From 74fa0d24c33b1cd9bf4c1f6ac19ec88e8ca8ff85 Mon Sep 17 00:00:00 2001 From: Mohammed Rayan A Date: Wed, 15 Jul 2026 23:49:27 +0530 Subject: [PATCH] fix: add API rate limiting via Upstash Redis (#3) --- app/api/_utils/rateLimit.ts | 149 ++++++++++++++++++++++++++++++ app/api/ai/ask/route.ts | 5 + app/api/public-resources/route.ts | 6 ++ app/api/resources/route.ts | 9 ++ package-lock.json | 58 +++++++++++- package.json | 6 +- 6 files changed, 230 insertions(+), 3 deletions(-) create mode 100644 app/api/_utils/rateLimit.ts diff --git a/app/api/_utils/rateLimit.ts b/app/api/_utils/rateLimit.ts new file mode 100644 index 0000000..d5d1819 --- /dev/null +++ b/app/api/_utils/rateLimit.ts @@ -0,0 +1,149 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; +import { NextRequest, NextResponse } from 'next/server'; + +// --------------------------------------------------------------------------- +// Upstash Redis client +// Requires env vars: UPSTASH_REDIS_REST_URL + UPSTASH_REDIS_REST_TOKEN +// --------------------------------------------------------------------------- +function getRedis(): Redis | null { + const url = process.env.UPSTASH_REDIS_REST_URL; + const token = process.env.UPSTASH_REDIS_REST_TOKEN; + if (!url || !token) return null; + return new Redis({ url, token }); +} + +// --------------------------------------------------------------------------- +// Rate limiters (created lazily; only when Redis credentials are present) +// --------------------------------------------------------------------------- +let _authenticatedLimiter: Ratelimit | null = null; +let _publicLimiter: Ratelimit | null = null; +let _aiLimiter: Ratelimit | null = null; + +function getAuthenticatedLimiter(): Ratelimit | null { + if (_authenticatedLimiter) return _authenticatedLimiter; + const redis = getRedis(); + if (!redis) return null; + _authenticatedLimiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(60, '1 m'), // 60 req/min for authenticated users + prefix: 'rl:auth', + analytics: false, + }); + return _authenticatedLimiter; +} + +function getPublicLimiter(): Ratelimit | null { + if (_publicLimiter) return _publicLimiter; + const redis = getRedis(); + if (!redis) return null; + _publicLimiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(20, '1 m'), // 20 req/min for public/anonymous + prefix: 'rl:public', + analytics: false, + }); + return _publicLimiter; +} + +function getAiLimiter(): Ratelimit | null { + if (_aiLimiter) return _aiLimiter; + const redis = getRedis(); + if (!redis) return null; + _aiLimiter = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(10, '1 m'), // 10 req/min for AI endpoints + prefix: 'rl:ai', + analytics: false, + }); + return _aiLimiter; +} + +// --------------------------------------------------------------------------- +// Identifier helpers +// --------------------------------------------------------------------------- +function getClientIp(request: NextRequest): string { + return ( + request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || + request.headers.get('x-real-ip') || + 'unknown' + ); +} + +// --------------------------------------------------------------------------- +// Rate-limit check helpers +// Returns a 429 NextResponse when limit is exceeded, null otherwise. +// If Redis is not configured, rate limiting is silently skipped (fail-open). +// --------------------------------------------------------------------------- + +export async function checkAuthenticatedRateLimit( + _request: NextRequest, + userId: string, +): Promise { + const limiter = getAuthenticatedLimiter(); + if (!limiter) return null; // Redis not configured – skip + + const { success, limit, remaining, reset } = await limiter.limit(userId); + if (success) return null; + + return NextResponse.json( + { error: 'Too many requests. Please slow down.' }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': String(remaining), + 'X-RateLimit-Reset': String(reset), + 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), + }, + }, + ); +} + +export async function checkPublicRateLimit( + request: NextRequest, +): Promise { + const limiter = getPublicLimiter(); + if (!limiter) return null; + + const ip = getClientIp(request); + const { success, limit, remaining, reset } = await limiter.limit(ip); + if (success) return null; + + return NextResponse.json( + { error: 'Too many requests. Please slow down.' }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': String(remaining), + 'X-RateLimit-Reset': String(reset), + 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), + }, + }, + ); +} + +export async function checkAiRateLimit( + _request: NextRequest, + userId: string, +): Promise { + const limiter = getAiLimiter(); + if (!limiter) return null; + + const { success, limit, remaining, reset } = await limiter.limit(userId); + if (success) return null; + + return NextResponse.json( + { error: 'AI rate limit exceeded. Please wait before sending another query.' }, + { + status: 429, + headers: { + 'X-RateLimit-Limit': String(limit), + 'X-RateLimit-Remaining': String(remaining), + 'X-RateLimit-Reset': String(reset), + 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), + }, + }, + ); +} diff --git a/app/api/ai/ask/route.ts b/app/api/ai/ask/route.ts index 362c302..df46819 100644 --- a/app/api/ai/ask/route.ts +++ b/app/api/ai/ask/route.ts @@ -2,10 +2,15 @@ import { NextRequest, NextResponse } from 'next/server'; import { buildAnswerContext, normalizeSearchMode, searchResourceChunks } from '../../_utils/aiSearch'; import { isAuthError, requireAuth, unauthorizedResponse } from '../../_utils/auth'; import { generateAnswer } from '../../_utils/gemini'; +import { checkAiRateLimit } from '../../_utils/rateLimit'; export async function POST(request: NextRequest) { try { const authUser = await requireAuth(request); + + // Rate limit: 10 AI queries per minute per authenticated user + const rateLimitResponse = await checkAiRateLimit(request, authUser.uid); + if (rateLimitResponse) return rateLimitResponse; const { question, mode, limit } = await request.json(); if (!question || typeof question !== 'string') { diff --git a/app/api/public-resources/route.ts b/app/api/public-resources/route.ts index 9bb50f4..e4aa663 100644 --- a/app/api/public-resources/route.ts +++ b/app/api/public-resources/route.ts @@ -2,11 +2,17 @@ import { NextRequest, NextResponse } from 'next/server'; import { isAuthError, requireAuth, unauthorizedResponse } from '../_utils/auth'; import { getServerFirestore } from '../_utils/firebaseAdmin'; import { indexResource } from '../_utils/resourceIndexer'; +import { checkAuthenticatedRateLimit } from '../_utils/rateLimit'; // GET /api/public-resources - Get public resources from other users export async function GET(request: NextRequest) { try { const authUser = await requireAuth(request); + + // Rate limit: 60 authenticated requests per minute per user + const rateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid); + if (rateLimitResponse) return rateLimitResponse; + const db = getServerFirestore(); // Get all public resources except current user's diff --git a/app/api/resources/route.ts b/app/api/resources/route.ts index 25715f0..7b992ec 100644 --- a/app/api/resources/route.ts +++ b/app/api/resources/route.ts @@ -2,6 +2,7 @@ import { NextRequest, NextResponse } from 'next/server'; import { isAuthError, requireAuth, unauthorizedResponse } from '../_utils/auth'; import { getServerFirestore } from '../_utils/firebaseAdmin'; import { getPreviewFromUrl } from '../_utils/linkPreview'; +import { checkAuthenticatedRateLimit, checkPublicRateLimit } from '../_utils/rateLimit'; import { indexResource } from '../_utils/resourceIndexer'; // GET /api/resources - Get the authenticated user's resources @@ -14,6 +15,9 @@ export async function GET(request: NextRequest) { const db = getServerFirestore(); if (username && isPublicParam) { + // Rate limit: 20 public requests per minute per IP + const rateLimitResponse = await checkPublicRateLimit(request); + if (rateLimitResponse) return rateLimitResponse; const usernameQuery = await db.collection('users') .where('username', '==', username.toLowerCase()) .get(); @@ -44,6 +48,11 @@ export async function GET(request: NextRequest) { } const authUser = await requireAuth(request); + + // Rate limit: 60 authenticated requests per minute per user + const authRateLimitResponse = await checkAuthenticatedRateLimit(request, authUser.uid); + if (authRateLimitResponse) return authRateLimitResponse; + const collectionId = searchParams.get('collectionId'); let query: FirebaseFirestore.Query = db.collection('resources') diff --git a/package-lock.json b/package-lock.json index 1a478bc..8fae5e3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,8 @@ "name": "dumpit", "version": "0.0.1", "dependencies": { + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "firebase": "^9.23.0", @@ -646,6 +648,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.9.13.tgz", "integrity": "sha512-GfiI1JxJ7ecluEmDjPzseRXk/PX31hS7+tjgBopL7XjB2hLUdR+0FTMXy2Q3/hXezypDvU6or7gVFizDESrkXw==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/component": "0.6.4", "@firebase/logger": "0.4.0", @@ -703,6 +706,7 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.2.13.tgz", "integrity": "sha512-j6ANZaWjeVy5zg6X7uiqh6lM6o3n3LD1+/SJFNs9V781xyryyZWXe+tmnWNWPkP086QfJoNkWN9pMQRqSG4vMg==", "license": "Apache-2.0", + "peer": true, "dependencies": { "@firebase/app": "0.9.13", "@firebase/component": "0.6.4", @@ -715,7 +719,8 @@ "version": "0.9.0", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.0.tgz", "integrity": "sha512-AeweANOIo0Mb8GiYm3xhTEBVCmPwTYAu9Hcd2qSkLuga/6+j9b1Jskl5bpiSQWy9eJ/j5pavxj6eYogmnuzm+Q==", - "license": "Apache-2.0" + "license": "Apache-2.0", + "peer": true }, "node_modules/@firebase/auth": { "version": "0.23.2", @@ -1125,6 +1130,7 @@ "resolved": "https://registry.npmjs.org/@firebase/util/-/util-1.9.3.tgz", "integrity": "sha512-DY02CRhOZwpzO36fHpuVysz6JZrscPiBXD0fXp6qSrL9oNOx5KWICKdR95C0lSITzxp0TZosVyHqzatE8JbcjA==", "license": "Apache-2.0", + "peer": true, "dependencies": { "tslib": "^2.1.0" } @@ -2337,6 +2343,7 @@ "integrity": "sha512-RFA/bURkcKzx/X9oumPG9Vp3D3JUgus/d0b67KB0t5S/raciymilkOa66olh78MUI92QLbEJevO7rvqU/kjwKA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@types/prop-types": "*", "csstype": "^3.0.2" @@ -2448,6 +2455,7 @@ "integrity": "sha512-6JSSaBZmsKvEkbRUkf7Zj7dru/8ZCrJxAqArcLaVMee5907JdtEbKGsZ7zNiIm/UAkpGUkaSMZEXShnN2D1HZA==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@typescript-eslint/scope-manager": "8.46.1", "@typescript-eslint/types": "8.46.1", @@ -2660,6 +2668,40 @@ "url": "https://opencollective.com/typescript-eslint" } }, + "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/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", + "peer": true, + "dependencies": { + "uncrypto": "^0.1.3" + } + }, "node_modules/@vitest/expect": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-1.6.1.tgz", @@ -2782,6 +2824,7 @@ "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3085,6 +3128,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "baseline-browser-mapping": "^2.8.9", "caniuse-lite": "^1.0.30001746", @@ -3848,6 +3892,7 @@ "integrity": "sha512-XyLmROnACWqSxiGYArdef1fItQd47weqB7iwtfr9JHwRrqIXZdcFMvvEcL9xHCmL0SNsOvF0c42lWyM1U5dgig==", "dev": true, "license": "MIT", + "peer": true, "dependencies": { "@eslint-community/eslint-utils": "^4.8.0", "@eslint-community/regexpp": "^4.12.1", @@ -5317,6 +5362,7 @@ "integrity": "sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==", "dev": true, "license": "MIT", + "peer": true, "bin": { "jiti": "bin/jiti.js" } @@ -6335,6 +6381,7 @@ } ], "license": "MIT", + "peer": true, "dependencies": { "nanoid": "^3.3.11", "picocolors": "^1.1.1", @@ -6632,6 +6679,7 @@ "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0" }, @@ -6644,6 +6692,7 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", "license": "MIT", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.2" @@ -7488,6 +7537,7 @@ "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", "dev": true, "license": "Apache-2.0", + "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -7527,6 +7577,12 @@ "dev": true, "license": "MIT" }, + "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": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/package.json b/package.json index 8b41782..3acbdd3 100644 --- a/package.json +++ b/package.json @@ -12,6 +12,8 @@ "typecheck": "tsc --noEmit" }, "dependencies": { + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.38.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "firebase": "^9.23.0", @@ -36,7 +38,7 @@ "postcss": "^8.4.35", "tailwindcss": "^3.4.1", "typescript": "^5.5.3", - "vitest": "^1.0.0", - "typescript-eslint": "^8.3.0" + "typescript-eslint": "^8.3.0", + "vitest": "^1.0.0" } }