fix(chat): add ip-based rate limiting to prevent session rotation abuse (#1436)
This commit is contained in:
parent
211cb96a96
commit
ad47aa0ee0
7 changed files with 87 additions and 5 deletions
|
|
@ -1,4 +1,5 @@
|
||||||
import { geolocation } from "@vercel/functions";
|
import { checkBotId } from "botid/server";
|
||||||
|
import { geolocation, ipAddress } from "@vercel/functions";
|
||||||
import {
|
import {
|
||||||
convertToModelMessages,
|
convertToModelMessages,
|
||||||
createUIMessageStream,
|
createUIMessageStream,
|
||||||
|
|
@ -31,6 +32,7 @@ import {
|
||||||
} from "@/lib/db/queries";
|
} from "@/lib/db/queries";
|
||||||
import type { DBMessage } from "@/lib/db/schema";
|
import type { DBMessage } from "@/lib/db/schema";
|
||||||
import { ChatbotError } from "@/lib/errors";
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
import { checkIpRateLimit } from "@/lib/ratelimit";
|
||||||
import type { ChatMessage } from "@/lib/types";
|
import type { ChatMessage } from "@/lib/types";
|
||||||
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
import { convertToUIMessages, generateUUID } from "@/lib/utils";
|
||||||
import { generateTitleFromUserMessage } from "../../actions";
|
import { generateTitleFromUserMessage } from "../../actions";
|
||||||
|
|
@ -62,12 +64,18 @@ export async function POST(request: Request) {
|
||||||
const { id, message, messages, selectedChatModel, selectedVisibilityType } =
|
const { id, message, messages, selectedChatModel, selectedVisibilityType } =
|
||||||
requestBody;
|
requestBody;
|
||||||
|
|
||||||
const session = await auth();
|
const [botResult, session] = await Promise.all([checkBotId(), auth()]);
|
||||||
|
|
||||||
|
if (botResult.isBot) {
|
||||||
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
|
}
|
||||||
|
|
||||||
if (!session?.user) {
|
if (!session?.user) {
|
||||||
return new ChatbotError("unauthorized:chat").toResponse();
|
return new ChatbotError("unauthorized:chat").toResponse();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await checkIpRateLimit(ipAddress(request));
|
||||||
|
|
||||||
const userType: UserType = session.user.type;
|
const userType: UserType = session.user.type;
|
||||||
|
|
||||||
const messageCount = await getMessageCountByUserId({
|
const messageCount = await getMessageCountByUserId({
|
||||||
|
|
|
||||||
10
instrumentation-client.ts
Normal file
10
instrumentation-client.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
import { initBotId } from "botid/client/core";
|
||||||
|
|
||||||
|
initBotId({
|
||||||
|
protect: [
|
||||||
|
{
|
||||||
|
path: "/api/chat",
|
||||||
|
method: "POST",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
@ -9,14 +9,14 @@ export const entitlementsByUserType: Record<UserType, Entitlements> = {
|
||||||
* For users without an account
|
* For users without an account
|
||||||
*/
|
*/
|
||||||
guest: {
|
guest: {
|
||||||
maxMessagesPerDay: 20,
|
maxMessagesPerDay: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* For users with an account
|
* For users with an account
|
||||||
*/
|
*/
|
||||||
regular: {
|
regular: {
|
||||||
maxMessagesPerDay: 50,
|
maxMessagesPerDay: 10,
|
||||||
},
|
},
|
||||||
|
|
||||||
/*
|
/*
|
||||||
|
|
|
||||||
42
lib/ratelimit.ts
Normal file
42
lib/ratelimit.ts
Normal file
|
|
@ -0,0 +1,42 @@
|
||||||
|
import { createClient } from "redis";
|
||||||
|
|
||||||
|
import { isProductionEnvironment } from "@/lib/constants";
|
||||||
|
import { ChatbotError } from "@/lib/errors";
|
||||||
|
|
||||||
|
const MAX_MESSAGES_PER_DAY = 10;
|
||||||
|
const TTL_SECONDS = 60 * 60 * 24;
|
||||||
|
|
||||||
|
let client: ReturnType<typeof createClient> | null = null;
|
||||||
|
|
||||||
|
function getClient() {
|
||||||
|
if (!client && process.env.REDIS_URL) {
|
||||||
|
client = createClient({ url: process.env.REDIS_URL });
|
||||||
|
client.on("error", () => {});
|
||||||
|
client.connect().catch(() => {
|
||||||
|
client = null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return client;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function checkIpRateLimit(ip: string | undefined) {
|
||||||
|
if (!isProductionEnvironment || !ip) return;
|
||||||
|
|
||||||
|
const redis = getClient();
|
||||||
|
if (!redis?.isReady) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const key = `ip-rate-limit:${ip}`;
|
||||||
|
const [count] = await redis
|
||||||
|
.multi()
|
||||||
|
.incr(key)
|
||||||
|
.expire(key, TTL_SECONDS, "NX")
|
||||||
|
.exec();
|
||||||
|
|
||||||
|
if (typeof count === "number" && count > MAX_MESSAGES_PER_DAY) {
|
||||||
|
throw new ChatbotError("rate_limit:chat");
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof ChatbotError) throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,5 @@
|
||||||
import type { NextConfig } from "next";
|
import type { NextConfig } from "next";
|
||||||
|
import { withBotId } from "botid/next/config";
|
||||||
|
|
||||||
const basePath = "/demo";
|
const basePath = "/demo";
|
||||||
|
|
||||||
|
|
@ -23,4 +24,4 @@ const nextConfig: NextConfig = {
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
export default nextConfig;
|
export default withBotId(nextConfig);
|
||||||
|
|
|
||||||
|
|
@ -49,6 +49,7 @@
|
||||||
"@xyflow/react": "^12.10.0",
|
"@xyflow/react": "^12.10.0",
|
||||||
"ai": "6.0.37",
|
"ai": "6.0.37",
|
||||||
"bcrypt-ts": "^5.0.2",
|
"bcrypt-ts": "^5.0.2",
|
||||||
|
"botid": "1.5.6",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"classnames": "^2.5.1",
|
"classnames": "^2.5.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
|
|
||||||
20
pnpm-lock.yaml
generated
20
pnpm-lock.yaml
generated
|
|
@ -101,6 +101,9 @@ importers:
|
||||||
bcrypt-ts:
|
bcrypt-ts:
|
||||||
specifier: ^5.0.2
|
specifier: ^5.0.2
|
||||||
version: 5.0.3
|
version: 5.0.3
|
||||||
|
botid:
|
||||||
|
specifier: 1.5.6
|
||||||
|
version: 1.5.6(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1)
|
||||||
class-variance-authority:
|
class-variance-authority:
|
||||||
specifier: ^0.7.1
|
specifier: ^0.7.1
|
||||||
version: 0.7.1
|
version: 0.7.1
|
||||||
|
|
@ -2460,6 +2463,7 @@ packages:
|
||||||
'@vercel/postgres@0.10.0':
|
'@vercel/postgres@0.10.0':
|
||||||
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
|
resolution: {integrity: sha512-fSD23DxGND40IzSkXjcFcxr53t3Tiym59Is0jSYIFpG4/0f0KO9SGtcp1sXiebvPaGe7N/tU05cH4yt2S6/IPg==}
|
||||||
engines: {node: '>=18.14'}
|
engines: {node: '>=18.14'}
|
||||||
|
deprecated: '@vercel/postgres is deprecated. If you are setting up a new database, you can choose an alternate storage solution from the Vercel Marketplace. If you had an existing Vercel Postgres database, it should have been migrated to Neon as a native Vercel integration. You can find more details and the guide to migrate to Neon''s SDKs here: https://neon.com/docs/guides/vercel-postgres-transition-guide'
|
||||||
|
|
||||||
'@xyflow/react@12.10.0':
|
'@xyflow/react@12.10.0':
|
||||||
resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==}
|
resolution: {integrity: sha512-eOtz3whDMWrB4KWVatIBrKuxECHqip6PfA8fTpaS2RUGVpiEAe+nqDKsLqkViVWxDGreq0lWX71Xth/SPAzXiw==}
|
||||||
|
|
@ -2503,6 +2507,17 @@ packages:
|
||||||
resolution: {integrity: sha512-2FcgD12xPbwCoe5i9/HK0jJ1xA1m+QfC1e6htG9Bl/hNOnLyaFmQSlqLKcfe3QdnoMPKpKEGFCbESBTg+SJNOw==}
|
resolution: {integrity: sha512-2FcgD12xPbwCoe5i9/HK0jJ1xA1m+QfC1e6htG9Bl/hNOnLyaFmQSlqLKcfe3QdnoMPKpKEGFCbESBTg+SJNOw==}
|
||||||
engines: {node: '>=18'}
|
engines: {node: '>=18'}
|
||||||
|
|
||||||
|
botid@1.5.6:
|
||||||
|
resolution: {integrity: sha512-KElecPjc1z6WJ6sCJMw6CvLo/CQclwlLJdobqmtOjVqvCXZZmaWvNafVDwBk0Kf5ordIaorDa3YkIx2OlGx7pg==}
|
||||||
|
peerDependencies:
|
||||||
|
next: '*'
|
||||||
|
react: ^18.0.0 || ^19.0.0
|
||||||
|
peerDependenciesMeta:
|
||||||
|
next:
|
||||||
|
optional: true
|
||||||
|
react:
|
||||||
|
optional: true
|
||||||
|
|
||||||
buffer-from@1.1.2:
|
buffer-from@1.1.2:
|
||||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||||
|
|
||||||
|
|
@ -5975,6 +5990,11 @@ snapshots:
|
||||||
|
|
||||||
bcrypt-ts@5.0.3: {}
|
bcrypt-ts@5.0.3: {}
|
||||||
|
|
||||||
|
botid@1.5.6(next@16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1))(react@19.0.1):
|
||||||
|
optionalDependencies:
|
||||||
|
next: 16.0.10(@opentelemetry/api@1.9.0)(@playwright/test@1.51.0)(react-dom@19.0.1(react@19.0.1))(react@19.0.1)
|
||||||
|
react: 19.0.1
|
||||||
|
|
||||||
buffer-from@1.1.2: {}
|
buffer-from@1.1.2: {}
|
||||||
|
|
||||||
bufferutil@4.0.9:
|
bufferutil@4.0.9:
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue