diff --git a/__tests__/api/health.test.ts b/__tests__/api/health.test.ts index 6c8ff3f51..460fac4e6 100644 --- a/__tests__/api/health.test.ts +++ b/__tests__/api/health.test.ts @@ -1,6 +1,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import type { Mock } from "vitest"; import handler from "@/pages/api/health"; +import { _resetRateLimitForTests } from "@/lib/rate-limit"; vi.mock("@/lib/prisma", () => ({ default: { @@ -16,10 +17,15 @@ function createMockReqRes(method = "GET"): { req: NextApiRequest; res: NextApiResponse; } { - const req = { method } as NextApiRequest; + const req = { + method, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; } @@ -28,6 +34,7 @@ describe("GET /api/health", () => { const originalEnv = process.env; beforeEach(() => { + _resetRateLimitForTests(); process.env = { ...originalEnv, DATABASE_URL: "postgresql://localhost:5432/test", diff --git a/__tests__/lib/j0di3/j0di3-proxy.test.ts b/__tests__/lib/j0di3/j0di3-proxy.test.ts index d08812724..18c2ec433 100644 --- a/__tests__/lib/j0di3/j0di3-proxy.test.ts +++ b/__tests__/lib/j0di3/j0di3-proxy.test.ts @@ -29,6 +29,8 @@ function createMockReqRes( method: "POST", body: { question: "What is React?" }, query: {}, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, user: { id: "user-123", name: "Test", @@ -42,6 +44,7 @@ function createMockReqRes( const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; diff --git a/__tests__/pages/api/contact.test.ts b/__tests__/pages/api/contact.test.ts index be79663af..395708085 100644 --- a/__tests__/pages/api/contact.test.ts +++ b/__tests__/pages/api/contact.test.ts @@ -3,6 +3,7 @@ import { NextApiRequest, NextApiResponse } from "next"; import type { Mock } from "vitest"; import { classifyContact } from "@/pages/api/api-helpers/classify-contact"; import handler from "@/pages/api/contact"; +import { _resetRateLimitForTests } from "@/lib/rate-limit"; vi.mock("@/pages/api/api-helpers/classify-contact", () => ({ classifyContact: vi.fn(), @@ -14,16 +15,22 @@ function createMockReqRes(body: Record): { req: NextApiRequest; res: NextApiResponse; } { - const req = { body } as NextApiRequest; + const req = { + body, + headers: {}, + socket: { remoteAddress: "127.0.0.1" }, + } as unknown as NextApiRequest; const res = { status: vi.fn().mockReturnThis(), json: vi.fn().mockReturnThis(), + setHeader: vi.fn(), } as unknown as NextApiResponse; return { req, res }; } describe("POST /api/contact", () => { beforeEach(() => { + _resetRateLimitForTests(); process.env.CONTACT_WEBHOOK_ID = "T00/B00/xxx"; }); diff --git a/docs/RATE_LIMITS.md b/docs/RATE_LIMITS.md new file mode 100644 index 000000000..c1916add4 --- /dev/null +++ b/docs/RATE_LIMITS.md @@ -0,0 +1,67 @@ +# API rate limits + +Every public API route that touches a paid backend, sends a Slack notification, +or is otherwise expensive enforces a per-IP or per-user rate limit. The limiter +lives at `src/lib/rate-limit.ts` and is in-memory (single Vercel instance); +swap the underlying `Map` for Redis/Upstash when we scale out. + +## Response headers + +Every rate-limited route sets: + +- `X-RateLimit-Limit` — max requests in the window +- `X-RateLimit-Remaining` — requests left in the current window +- `X-RateLimit-Reset` — UNIX seconds when the window resets +- `Retry-After` — seconds until the window resets (sent **only on 429**) + +The body on `429` is `{ "error": "" }` so the frontend can +surface the reason directly. + +## Per-route limits + +| Route | Bucket scope | Key | Limit / window | +| ---------------------------------- | ------------------- | ------------------ | ---------------------- | +| `/api/j0di3/*` | `j0di3` | session user id | **30 / minute** | +| `/api/ai/chat` | `ai-chat` | session user id | **20 / minute** | +| `/api/military-resume/translate` | (default) | IP | **10 / 15 minutes** | +| `/api/military-resume/parse-pdf` | (default) | IP | _per-route, see file_ | +| `/api/military-resume/extract-fields` | (default) | IP | _per-route, see file_ | +| `/api/military-resume/salary-lookup` | (default) | IP | _per-route, see file_ | +| `/api/contact` | `contact` | IP | **5 / 15 minutes** | +| `/api/newsletter` | `newsletter` | IP | **5 / 15 minutes** | +| `/api/apply` | `apply` | IP | **3 / hour** | +| `/api/mentor` | `mentor` | IP | **5 / hour** | +| `/api/mentee` | `mentee` | IP | **5 / hour** | +| `/api/health` | `health` | IP | **60 / minute** | + +Limits are intentionally conservative — they should never block normal use but +will catch a buggy client in a tight loop or a casual abuser. If a real user +hits one, raise the cap in code; do not paper over with a longer window. + +## Frontend conventions + +- Treat `429` as user-facing: show the response body's `error` string verbatim. +- If `Retry-After` is present, disable the affected control for that many + seconds before letting the user retry. +- Do not implement client-side retries on `429` — the user should consciously + retry, otherwise we just amplify the storm. + +## Adding a new limit + +```ts +import { applyRateLimit } from "@/lib/rate-limit"; + +const rl = applyRateLimit(req, res, { + scope: "my-feature", // unique per route family + key: session.user.id, // optional — defaults to client IP + max: 10, + windowMs: 60_000, +}); +if (!rl.allowed) { + return res.status(429).json({ error: "Slow down a little." }); +} +``` + +`applyRateLimit` sets the headers above automatically — the caller only has to +short-circuit on `!rl.allowed`. Don't pull `checkRateLimit` directly unless you +need a result without writing headers (e.g. for tests). diff --git a/src/lib/j0di3-proxy.ts b/src/lib/j0di3-proxy.ts index 5e55b7035..76393ed4e 100644 --- a/src/lib/j0di3-proxy.ts +++ b/src/lib/j0di3-proxy.ts @@ -1,16 +1,30 @@ import axios from "axios"; import type { NextApiRequest, NextApiResponse } from "next"; -import { requireAuth, type AuthenticatedRequest } from "@/lib/rbac"; import j0di3 from "@/lib/j0di3-client"; +import { applyRateLimit } from "@/lib/rate-limit"; +import { type AuthenticatedRequest, requireAuth } from "@/lib/rbac"; type Method = "GET" | "POST" | "PATCH" | "DELETE"; -export function j0di3Proxy( - method: Method, - path: string | ((req: NextApiRequest) => string) -) { +export function j0di3Proxy(method: Method, path: string | ((req: NextApiRequest) => string)) { return requireAuth(async (req: AuthenticatedRequest, res: NextApiResponse) => { const url = typeof path === "function" ? path(req) : path; + + // Per-user rate limit. Every call hits a paid AI backend, so a + // misbehaving client or stolen session should not be able to drive + // cost up arbitrarily. + const rl = applyRateLimit(req, res, { + scope: "j0di3", + key: req.user!.id, + max: 30, + windowMs: 60_000, + }); + if (!rl.allowed) { + return res.status(429).json({ + error: "You're making requests to J0dI3 too quickly. Please wait a moment and try again.", + }); + } + const troopId = req.user!.troopId; if (!troopId) { @@ -20,8 +34,7 @@ export function j0di3Proxy( } try { - const body = - method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; + const body = method !== "GET" ? { ...req.body, troop_id: troopId } : undefined; const { data } = await j0di3({ method, @@ -30,12 +43,14 @@ export function j0di3Proxy( params: method === "GET" ? { ...req.query, troop_id: troopId } : undefined, }); - res.json(data); } catch (error: unknown) { if (axios.isAxiosError(error) && error.response) { const status = error.response.status; - const message = error.response.data?.detail || error.response.data?.error || "J0dI3 request failed"; + const message = + error.response.data?.detail || + error.response.data?.error || + "J0dI3 request failed"; return res.status(status).json({ error: message }); } console.error("[j0di3-proxy] Unexpected error:", error); diff --git a/src/lib/rate-limit.ts b/src/lib/rate-limit.ts index 89807af42..2cab8c1fb 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,47 @@ export function checkRateLimit( entry.count++; return { allowed: true, remaining: maxRequests - entry.count, resetAt: entry.resetAt }; } + +export interface ApplyRateLimitOptions { + /** Bucket key. Defaults to client IP. Use a user/session id for per-user limits. */ + key?: string; + /** Max requests allowed in the window. */ + max: number; + /** Window length in milliseconds. */ + windowMs: number; + /** Optional prefix added to the key (avoids collisions across endpoints sharing a key strategy). */ + scope?: string; +} + +/** + * Apply a rate limit and set the standard headers (X-RateLimit-* and + * Retry-After when blocked). The caller is responsible for short-circuiting + * with `res.status(429).json(...)` when `allowed === false`. + */ +export function applyRateLimit( + req: NextApiRequest, + res: NextApiResponse, + options: ApplyRateLimitOptions +): RateLimitResult { + const rawKey = options.key || getClientIp(req); + const bucketKey = options.scope ? `${options.scope}:${rawKey}` : rawKey; + const limit = checkRateLimit(bucketKey, options.max, options.windowMs); + + res.setHeader("X-RateLimit-Limit", options.max); + res.setHeader("X-RateLimit-Remaining", limit.remaining); + res.setHeader("X-RateLimit-Reset", Math.ceil(limit.resetAt / 1000)); + if (!limit.allowed) { + const retryAfterSec = Math.max(0, Math.ceil((limit.resetAt - Date.now()) / 1000)); + res.setHeader("Retry-After", retryAfterSec); + } + return limit; +} + +/** + * Exposed for unit tests — clears the in-memory bucket so test order does not + * accumulate state. Do not call from production code. + */ +export function _resetRateLimitForTests(): void { + ipBuckets.clear(); + lastCleanup = Date.now(); +} diff --git a/src/pages/api/ai/chat.ts b/src/pages/api/ai/chat.ts index 4807a2873..a92656a98 100644 --- a/src/pages/api/ai/chat.ts +++ b/src/pages/api/ai/chat.ts @@ -1,6 +1,7 @@ import { streamText } from "ai"; import { NextApiResponse } from "next"; import { getAIModelWithFallback, JODIE_SYSTEM_PROMPT } from "@/lib/ai-provider"; +import { applyRateLimit } from "@/lib/rate-limit"; import { AuthenticatedRequest, requireAuth } from "@/lib/rbac"; export const runtime = "nodejs"; @@ -47,6 +48,20 @@ export default requireAuth(async (req: AuthenticatedRequest, res: NextApiRespons return res.status(405).json({ error: "Method not allowed" }); } + // Per-user rate limit on the streaming AI endpoint. Each call hits a paid + // model, so a runaway client should not be able to drain quota. + const rl = applyRateLimit(req, res, { + scope: "ai-chat", + key: req.user!.id, + max: 20, + windowMs: 60_000, + }); + if (!rl.allowed) { + return res.status(429).json({ + error: "You're sending messages too quickly. Please wait a moment.", + }); + } + try { const { messages, lessonContext } = req.body as ChatRequestBody; diff --git a/src/pages/api/apply.ts b/src/pages/api/apply.ts index 189e132c8..e3bac45cd 100644 --- a/src/pages/api/apply.ts +++ b/src/pages/api/apply.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody interface to type-check the request body @@ -112,6 +114,17 @@ async function postToSlack(text: string): Promise { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "apply", max: 3, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many application attempts. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; const hasErrors = validateBody(parsedBody); diff --git a/src/pages/api/contact.ts b/src/pages/api/contact.ts index 2a13f8bcf..92af791fc 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 { applyRateLimit } from "@/lib/rate-limit"; import { checkLength, checkParams, contactErrors } from "./api-helpers"; import { classifyContact } from "./api-helpers/classify-contact"; @@ -35,6 +36,15 @@ async function postToSlack(parsedBody: ParsedBody): Promise { } export default async function handler(req: NextApiRequest, res: NextApiResponse) { + // Per-IP rate limit on the contact form. The Gemini classifier handles + // semantic spam; this caps the volume that classifier even has to score. + const rl = applyRateLimit(req, res, { scope: "contact", max: 5, windowMs: 15 * 60 * 1000 }); + if (!rl.allowed) { + return res.status(429).json({ + error: "Too many messages sent recently. Please try again in a few minutes.", + }); + } + 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..1f4f9b93e 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 { applyRateLimit } from "@/lib/rate-limit"; import { version } from "../../../package.json"; interface CheckResult { @@ -64,6 +65,14 @@ export default async function handler( return res.status(405).json({ error: "Method not allowed" }); } + // Cap /health to prevent tight-loop pings from a misbehaving uptime + // checker. Real uptime probes ping every 30–60s — 60/min per IP is + // generous. + const rl = applyRateLimit(req, res, { scope: "health", max: 60, windowMs: 60_000 }); + if (!rl.allowed) { + return res.status(429).json({ error: "Rate limit exceeded" }); + } + const dbCheck = await checkDatabase(); const envCheck = checkEnvironment(); const checks = [dbCheck, envCheck]; diff --git a/src/pages/api/mentee.ts b/src/pages/api/mentee.ts index 9505cdbd0..c6c2ea6d3 100644 --- a/src/pages/api/mentee.ts +++ b/src/pages/api/mentee.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody type for mentee @@ -16,6 +18,17 @@ interface ParsedBody { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "mentee", max: 5, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many mentee signups. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; // Cast to ensure body matches ParsedBody interface const requiredParams: (keyof ParsedBody)[] = [ diff --git a/src/pages/api/mentor.ts b/src/pages/api/mentor.ts index c1ef8d37b..574341b91 100644 --- a/src/pages/api/mentor.ts +++ b/src/pages/api/mentor.ts @@ -1,5 +1,7 @@ import axios from "axios"; import { Request, Response } from "express"; +import type { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; import { checkParams } from "./api-helpers"; // Define the ParsedBody type @@ -15,6 +17,17 @@ interface ParsedBody { export default async function handler(req: Request, res: Response) { try { + const rl = applyRateLimit( + req as unknown as NextApiRequest, + res as unknown as NextApiResponse, + { scope: "mentor", max: 5, windowMs: 60 * 60 * 1000 } + ); + if (!rl.allowed) { + return res.status(429).json({ + message: "Too many mentor signups. Please try again in an hour.", + }); + } + const parsedBody = req.body as ParsedBody; // Cast to ensure body matches ParsedBody interface const requiredParams: (keyof ParsedBody)[] = [ "name", diff --git a/src/pages/api/newsletter.ts b/src/pages/api/newsletter.ts index 5e8047897..28409fd29 100644 --- a/src/pages/api/newsletter.ts +++ b/src/pages/api/newsletter.ts @@ -1,4 +1,5 @@ import { NextApiRequest, NextApiResponse } from "next"; +import { applyRateLimit } from "@/lib/rate-limit"; const MAILCHIMP_API_SERVER = "https://us4.api.mailchimp.com/3.0/lists"; const MAILCHIMP_LIST_ID = process.env?.MAILCHIMP_LIST_ID ?? ""; @@ -43,6 +44,14 @@ export default async function handler(req: NextApiRequest, res: NextApiResponse) if (req.method !== "POST") { return res.status(405).json({ error: "Method Not Allowed" }); } + + const rl = applyRateLimit(req, res, { scope: "newsletter", max: 5, windowMs: 15 * 60 * 1000 }); + if (!rl.allowed) { + return res.status(429).json({ + error: "Too many subscription attempts. Please try again in a few minutes.", + }); + } + let requestData: RequestData; try {