diff --git a/__tests__/api/health.test.ts b/__tests__/api/health.test.ts index 6c8ff3f51..c0323730e 100644 --- a/__tests__/api/health.test.ts +++ b/__tests__/api/health.test.ts @@ -12,14 +12,27 @@ import prisma from "@/lib/prisma"; const mockQueryRaw = prisma.$queryRaw as Mock; -function createMockReqRes(method = "GET"): { +// Each call gets a unique IP by default so the per-IP rate limiter +// never interferes across tests. Pass `ip` to share a bucket on purpose. +let nextIp = 0; + +function createMockReqRes( + method = "GET", + ip?: string +): { req: NextApiRequest; res: NextApiResponse; } { - const req = { method } as NextApiRequest; + nextIp++; + const req = { + method, + headers: { "x-forwarded-for": ip ?? `198.51.100.${nextIp}` }, + socket: {}, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), } as unknown as NextApiResponse; return { req, res }; } @@ -152,4 +165,23 @@ describe("GET /api/health", () => { expect(dbCheck.status).toBe("unhealthy"); expect(typeof dbCheck.responseTime).toBe("number"); }); + + it("returns 429 with Retry-After after 30 requests per minute from one IP", async () => { + const ip = "203.0.113.50"; + + for (let i = 0; i < 30; i++) { + const { req, res } = createMockReqRes("GET", ip); + await handler(req, res); + expect(res.status).toHaveBeenCalledWith(200); + } + + const { req, res } = createMockReqRes("GET", ip); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: "Too many requests. Please try again later.", + }); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number)); + }); }); diff --git a/__tests__/lib/j0di3/j0di3-proxy.test.ts b/__tests__/lib/j0di3/j0di3-proxy.test.ts index 30029572f..ed085671a 100644 --- a/__tests__/lib/j0di3/j0di3-proxy.test.ts +++ b/__tests__/lib/j0di3/j0di3-proxy.test.ts @@ -199,4 +199,35 @@ describe("j0di3Proxy", () => { expect(res.json).toHaveBeenCalledWith({ error: "Internal server error" }); consoleSpy.mockRestore(); }); + + it("returns 429 with Retry-After after 30 requests per minute for one troop", async () => { + mockJ0di3.mockResolvedValue({ data: { ok: true } }); + + const handler = j0di3Proxy("POST", "/api/v1/learning/explain"); + const user = { + id: "user-rl", + name: "Rate Limited", + email: "rl@test.com", + role: "STUDENT", + troopId: "troop-rate-limit-test", + troopToken: "troop-token-rl", + }; + + for (let i = 0; i < 30; i++) { + const { req, res } = createMockReqRes({ user } as Partial); + await handler(req, res); + expect(res.status).not.toHaveBeenCalledWith(429); + } + expect(mockJ0di3).toHaveBeenCalledTimes(30); + + const { req, res } = createMockReqRes({ user } as Partial); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: "Too many requests. Please try again later.", + }); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number)); + expect(mockJ0di3).toHaveBeenCalledTimes(30); + }); }); diff --git a/__tests__/lib/rate-limit.test.ts b/__tests__/lib/rate-limit.test.ts index 3163d4c2e..5af5b62b1 100644 --- a/__tests__/lib/rate-limit.test.ts +++ b/__tests__/lib/rate-limit.test.ts @@ -1,5 +1,5 @@ -import { checkRateLimit, getClientIp } from "@/lib/rate-limit"; -import type { NextApiRequest } from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { checkRateLimit, enforceRateLimit, getClientIp } from "@/lib/rate-limit"; describe("rate-limit", () => { describe("checkRateLimit", () => { @@ -83,4 +83,83 @@ describe("rate-limit", () => { expect(getClientIp(req)).toBe("unknown"); }); }); + + describe("enforceRateLimit", () => { + function createMockReqRes(ip: string): { req: NextApiRequest; res: NextApiResponse } { + const req = { + headers: { "x-forwarded-for": ip }, + socket: { remoteAddress: ip }, + } as unknown as NextApiRequest; + const res = { + status: vi.fn().mockReturnThis(), + json: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), + } as unknown as NextApiResponse; + return { req, res }; + } + + it("should allow requests under the limit and set rate limit headers", () => { + const { req, res } = createMockReqRes("20.0.0.1"); + + const allowed = enforceRateLimit(req, res, { + name: "test-allow", + maxRequests: 2, + windowMs: 60000, + }); + + expect(allowed).toBe(true); + expect(res.status).not.toHaveBeenCalled(); + expect(res.setHeader).toHaveBeenCalledWith("X-RateLimit-Remaining", 1); + }); + + it("should send 429 with Retry-After when the limit is exceeded", () => { + const options = { name: "test-block", maxRequests: 2, windowMs: 60000 }; + for (let i = 0; i < 2; i++) { + const { req, res } = createMockReqRes("20.0.0.2"); + enforceRateLimit(req, res, options); + } + + const { req, res } = createMockReqRes("20.0.0.2"); + const allowed = enforceRateLimit(req, res, options); + + expect(allowed).toBe(false); + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: "Too many requests. Please try again later.", + }); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number)); + }); + + it("should not share buckets between different names", () => { + const ip = "20.0.0.3"; + const first = createMockReqRes(ip); + enforceRateLimit(first.req, first.res, { + name: "endpoint-a", + maxRequests: 1, + windowMs: 60000, + }); + + const { req, res } = createMockReqRes(ip); + const allowed = enforceRateLimit(req, res, { + name: "endpoint-b", + maxRequests: 1, + windowMs: 60000, + }); + + expect(allowed).toBe(true); + }); + + it("should use the provided key instead of the client IP", () => { + const options = { name: "test-key", maxRequests: 1, windowMs: 60000 }; + const first = createMockReqRes("20.0.0.4"); + enforceRateLimit(first.req, first.res, { ...options, key: "troop:abc" }); + + // Different IP, same key — still blocked. + const { req, res } = createMockReqRes("20.0.0.5"); + const allowed = enforceRateLimit(req, res, { ...options, key: "troop:abc" }); + + expect(allowed).toBe(false); + expect(res.status).toHaveBeenCalledWith(429); + }); + }); }); diff --git a/__tests__/pages/api/contact.test.ts b/__tests__/pages/api/contact.test.ts index 930b17867..0478a012e 100644 --- a/__tests__/pages/api/contact.test.ts +++ b/__tests__/pages/api/contact.test.ts @@ -10,14 +10,27 @@ vi.mock("@/pages/api/api-helpers/classify-contact", () => ({ vi.mock("axios"); -function createMockReqRes(body: Record): { +// Each call gets a unique IP by default so the per-IP rate limiter +// never interferes across tests. Pass `ip` to share a bucket on purpose. +let nextIp = 0; + +function createMockReqRes( + body: Record, + ip?: string +): { req: NextApiRequest; res: NextApiResponse; } { - const req = { body } as NextApiRequest; + nextIp++; + const req = { + body, + headers: { "x-forwarded-for": ip ?? `198.51.100.${nextIp}` }, + socket: {}, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn().mockReturnThis(), } as unknown as NextApiResponse; return { req, res }; } @@ -243,4 +256,26 @@ describe("POST /api/contact", () => { expect(payload.text).toContain("I want to join the bootcamp"); }); }); + + describe("rate limiting", () => { + it("should return 429 with Retry-After after 5 requests per minute from one IP", async () => { + const ip = "203.0.113.99"; + + for (let i = 0; i < 5; i++) { + const { req, res } = createMockReqRes({}, ip); + await handler(req, res); + // Under the limit: requests reach validation, not the limiter. + expect(res.status).toHaveBeenCalledWith(422); + } + + const { req, res } = createMockReqRes({}, ip); + await handler(req, res); + + expect(res.status).toHaveBeenCalledWith(429); + expect(res.json).toHaveBeenCalledWith({ + error: "Too many requests. Please try again later.", + }); + expect(res.setHeader).toHaveBeenCalledWith("Retry-After", expect.any(Number)); + }); + }); }); diff --git a/docs/RATE_LIMITS.md b/docs/RATE_LIMITS.md new file mode 100644 index 000000000..bd4ceb89a --- /dev/null +++ b/docs/RATE_LIMITS.md @@ -0,0 +1,36 @@ +# API Rate Limits + +Public API endpoints are rate limited via the in-memory sliding-window limiter in +`src/lib/rate-limit.ts` (`enforceRateLimit`). Buckets are namespaced per endpoint, so +hitting one endpoint never consumes another endpoint's budget. + +## Limits + +| Endpoint | Limit | Window | Keyed by | +| --------------- | ---------- | ------ | ----------------------------------------- | +| `/api/j0di3/*` | 30 req/min | 60s | troopId, falling back to user id, then IP | +| `/api/contact` | 5 req/min | 60s | client IP | +| `/api/health` | 30 req/min | 60s | client IP | + +The J0dI3 limit is enforced centrally in `src/lib/j0di3-proxy.ts`, so every +`/api/j0di3/*` route is covered in one place. + +Other endpoints (e.g. `/api/military-resume/*`) apply their own limits directly with +`checkRateLimit` — see the individual handlers. + +## Response on limit + +When a limit is exceeded the endpoint responds: + +- Status: `429` +- Headers: `Retry-After` (seconds until the window resets), plus + `X-RateLimit-Remaining` and `X-RateLimit-Reset` on every response +- Body: `{ "error": "Too many requests. Please try again later." }` + +## Scaling note + +The limiter keeps its counters in process memory, which is only correct on a +single instance. If the app scales to multiple instances (or heavy serverless +fan-out makes per-instance counters too leaky), swap the `Map` in +`src/lib/rate-limit.ts` for a shared store such as Upstash Redis — the +`enforceRateLimit` call sites stay the same. diff --git a/public/swagger-spec.json b/public/swagger-spec.json index ee9e7df7a..32cd20da5 100644 --- a/public/swagger-spec.json +++ b/public/swagger-spec.json @@ -157,6 +157,86 @@ } } } + }, + "/api/contact": { + "post": { + "summary": "Submit the contact form", + "description": "Validates the submission, filters spam, and forwards it to Slack. Rate limited to 5 requests per minute per IP.", + "tags": [ + "Contact" + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": [ + "email", + "message" + ], + "properties": { + "name": { + "type": "string" + }, + "email": { + "type": "string" + }, + "phone": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "message": { + "type": "string" + } + } + } + } + } + }, + "responses": { + "200": { + "description": "Message accepted" + }, + "400": { + "description": "Message too short" + }, + "422": { + "description": "Missing required fields" + }, + "429": { + "description": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header." + }, + "500": { + "description": "Failed to deliver the message" + } + } + } + }, + "/api/health": { + "get": { + "summary": "Service health check", + "description": "Reports database and environment health. Rate limited to 30 requests per minute per IP.", + "tags": [ + "Health" + ], + "responses": { + "200": { + "description": "Service is healthy or degraded" + }, + "405": { + "description": "Method not allowed" + }, + "429": { + "description": "Rate limit exceeded. Retry after the number of seconds in the Retry-After header." + }, + "503": { + "description": "Service is unhealthy" + } + } + } } }, "tags": [] diff --git a/src/lib/j0di3-proxy.ts b/src/lib/j0di3-proxy.ts index 424e05757..a07b1c39c 100644 --- a/src/lib/j0di3-proxy.ts +++ b/src/lib/j0di3-proxy.ts @@ -1,6 +1,7 @@ import axios from "axios"; import type { NextApiRequest, NextApiResponse } from "next"; import j0di3 from "@/lib/j0di3-client"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { type AuthenticatedRequest, requireAuth, requireRole } from "@/lib/rbac"; type Method = "GET" | "POST" | "PATCH" | "DELETE"; @@ -19,6 +20,16 @@ async function dispatch( const troopId = req.user!.troopId; const troopToken = req.user!.troopToken; + // 30 req/min per troop (falls back to user id, then client IP). + const rateKey = troopId ? `troop:${troopId}` : req.user?.id ? `user:${req.user.id}` : undefined; + const limited = !enforceRateLimit(req, res, { + name: "j0di3", + maxRequests: 30, + windowMs: 60 * 1000, + key: rateKey, + }); + if (limited) return; + if (injectTroopId && !troopId) { return res .status(400) diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 89807af42..0541e01e0 100644 --- a/src/lib/rate-limit.ts +++ b/src/lib/rate-limit.ts @@ -5,7 +5,7 @@ * swap the Map for Redis or similar. */ -import type { NextApiRequest } from "next"; +import type { NextApiRequest, NextApiResponse } from "next"; interface RateLimitEntry { count: number; @@ -70,3 +70,32 @@ export function checkRateLimit( entry.count++; return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }; } + +/** + * One-line opt-in rate limiting for API routes. + * Buckets are namespaced by `name` so endpoints with different limits + * never share counters. Defaults to keying by client IP; pass `key` + * to key by something else (e.g. troopId or user id). + * When the limit is exceeded, sends a 429 with a Retry-After header + * and returns false — the caller should stop handling the request. + */ +export function enforceRateLimit( + req: NextApiRequest, + res: NextApiResponse, + options: { name: string; maxRequests: number; windowMs: number; key?: string } +): boolean { + const bucket = `${options.name}:${options.key ?? getClientIp(req)}`; + const result = checkRateLimit(bucket, options.maxRequests, options.windowMs); + + res.setHeader("X-RateLimit-Remaining", result.remaining); + res.setHeader("X-RateLimit-Reset", Math.ceil(result.resetAt / 1000)); + + if (!result.allowed) { + const retryAfter = Math.max(1, Math.ceil((result.resetAt - Date.now()) / 1000)); + res.setHeader("Retry-After", retryAfter); + res.status(429).json({ error: "Too many requests. Please try again later." }); + return false; + } + + return true; +} diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index c8b2257d3..cac8023a8 100644 --- a/src/pages/api/contact.ts +++ b/src/pages/api/contact.ts @@ -1,5 +1,6 @@ import axios, { AxiosRequestConfig } from "axios"; import { NextApiRequest, NextApiResponse } from "next"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { checkLength, checkParams, contactErrors } from "./api-helpers"; import { classifyContact } from "./api-helpers/classify-contact"; @@ -39,7 +40,50 @@ async function postToSlack(parsedBody: ParsedBody): Promise { await axios(axiosConfig); } +/** + * @swagger + * /api/contact: + * post: + * summary: Submit the contact form + * description: Validates the submission, filters spam, and forwards it to Slack. Rate limited to 5 requests per minute per IP. + * tags: + * - Contact + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: [email, message] + * properties: + * name: + * type: string + * email: + * type: string + * phone: + * type: string + * subject: + * type: string + * message: + * type: string + * responses: + * 200: + * description: Message accepted + * 400: + * description: Message too short + * 422: + * description: Missing required fields + * 429: + * description: Rate limit exceeded. Retry after the number of seconds in the Retry-After header. + * 500: + * description: Failed to deliver the message + */ export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // 5 req/min per IP — strict, this endpoint triggers AI classification and Slack posts. + if (!enforceRateLimit(req, res, { name: "contact", maxRequests: 5, windowMs: 60 * 1000 })) { + return; + } + const parsedBody: ParsedBody = req.body as ParsedBody; const { name, email, message } = parsedBody; const requiredParams: string[] = ["email", "message"]; diff --git a/src/pages/api/health.ts b/src/pages/api/health.ts index 91ba0396b..adfb3c70d 100644 --- a/src/pages/api/health.ts +++ b/src/pages/api/health.ts @@ -1,5 +1,6 @@ import type { NextApiRequest, NextApiResponse } from "next"; import prisma from "@/lib/prisma"; +import { enforceRateLimit } from "@/lib/rate-limit"; import { version } from "../../../package.json"; interface CheckResult { @@ -56,10 +57,33 @@ function aggregateStatus(checks: CheckResult[]): "healthy" | "degraded" | "unhea return "healthy"; } +/** + * @swagger + * /api/health: + * get: + * summary: Service health check + * description: Reports database and environment health. Rate limited to 30 requests per minute per IP. + * tags: + * - Health + * responses: + * 200: + * description: Service is healthy or degraded + * 405: + * description: Method not allowed + * 429: + * description: Rate limit exceeded. Retry after the number of seconds in the Retry-After header. + * 503: + * description: Service is unhealthy + */ export default async function handler( req: NextApiRequest, res: NextApiResponse ) { + // 30 req/min per IP — health checks are cheap but hit the database. + if (!enforceRateLimit(req, res, { name: "health", maxRequests: 30, windowMs: 60 * 1000 })) { + return; + } + if (req.method !== "GET") { return res.status(405).json({ error: "Method not allowed" }); }