From 43644b685734579c6da058cacec6985a8326a624 Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 11:24:49 +0530 Subject: [PATCH 1/6] (lib): add rate limit to write endpoints --- .env.example | 6 ++++- app/api/health/route.ts | 15 ++++++++++- lib/audit-log.ts | 30 +++++++++++++++++++++ lib/ratelimit.ts | 60 +++++++++++++++++++++++++++++++++++++++++ lib/with-ratelimit.ts | 25 +++++++++++++++++ package.json | 2 ++ pnpm-lock.yaml | 36 +++++++++++++++++++++++++ 7 files changed, 172 insertions(+), 2 deletions(-) create mode 100644 lib/audit-log.ts create mode 100644 lib/ratelimit.ts create mode 100644 lib/with-ratelimit.ts diff --git a/.env.example b/.env.example index 32bd77f..451ec93 100644 --- a/.env.example +++ b/.env.example @@ -2,4 +2,8 @@ # MONGODB_URI=mongodb://localhost:27017/iiitl-alumni # MongoDB Atlas (production example) -# MONGODB_URI=mongodb+srv://:@cluster.mongodb.net/iiitl \ No newline at end of file +# MONGODB_URI=mongodb+srv://:@cluster.mongodb.net/iiitl + +# Upstash Redis +# UPSTASH_REDIS_REST_URL= +# UPSTASH_REDIS_REST_TOKEN= \ No newline at end of file diff --git a/app/api/health/route.ts b/app/api/health/route.ts index e6f794b..a21f6c5 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,4 +1,5 @@ import { connectDB } from "@/lib/db"; +import { withRateLimit } from "@/lib/with-ratelimit"; export async function GET() { const start = Date.now(); @@ -11,4 +12,16 @@ export async function GET() { status: "ok", latency, }); -} \ No newline at end of file +} + +export const POST = withRateLimit(async (req: Request) => { + // Create logic +}); + +export const PUT = withRateLimit(async (req: Request) => { + // Update logic +}); + +export const DELETE = withRateLimit(async (req: Request) => { + // Delete logic +}); \ No newline at end of file diff --git a/lib/audit-log.ts b/lib/audit-log.ts new file mode 100644 index 0000000..d86a546 --- /dev/null +++ b/lib/audit-log.ts @@ -0,0 +1,30 @@ +// import { connectDB } from "./db"; + +interface AuditLogParams { + severity: 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'; + event: string; + target: string; + message: string; +} + +export async function logToAudit(params: AuditLogParams) { + try { + // Establish/reuse the MongoDB connection + // await connectDB(); + + // Perform your database operation + // Have to create model for AuditLog first + // await AuditLog.create({ + // severity: params.severity, + // event: params.event, + // target: params.target, + // message: params.message, + // timestamp: new Date(), + // }); + + console.log("[AuditLog]", params.message); + + } catch (error) { + console.error("Failed to write to audit log:", error); + } +} \ No newline at end of file diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 0000000..293854e --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,60 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; +import { logToAudit } from './audit-log'; + +const redis = new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL!, + token: process.env.UPSTASH_REDIS_REST_TOKEN!, +}); + +// Define valid window formats expected by Upstash +type Unit = 'ms' | 's' | 'm' | 'h' | 'd'; +type Duration = `${number} ${Unit}` | `${number}${Unit}`; + +interface RateLimitConfig { + maxRequests: number; + window: Duration; +} + +export async function limit(key: string, config: RateLimitConfig) { + // Initialize the sliding window limiter with the custom config + const ratelimit = new Ratelimit({ + redis, + limiter: Ratelimit.slidingWindow(config.maxRequests, config.window), + analytics: true, + }); + + const result = await ratelimit.limit(key); + + // Calculate standard Retry-After in seconds + const retryAfterSeconds = Math.ceil((result.reset - Date.now()) / 1000); + + // Handle breach logging + if (!result.success) { + const breachKey = `breach_count:${key}`; + + // Increment a separate counter for breaches, expiring after 1 hour + const breachCount = await redis.incr(breachKey); + if (breachCount === 1) { + await redis.expire(breachKey, 3600); + } + + // If they hit the 429 limit 3+ times in an hour, escalate to AuditLog + if (breachCount >= 3) { + await logToAudit({ + severity: 'WARNING', + event: 'CONSISTENT_RATE_LIMIT_BREACH', + target: key, + message: `Key breached rate limits ${breachCount} times within the hour.`, + }); + } + } + + return { + success: result.success, + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + retryAfter: retryAfterSeconds > 0 ? retryAfterSeconds : 1, + }; +} \ No newline at end of file diff --git a/lib/with-ratelimit.ts b/lib/with-ratelimit.ts new file mode 100644 index 0000000..7ca807f --- /dev/null +++ b/lib/with-ratelimit.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { limit } from "./ratelimit"; + +export function withRateLimit(handler: Function) { + return async (req: NextRequest, ...args: any[]) => { + // Determine the user's IP or identifier to use as the rate limit key + const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "127.0.0.1"; + + // Call your limit function (allow e.g. 5 requests per minute) + const { success, retryAfter } = await limit(ip, { + maxRequests: 5, + window: "1 m", + }); + + if (!success) { + return NextResponse.json( + { error: "Too many requests. Please try again later." }, + { status: 429, headers: { "Retry-After": retryAfter.toString() } } + ); + } + + // If rate limit checks pass, execute the actual API route logic + return handler(req, ...args); + }; +} \ No newline at end of file diff --git a/package.json b/package.json index 9f1a2c5..7e59a10 100644 --- a/package.json +++ b/package.json @@ -10,6 +10,8 @@ "db:seed": "node scripts/seed.js" }, "dependencies": { + "@upstash/ratelimit": "^2.0.8", + "@upstash/redis": "^1.37.0", "dotenv": "^17.4.1", "mongoose": "^9.4.1", "next": "16.2.3", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b7f286f..11cf8a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,6 +8,12 @@ importers: .: dependencies: + '@upstash/ratelimit': + specifier: ^2.0.8 + version: 2.0.8(@upstash/redis@1.37.0) + '@upstash/redis': + specifier: ^1.37.0 + version: 1.37.0 dotenv: specifier: ^17.4.1 version: 17.4.1 @@ -723,6 +729,18 @@ packages: cpu: [x64] os: [win32] + '@upstash/core-analytics@0.0.10': + resolution: {integrity: sha512-7qJHGxpQgQr9/vmeS1PktEwvNAF7TI4iJDi8Pu2CFZ9YUGHZH4fOP5TfYlZ4aVxfopnELiE4BS4FBjyK7V1/xQ==} + engines: {node: '>=16.0.0'} + + '@upstash/ratelimit@2.0.8': + resolution: {integrity: sha512-YSTMBJ1YIxsoPkUMX/P4DDks/xV5YYCswWMamU8ZIfK9ly6ppjRnVOyBhMDXBmzjODm4UQKcxsJPvaeFAijp5w==} + peerDependencies: + '@upstash/redis': ^1.34.3 + + '@upstash/redis@1.37.0': + resolution: {integrity: sha512-LqOJ3+XWPLSZ2rGSed5DYG3ixybxb8EhZu3yQqF7MdZX1wLBG/FRcI6xcUZXHy/SS7mmXWyadrud0HJHkOc+uw==} + acorn-jsx@5.3.2: resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} peerDependencies: @@ -1977,6 +1995,9 @@ packages: resolution: {integrity: sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==} engines: {node: '>= 0.4'} + uncrypto@0.1.3: + resolution: {integrity: sha512-Ql87qFHB3s/De2ClA9e0gsnS6zXG27SkTiSJwjCc9MebbfapQfuPzumMIUMi38ezPZVNFcHI9sUIepeQfw8J8Q==} + undici-types@6.21.0: resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} @@ -2643,6 +2664,19 @@ snapshots: '@unrs/resolver-binding-win32-x64-msvc@1.11.1': optional: true + '@upstash/core-analytics@0.0.10': + dependencies: + '@upstash/redis': 1.37.0 + + '@upstash/ratelimit@2.0.8(@upstash/redis@1.37.0)': + dependencies: + '@upstash/core-analytics': 0.0.10 + '@upstash/redis': 1.37.0 + + '@upstash/redis@1.37.0': + dependencies: + uncrypto: 0.1.3 + acorn-jsx@5.3.2(acorn@8.16.0): dependencies: acorn: 8.16.0 @@ -4120,6 +4154,8 @@ snapshots: has-symbols: 1.1.0 which-boxed-primitive: 1.1.1 + uncrypto@0.1.3: {} + undici-types@6.21.0: {} unrs-resolver@1.11.1: From ba12d90e8417d67a3dbc46df5ccb850dd89040a2 Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 11:38:37 +0530 Subject: [PATCH 2/6] fix linting errors --- app/api/health/route.ts | 9 ++++++--- lib/with-ratelimit.ts | 6 ++++-- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index a21f6c5..786bc2e 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -14,14 +14,17 @@ export async function GET() { }); } -export const POST = withRateLimit(async (req: Request) => { +export const POST = withRateLimit(async (_req: Request) => { // Create logic + return Response.json({ success: true }); }); -export const PUT = withRateLimit(async (req: Request) => { +export const PUT = withRateLimit(async (_req: Request) => { // Update logic + return Response.json({ success: true }); }); -export const DELETE = withRateLimit(async (req: Request) => { +export const DELETE = withRateLimit(async (_req: Request) => { // Delete logic + return Response.json({ success: true }); }); \ No newline at end of file diff --git a/lib/with-ratelimit.ts b/lib/with-ratelimit.ts index 7ca807f..b3abbb0 100644 --- a/lib/with-ratelimit.ts +++ b/lib/with-ratelimit.ts @@ -1,8 +1,10 @@ import { NextRequest, NextResponse } from "next/server"; import { limit } from "./ratelimit"; -export function withRateLimit(handler: Function) { - return async (req: NextRequest, ...args: any[]) => { +export function withRateLimit( + handler: (req: NextRequest, ...args: T) => Promise | Response +) { + return async (req: NextRequest, ...args: T) => { // Determine the user's IP or identifier to use as the rate limit key const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "127.0.0.1"; From 58dfcb49616d594568c5979eaa4c646d13eead8e Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 12:39:56 +0530 Subject: [PATCH 3/6] Remove write methods from api/health/route.ts. Fix audit flooding issue. --- app/api/health/route.ts | 17 +---------------- lib/ratelimit.ts | 2 +- lib/with-ratelimit.ts | 19 +++++++++++++++++++ 3 files changed, 21 insertions(+), 17 deletions(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 786bc2e..6cae8e0 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -12,19 +12,4 @@ export async function GET() { status: "ok", latency, }); -} - -export const POST = withRateLimit(async (_req: Request) => { - // Create logic - return Response.json({ success: true }); -}); - -export const PUT = withRateLimit(async (_req: Request) => { - // Update logic - return Response.json({ success: true }); -}); - -export const DELETE = withRateLimit(async (_req: Request) => { - // Delete logic - return Response.json({ success: true }); -}); \ No newline at end of file +} \ No newline at end of file diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts index 293854e..4deb09c 100644 --- a/lib/ratelimit.ts +++ b/lib/ratelimit.ts @@ -40,7 +40,7 @@ export async function limit(key: string, config: RateLimitConfig) { } // If they hit the 429 limit 3+ times in an hour, escalate to AuditLog - if (breachCount >= 3) { + if (breachCount === 3) { await logToAudit({ severity: 'WARNING', event: 'CONSISTENT_RATE_LIMIT_BREACH', diff --git a/lib/with-ratelimit.ts b/lib/with-ratelimit.ts index b3abbb0..be2af8e 100644 --- a/lib/with-ratelimit.ts +++ b/lib/with-ratelimit.ts @@ -1,6 +1,25 @@ import { NextRequest, NextResponse } from "next/server"; import { limit } from "./ratelimit"; +/** + * Wraps a Next.js API route handler to apply rate limiting. + * + * @example + * export const POST = withRateLimit(async (req: NextRequest) => { + * // Create logic + * return Response.json({ success: true }); + * }); + * + * export const PUT = withRateLimit(async (req: NextRequest) => { + * // Update logic + * return Response.json({ success: true }); + * }); + * + * export const DELETE = withRateLimit(async (req: NextRequest) => { + * // Delete logic + * return Response.json({ success: true }); + * }); + */ export function withRateLimit( handler: (req: NextRequest, ...args: T) => Promise | Response ) { From a49be9fa2be38062a3f868471d11fd47242231cd Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 12:41:30 +0530 Subject: [PATCH 4/6] fix lint issue --- app/api/health/route.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/app/api/health/route.ts b/app/api/health/route.ts index 6cae8e0..e6f794b 100644 --- a/app/api/health/route.ts +++ b/app/api/health/route.ts @@ -1,5 +1,4 @@ import { connectDB } from "@/lib/db"; -import { withRateLimit } from "@/lib/with-ratelimit"; export async function GET() { const start = Date.now(); From a903eb5a527afb8ac3290a46fc5ebf10b4bc52e9 Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 17:18:45 +0530 Subject: [PATCH 5/6] Implement better error handling, rate limit initialization and remove audit log --- lib/audit-log.ts | 30 ------------ lib/ratelimit.ts | 109 +++++++++++++++++++++++++----------------- lib/with-ratelimit.ts | 7 ++- 3 files changed, 69 insertions(+), 77 deletions(-) delete mode 100644 lib/audit-log.ts diff --git a/lib/audit-log.ts b/lib/audit-log.ts deleted file mode 100644 index d86a546..0000000 --- a/lib/audit-log.ts +++ /dev/null @@ -1,30 +0,0 @@ -// import { connectDB } from "./db"; - -interface AuditLogParams { - severity: 'INFO' | 'WARNING' | 'ERROR' | 'CRITICAL'; - event: string; - target: string; - message: string; -} - -export async function logToAudit(params: AuditLogParams) { - try { - // Establish/reuse the MongoDB connection - // await connectDB(); - - // Perform your database operation - // Have to create model for AuditLog first - // await AuditLog.create({ - // severity: params.severity, - // event: params.event, - // target: params.target, - // message: params.message, - // timestamp: new Date(), - // }); - - console.log("[AuditLog]", params.message); - - } catch (error) { - console.error("Failed to write to audit log:", error); - } -} \ No newline at end of file diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts index 4deb09c..84f4c1d 100644 --- a/lib/ratelimit.ts +++ b/lib/ratelimit.ts @@ -1,13 +1,13 @@ import { Ratelimit } from '@upstash/ratelimit'; import { Redis } from '@upstash/redis'; -import { logToAudit } from './audit-log'; -const redis = new Redis({ - url: process.env.UPSTASH_REDIS_REST_URL!, - token: process.env.UPSTASH_REDIS_REST_TOKEN!, -}); +const redisClient = (process.env.UPSTASH_REDIS_REST_URL && process.env.UPSTASH_REDIS_REST_TOKEN) + ? new Redis({ + url: process.env.UPSTASH_REDIS_REST_URL, + token: process.env.UPSTASH_REDIS_REST_TOKEN, + }) + : null; -// Define valid window formats expected by Upstash type Unit = 'ms' | 's' | 'm' | 'h' | 'd'; type Duration = `${number} ${Unit}` | `${number}${Unit}`; @@ -16,45 +16,68 @@ interface RateLimitConfig { window: Duration; } +const ratelimitCache = new Map(); + export async function limit(key: string, config: RateLimitConfig) { - // Initialize the sliding window limiter with the custom config - const ratelimit = new Ratelimit({ - redis, - limiter: Ratelimit.slidingWindow(config.maxRequests, config.window), - analytics: true, - }); - - const result = await ratelimit.limit(key); - - // Calculate standard Retry-After in seconds - const retryAfterSeconds = Math.ceil((result.reset - Date.now()) / 1000); - - // Handle breach logging - if (!result.success) { - const breachKey = `breach_count:${key}`; - - // Increment a separate counter for breaches, expiring after 1 hour - const breachCount = await redis.incr(breachKey); - if (breachCount === 1) { - await redis.expire(breachKey, 3600); - } + if (!redisClient) { + console.warn("Rate limiting bypassed: UPSTASH_REDIS credentials are missing."); + return { success: true, limit: config.maxRequests, remaining: config.maxRequests, reset: 0, retryAfter: 0 }; + } - // If they hit the 429 limit 3+ times in an hour, escalate to AuditLog - if (breachCount === 3) { - await logToAudit({ - severity: 'WARNING', - event: 'CONSISTENT_RATE_LIMIT_BREACH', - target: key, - message: `Key breached rate limits ${breachCount} times within the hour.`, - }); - } + // Retrieve existing rate limiter from cache, or create and cache a new one for this config + const cacheKey = `${config.maxRequests}-${config.window}`; + let ratelimit = ratelimitCache.get(cacheKey); + + if (!ratelimit) { + ratelimit = new Ratelimit({ + redis: redisClient, + limiter: Ratelimit.slidingWindow(config.maxRequests, config.window), + analytics: true, + }); + ratelimitCache.set(cacheKey, ratelimit); } - return { - success: result.success, - limit: result.limit, - remaining: result.remaining, - reset: result.reset, - retryAfter: retryAfterSeconds > 0 ? retryAfterSeconds : 1, - }; + try { + const result = await ratelimit.limit(key); + + // Calculate standard Retry-After in seconds + const retryAfterSeconds = Math.max(1, Math.ceil((result.reset - Date.now()) / 1000)); + + if (!result.success) { + const breachKey = `breach_count:${key}`; + + const pipeline = redisClient.pipeline(); + pipeline.incr(breachKey); + pipeline.expire(breachKey, 3600); + + const results = await pipeline.exec(); + const breachCount = results[0] as number; + + if (breachCount === 3) { + console.log({ + severity: 'WARNING', + event: 'CONSISTENT_RATE_LIMIT_BREACH', + target: key, + message: `Key breached rate limits ${breachCount} times within the hour.`, + }); + } + } + + return { + success: result.success, + limit: result.limit, + remaining: result.remaining, + reset: result.reset, + retryAfter: retryAfterSeconds, + }; + } catch (error) { + console.error("Rate limiting error:", error); + return { + success: true, + limit: config.maxRequests, + remaining: config.maxRequests, + reset: 0, + retryAfter: 0, + }; + } } \ No newline at end of file diff --git a/lib/with-ratelimit.ts b/lib/with-ratelimit.ts index be2af8e..dac27c3 100644 --- a/lib/with-ratelimit.ts +++ b/lib/with-ratelimit.ts @@ -24,11 +24,11 @@ export function withRateLimit( handler: (req: NextRequest, ...args: T) => Promise | Response ) { return async (req: NextRequest, ...args: T) => { - // Determine the user's IP or identifier to use as the rate limit key const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "127.0.0.1"; + + const identifier = `${req.nextUrl.pathname}:${ip}`; - // Call your limit function (allow e.g. 5 requests per minute) - const { success, retryAfter } = await limit(ip, { + const { success, retryAfter } = await limit(identifier, { maxRequests: 5, window: "1 m", }); @@ -40,7 +40,6 @@ export function withRateLimit( ); } - // If rate limit checks pass, execute the actual API route logic return handler(req, ...args); }; } \ No newline at end of file From 0f3d9d9fa91fdf871590942f30632de1804e5beb Mon Sep 17 00:00:00 2001 From: Ewan-Dkhar Date: Sun, 12 Apr 2026 17:40:16 +0530 Subject: [PATCH 6/6] Update error handling for rate limiting under redis failures --- lib/ratelimit.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts index 84f4c1d..cf4fd43 100644 --- a/lib/ratelimit.ts +++ b/lib/ratelimit.ts @@ -73,11 +73,11 @@ export async function limit(key: string, config: RateLimitConfig) { } catch (error) { console.error("Rate limiting error:", error); return { - success: true, + success: false, limit: config.maxRequests, - remaining: config.maxRequests, + remaining: 0, reset: 0, - retryAfter: 0, + retryAfter: 60, }; } } \ No newline at end of file