diff --git a/.env.example b/.env.example index f5a4275..0b69b12 100644 --- a/.env.example +++ b/.env.example @@ -4,8 +4,12 @@ # MongoDB Atlas (production example) # MONGODB_URI=mongodb+srv://:@cluster.mongodb.net/iiitl +# Upstash Redis +# UPSTASH_REDIS_REST_URL= +# UPSTASH_REDIS_REST_TOKEN= + # Mailgun Configuration MAILGUN_API_KEY="" MAILGUN_DOMAIN="" # MAILGUN_URL="https://api.eu.mailgun.net" # Uncomment if using an EU region domain -EMAIL_FROM="no-reply@yourdomain.com" \ No newline at end of file +EMAIL_FROM="no-reply@yourdomain.com" diff --git a/lib/ratelimit.ts b/lib/ratelimit.ts new file mode 100644 index 0000000..cf4fd43 --- /dev/null +++ b/lib/ratelimit.ts @@ -0,0 +1,83 @@ +import { Ratelimit } from '@upstash/ratelimit'; +import { Redis } from '@upstash/redis'; + +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; + +type Unit = 'ms' | 's' | 'm' | 'h' | 'd'; +type Duration = `${number} ${Unit}` | `${number}${Unit}`; + +interface RateLimitConfig { + maxRequests: number; + window: Duration; +} + +const ratelimitCache = new Map(); + +export async function limit(key: string, config: RateLimitConfig) { + 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 }; + } + + // 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); + } + + 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: false, + limit: config.maxRequests, + remaining: 0, + reset: 0, + retryAfter: 60, + }; + } +} \ No newline at end of file diff --git a/lib/with-ratelimit.ts b/lib/with-ratelimit.ts new file mode 100644 index 0000000..dac27c3 --- /dev/null +++ b/lib/with-ratelimit.ts @@ -0,0 +1,45 @@ +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 +) { + return async (req: NextRequest, ...args: T) => { + const ip = req.headers.get("x-forwarded-for") || req.headers.get("x-real-ip") || "127.0.0.1"; + + const identifier = `${req.nextUrl.pathname}:${ip}`; + + const { success, retryAfter } = await limit(identifier, { + 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() } } + ); + } + + return handler(req, ...args); + }; +} \ No newline at end of file diff --git a/package.json b/package.json index 0e61eb3..b79f11e 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", "form-data": "^4.0.5", "mailgun.js": "^12.7.1", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a600e42..20000f7 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 @@ -888,6 +894,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: @@ -2203,6 +2221,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==} @@ -2950,6 +2971,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 @@ -4510,6 +4544,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: