From bed9080cd3dbb2bdcc3e6643c11d57270a1e0584 Mon Sep 17 00:00:00 2001 From: Muawiya-contact Date: Mon, 6 Jul 2026 23:26:07 +0500 Subject: [PATCH] feat(chat): rate limit and validate /api/chat to protect the OpenAI budget Adds IP-based rate limiting (Upstash Redis, no-op until env vars are set) and Zod validation that returns a clean 400 on malformed bodies instead of an unhandled 500. Fixes #68 --- app/api/chat/route.tsx | 38 +++++++++++++++++++++++++++++++++++++- lib/ratelimit.ts | 32 ++++++++++++++++++++++++++++++++ package-lock.json | 41 +++++++++++++++++++++++++++++++++++++++++ package.json | 2 ++ 4 files changed, 112 insertions(+), 1 deletion(-) create mode 100644 lib/ratelimit.ts diff --git a/app/api/chat/route.tsx b/app/api/chat/route.tsx index 8307ec5..d87bfb1 100644 --- a/app/api/chat/route.tsx +++ b/app/api/chat/route.tsx @@ -2,11 +2,47 @@ import { openai } from "@ai-sdk/openai" import { streamText } from "ai" import { findRelevantContent, findRelevantRepresentatives, detectQueryTypes } from "@/lib/ai/embedding" +import { NextResponse } from "next/server" +import { z } from "zod" +import { ratelimit, getClientIp } from "@/lib/ratelimit" export const maxDuration = 30 +const chatRequestSchema = z.object({ + messages: z + .array( + z + .object({ + role: z.enum(["system", "user", "assistant", "data"]), + content: z.string(), + }) + .passthrough() + ) + .min(1), +}) + export async function POST(req: Request) { - const { messages } = await req.json() + // Throttle by client IP. No-op when Upstash Redis env vars are unset, so + // local dev and preview deployments keep working without extra setup. + if (ratelimit) { + const { success } = await ratelimit.limit(getClientIp(req)) + if (!success) { + return NextResponse.json( + { error: "Too many requests. Please slow down and try again shortly." }, + { status: 429 } + ) + } + } + + // Validate the body before use so a malformed payload returns a clean 400 + // instead of throwing an unhandled 500 on messages[messages.length - 1]. + let messages + try { + const body = await req.json() + messages = chatRequestSchema.parse(body).messages + } catch { + return NextResponse.json({ error: "Invalid request body." }, { status: 400 }) + } // Get relevant content for the last message const lastMessage = messages[messages.length - 1] diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 0000000..a5be72e --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,32 @@ +import { Ratelimit } from "@upstash/ratelimit" +import { Redis } from "@upstash/redis" + +// Rate limiting uses Upstash Redis (already used elsewhere via QStash). It is +// enabled only when both env vars are present, which keeps local development +// and preview builds working without a Redis instance while still protecting +// production. An in-memory limiter is not an option because each serverless +// invocation runs in isolation and would not share counters. +const url = process.env.UPSTASH_REDIS_REST_URL +const token = process.env.UPSTASH_REDIS_REST_TOKEN + +export const ratelimit = + url && token + ? new Ratelimit({ + redis: new Redis({ url, token }), + // 20 requests per minute per IP, sliding window. + limiter: Ratelimit.slidingWindow(20, "1 m"), + prefix: "numainda:chat", + analytics: true, + }) + : null + +/** + * Best-effort client IP from proxy headers (Vercel sets x-forwarded-for). + * Falls back to a constant bucket so requests without an IP are still limited + * as a group rather than bypassing the limiter entirely. + */ +export function getClientIp(req: Request): string { + const forwarded = req.headers.get("x-forwarded-for") + if (forwarded) return forwarded.split(",")[0].trim() + return req.headers.get("x-real-ip") ?? "anonymous" +} \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index b91a085..1e3aaa5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,8 @@ "@supabase/supabase-js": "^2.46.2", "@t3-oss/env-nextjs": "^0.10.1", "@upstash/qstash": "^2.7.23", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.4.1", "ai": "^4.0.4", "class-variance-authority": "^0.7.1", @@ -7586,6 +7588,18 @@ "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.2.0.tgz", "integrity": "sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==" }, + "node_modules/@upstash/core-analytics": { + "version": "0.0.10", + "resolved": "https://registry.npmjs.org/@upstash/core-analytics/-/core-analytics-0.0.10.tgz", + "integrity": "sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==", + "license": "MIT", + "dependencies": { + "@upstash/redis": "^1.28.3" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@upstash/qstash": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/@upstash/qstash/-/qstash-2.8.4.tgz", @@ -7597,6 +7611,27 @@ "neverthrow": "^7.0.1" } }, + "node_modules/@upstash/ratelimit": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/@upstash/ratelimit/-/ratelimit-2.0.8.tgz", + "integrity": "sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==", + "license": "MIT", + "dependencies": { + "@upstash/core-analytics": "^0.0.10" + }, + "peerDependencies": { + "@upstash/redis": "^1.34.3" + } + }, + "node_modules/@upstash/redis": { + "version": "1.38.0", + "resolved": "https://registry.npmjs.org/@upstash/redis/-/redis-1.38.0.tgz", + "integrity": "sha512-wu+dZBptlLy0+MCUEoHmzrY/TnmgDey3+c7EbIGwrLqAvkP8yi5MWZHYGIFtAygmL4Bkz2TdFu+eU0vFPncIcg==", + "license": "MIT", + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vercel/analytics": { "version": "1.4.1", "resolved": "https://registry.npmjs.org/@vercel/analytics/-/analytics-1.4.1.tgz", @@ -18029,6 +18064,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/uncrypto": { + "version": "0.1.3", + "resolved": "https://registry.npmjs.org/uncrypto/-/uncrypto-0.1.3.tgz", + "integrity": "sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==", + "license": "MIT" + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", diff --git a/package.json b/package.json index 2969a8d..0b49322 100644 --- a/package.json +++ b/package.json @@ -47,6 +47,8 @@ "@supabase/supabase-js": "^2.46.2", "@t3-oss/env-nextjs": "^0.10.1", "@upstash/qstash": "^2.7.23", + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "@vercel/analytics": "^1.4.1", "ai": "^4.0.4", "class-variance-authority": "^0.7.1",