diff --git a/src/api/nextjs-backend/bench/dedupe-bench.ts b/src/api/nextjs-backend/bench/dedupe-bench.ts new file mode 100644 index 0000000..8c4e254 --- /dev/null +++ b/src/api/nextjs-backend/bench/dedupe-bench.ts @@ -0,0 +1,63 @@ +/** + * Benchmark: email-uniqueness enforcement — O(1) Map index vs naive O(n) + * Array.find scan, across N sequential inserts. + * + * The uplifted /api/users route enforces unique emails with a Map index, so + * each insert stays O(1). A naive `users.find(u => u.email === email)` check + * is O(n) per insert -> O(n^2) across N inserts. This script quantifies the gap. + * + * Run: npx tsx bench/dedupe-bench.ts + */ + +interface Rec { + id: number; + email: string; +} + +function bench(label: string, fn: () => void): number { + const start = process.hrtime.bigint(); + fn(); + const end = process.hrtime.bigint(); + const ms = Number(end - start) / 1e6; + // eslint-disable-next-line no-console + console.log(`${label.padEnd(28)} ${ms.toFixed(1)} ms`); + return ms; +} + +function run(n: number): void { + const emails = Array.from({ length: n }, (_, i) => `user${i}@example.com`); + + // eslint-disable-next-line no-console + console.log(`\nN = ${n.toLocaleString()} sequential unique inserts`); + + const naiveMs = bench("naive Array.find dedupe", () => { + const users: Rec[] = []; + for (let i = 0; i < n; i += 1) { + const email = emails[i]; + if (!users.find((u) => u.email === email)) { + users.push({ id: i, email }); + } + } + }); + + const indexedMs = bench("indexed Map dedupe", () => { + const users: Rec[] = []; + const byEmail = new Map(); + for (let i = 0; i < n; i += 1) { + const email = emails[i]; + if (!byEmail.has(email)) { + const rec = { id: i, email }; + users.push(rec); + byEmail.set(email, rec); + } + } + }); + + const speedup = naiveMs / indexedMs; + // eslint-disable-next-line no-console + console.log(`speedup: ${speedup.toFixed(0)}x`); +} + +for (const n of [10000, 50000]) { + run(n); +} diff --git a/src/api/nextjs-backend/next.config.js b/src/api/nextjs-backend/next.config.js index bb3102b..401e48f 100644 --- a/src/api/nextjs-backend/next.config.js +++ b/src/api/nextjs-backend/next.config.js @@ -23,27 +23,17 @@ const nextConfig = { ]; }, - // Headers for security and CORS + // Static security headers. + // + // CORS is intentionally NOT set here. A static header block cannot vary + // `Access-Control-Allow-Origin` per request, and emitting the raw + // comma-separated ALLOWED_ORIGINS value produces an invalid ACAO header that + // browsers reject. Origin-reflection CORS lives in src/middleware.ts instead. async headers() { return [ { source: "/api/:path*", headers: [ - { - key: "Access-Control-Allow-Origin", - value: - process.env.NODE_ENV === "production" - ? process.env.ALLOWED_ORIGINS || "https://yourdomain.com" - : "*", - }, - { - key: "Access-Control-Allow-Methods", - value: "GET, POST, PUT, DELETE, PATCH, OPTIONS", - }, - { - key: "Access-Control-Allow-Headers", - value: "Content-Type, Authorization, X-Requested-With", - }, { key: "X-Content-Type-Options", value: "nosniff", @@ -53,20 +43,24 @@ const nextConfig = { value: "DENY", }, { + // OWASP guidance: the legacy XSS auditor can introduce + // vulnerabilities; disable it and rely on CSP instead. key: "X-XSS-Protection", - value: "1; mode=block", + value: "0", + }, + { + key: "Referrer-Policy", + value: "no-referrer", }, ], }, ]; }, - // Environment variables validation - // Remove NODE_ENV as Next.js handles it automatically - env: { - DATABASE_URL: process.env.DATABASE_URL, - JWT_SECRET: process.env.JWT_SECRET, - }, + // NOTE: DATABASE_URL and JWT_SECRET are deliberately NOT exposed via the + // `env` key. Values placed there are inlined into the JavaScript bundle at + // build time (including client bundles) — a secret-leak. Server code reads + // them directly from process.env in route handlers / server components. // Logging configuration logging: { diff --git a/src/api/nextjs-backend/src/app/api/users/route.test.ts b/src/api/nextjs-backend/src/app/api/users/route.test.ts index d17c844..3f27ac8 100644 --- a/src/api/nextjs-backend/src/app/api/users/route.test.ts +++ b/src/api/nextjs-backend/src/app/api/users/route.test.ts @@ -21,10 +21,15 @@ const createRequest = (payload: unknown): Request => json: async () => payload, }) as unknown as Request; +const createGetRequest = (query: string): NextRequest => + ({ + nextUrl: new URL(`http://localhost/api/users?${query}`), + }) as unknown as NextRequest; + describe("/api/users route handlers", () => { it("returns an empty user list by default", async () => { await withFreshModule(async ({ GET }) => { - const response = await GET(); + const response = await GET(createGetRequest("")); expect(response.status).toBe(200); const body = await response.json(); @@ -47,7 +52,7 @@ describe("/api/users route handlers", () => { expect(createBody.data.name).toBe("Test User"); expect(typeof createBody.data.id).toBe("string"); - const listResponse = await GET(); + const listResponse = await GET(createGetRequest("")); const listBody = await listResponse.json(); expect(listBody.total).toBe(1); expect(listBody.data[0].email).toBe("user@example.com"); @@ -65,4 +70,98 @@ describe("/api/users route handlers", () => { expect(Array.isArray(body.details)).toBe(true); }); }); + + it("rejects a whitespace-only name (plain min(1) would accept it)", async () => { + await withFreshModule(async ({ POST }) => { + const response = await POST( + createRequest({ email: "ws@example.com", name: " " }) as NextRequest + ); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation error"); + }); + }); + + it("normalizes email (trim + lowercase) on create", async () => { + await withFreshModule(async ({ POST, GET }) => { + const response = await POST( + createRequest({ email: " MixedCase@Example.COM ", name: "Case User" }) as NextRequest + ); + expect(response.status).toBe(201); + const body = await response.json(); + expect(body.data.email).toBe("mixedcase@example.com"); + + const list = await (await GET(createGetRequest(""))).json(); + expect(list.data[0].email).toBe("mixedcase@example.com"); + }); + }); + + it("rejects a duplicate email with 409 Conflict", async () => { + await withFreshModule(async ({ POST, GET }) => { + const first = await POST( + createRequest({ email: "dupe@example.com", name: "First" }) as NextRequest + ); + expect(first.status).toBe(201); + + const second = await POST( + createRequest({ email: "dupe@example.com", name: "Second" }) as NextRequest + ); + expect(second.status).toBe(409); + const body = await second.json(); + expect(body.success).toBe(false); + expect(body.error).toBe("Email already exists"); + + // No duplicate was stored. + const list = await (await GET(createGetRequest(""))).json(); + expect(list.total).toBe(1); + }); + }); + + it("treats differently-cased / padded emails as the same identity", async () => { + await withFreshModule(async ({ POST, GET }) => { + await POST(createRequest({ email: "person@example.com", name: "A" }) as NextRequest); + const dup = await POST( + createRequest({ email: " Person@Example.com ", name: "B" }) as NextRequest + ); + expect(dup.status).toBe(409); + + const list = await (await GET(createGetRequest(""))).json(); + expect(list.total).toBe(1); + }); + }); + + it("paginates with limit/offset while keeping the full total (backward compatible)", async () => { + await withFreshModule(async ({ POST, GET }) => { + for (let i = 0; i < 5; i += 1) { + // eslint-disable-next-line no-await-in-loop -- sequential inserts for deterministic order + await POST(createRequest({ email: `u${i}@example.com`, name: `U${i}` }) as NextRequest); + } + + const page = await GET(createGetRequest("limit=2&offset=1")); + expect(page.status).toBe(200); + const body = await page.json(); + expect(body.total).toBe(5); + expect(body.data).toHaveLength(2); + expect(body.data[0].email).toBe("u1@example.com"); + expect(body.data[1].email).toBe("u2@example.com"); + }); + }); + + it("returns the full list unchanged when no pagination params are given", async () => { + await withFreshModule(async ({ POST, GET }) => { + await POST(createRequest({ email: "only@example.com", name: "Only" }) as NextRequest); + const body = await (await GET(createGetRequest(""))).json(); + expect(body.data).toHaveLength(1); + expect(body.total).toBe(1); + }); + }); + + it("rejects invalid pagination params with 400", async () => { + await withFreshModule(async ({ GET }) => { + const response = await GET(createGetRequest("limit=-3")); + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.error).toBe("Validation error"); + }); + }); }); diff --git a/src/api/nextjs-backend/src/app/api/users/route.ts b/src/api/nextjs-backend/src/app/api/users/route.ts index 30f9f1b..296e952 100644 --- a/src/api/nextjs-backend/src/app/api/users/route.ts +++ b/src/api/nextjs-backend/src/app/api/users/route.ts @@ -3,18 +3,67 @@ import { z } from "zod"; import { User } from "@/types"; import { HttpStatus } from "@/constants"; -// In-memory storage for demo +// In-memory storage for demo. +// `users` preserves insertion order for listing; `usersByEmail` is an O(1) +// uniqueness index keyed by the normalized email. Keeping both avoids the +// O(n) `Array.find` scan a naive dedupe would run on every insert (which is +// O(n^2) across n creates). const users: User[] = []; +const usersByEmail = new Map(); +// Zod normalizes before validating: trim + lowercase the email so that +// " User@Example.com " and "user@example.com" collide as the same identity, +// and reject whitespace-only names (plain .min(1) accepts " "). const createUserSchema = z.object({ - email: z.string().email(), - name: z.string().min(1), + email: z.string().trim().toLowerCase().email(), + name: z.string().trim().min(1), }); -export async function GET(): Promise { +const MAX_PAGE_LIMIT = 1000; + +const paginationSchema = z.object({ + limit: z.coerce.number().int().min(1).max(MAX_PAGE_LIMIT).optional(), + offset: z.coerce.number().int().min(0).optional(), +}); + +// Next.js's route-handler type checker (next build) requires the exported +// GET signature to be assignable to (request: NextRequest) => ... — even a +// default-valued (structurally optional) param fails that check. Keep the +// param required; callers (incl. direct unit-test invocation) must pass a +// request. See route.test.ts's createGetRequest("") for the no-params case. +export async function GET(request: NextRequest): Promise { + const params = request.nextUrl?.searchParams; + const rawLimit = params?.get("limit") ?? undefined; + const rawOffset = params?.get("offset") ?? undefined; + + // Backward compatible: with no pagination params, return the full list. + if (rawLimit === undefined && rawOffset === undefined) { + return NextResponse.json({ + success: true, + data: users, + total: users.length, + }); + } + + const parsed = paginationSchema.safeParse({ limit: rawLimit, offset: rawOffset }); + if (!parsed.success) { + return NextResponse.json( + { + success: false, + error: "Validation error", + details: parsed.error.errors, + }, + { status: HttpStatus.BAD_REQUEST } + ); + } + + const offset = parsed.data.offset ?? 0; + const limit = parsed.data.limit ?? users.length; + const page = users.slice(offset, offset + limit); + return NextResponse.json({ success: true, - data: users, + data: page, total: users.length, }); } @@ -35,7 +84,19 @@ export async function POST(request: NextRequest): Promise { ); } + // email is already trimmed + lowercased by the schema. const { email, name } = validatedData.data; + + if (usersByEmail.has(email)) { + return NextResponse.json( + { + success: false, + error: "Email already exists", + }, + { status: HttpStatus.CONFLICT } + ); + } + const now = new Date(); const newUser: User = { id: crypto.randomUUID(), @@ -46,6 +107,7 @@ export async function POST(request: NextRequest): Promise { }; users.push(newUser); + usersByEmail.set(email, newUser); return NextResponse.json( { diff --git a/src/api/nextjs-backend/src/config/next-config.test.ts b/src/api/nextjs-backend/src/config/next-config.test.ts new file mode 100644 index 0000000..19ad41a --- /dev/null +++ b/src/api/nextjs-backend/src/config/next-config.test.ts @@ -0,0 +1,50 @@ +/** + * Regression guards for next.config.js: + * 1. Secrets (JWT_SECRET / DATABASE_URL) are never placed in the `env` block, + * which Next inlines into the JavaScript bundle at build time. + * 2. The static header block does not emit an Access-Control-Allow-Origin + * header — it cannot be varied per request and a comma/space list is an + * invalid ACAO value. (CORS lives in middleware.ts.) + * 3. X-XSS-Protection is disabled ("0") per current OWASP guidance. + */ + +// eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires -- require a CommonJS config +const nextConfig = require("../../next.config.js"); + +describe("next.config.js hardening", () => { + it("does not expose secrets through the inlined `env` block", () => { + const env = (nextConfig.env ?? {}) as Record; + expect(Object.keys(env)).not.toContain("JWT_SECRET"); + expect(Object.keys(env)).not.toContain("DATABASE_URL"); + }); + + it("does not emit a static Access-Control-Allow-Origin header", async () => { + const groups = await nextConfig.headers(); + const allHeaders = groups.flatMap( + (g: { headers: Array<{ key: string; value: string }> }) => g.headers + ); + const acao = allHeaders.find( + (h: { key: string }) => h.key.toLowerCase() === "access-control-allow-origin" + ); + expect(acao).toBeUndefined(); + // And nothing sneaks a comma-list origin into any header value. + for (const h of allHeaders) { + if (h.key.toLowerCase().startsWith("access-control-allow-origin")) { + expect(h.value).not.toContain(","); + } + } + }); + + it("sets modern security headers", async () => { + const groups = await nextConfig.headers(); + const allHeaders = groups.flatMap( + (g: { headers: Array<{ key: string; value: string }> }) => g.headers + ); + const byKey = (k: string): string | undefined => + allHeaders.find((h: { key: string }) => h.key.toLowerCase() === k)?.value; + + expect(byKey("x-xss-protection")).toBe("0"); + expect(byKey("x-content-type-options")).toBe("nosniff"); + expect(byKey("x-frame-options")).toBe("DENY"); + }); +}); diff --git a/src/api/nextjs-backend/src/constants.ts b/src/api/nextjs-backend/src/constants.ts index f04a243..3ef4d2a 100644 --- a/src/api/nextjs-backend/src/constants.ts +++ b/src/api/nextjs-backend/src/constants.ts @@ -8,5 +8,6 @@ export const HttpStatus = { BAD_REQUEST: 400, UNAUTHORIZED: 401, NOT_FOUND: 404, + CONFLICT: 409, INTERNAL_SERVER_ERROR: 500, } as const; diff --git a/src/api/nextjs-backend/src/lib/cors.test.ts b/src/api/nextjs-backend/src/lib/cors.test.ts new file mode 100644 index 0000000..1ec3cc8 --- /dev/null +++ b/src/api/nextjs-backend/src/lib/cors.test.ts @@ -0,0 +1,94 @@ +import { parseAllowedOrigins, resolveAllowOrigin, buildCorsHeaders } from "./cors"; + +describe("parseAllowedOrigins", () => { + it("splits a comma list and trims whitespace", () => { + expect(parseAllowedOrigins("http://a.com, http://b.com ,http://c.com")).toEqual([ + "http://a.com", + "http://b.com", + "http://c.com", + ]); + }); + + it("returns [] for empty/undefined/null input", () => { + expect(parseAllowedOrigins(undefined)).toEqual([]); + expect(parseAllowedOrigins(null)).toEqual([]); + expect(parseAllowedOrigins("")).toEqual([]); + expect(parseAllowedOrigins(" , ")).toEqual([]); + }); + + it("collapses to ['*'] when the list contains a wildcard", () => { + expect(parseAllowedOrigins("http://a.com, *")).toEqual(["*"]); + }); +}); + +describe("resolveAllowOrigin", () => { + it("returns '*' for wildcard without credentials", () => { + expect(resolveAllowOrigin("http://a.com", ["*"], false)).toBe("*"); + }); + + it("reflects the request origin for wildcard WITH credentials (never '*')", () => { + expect(resolveAllowOrigin("http://a.com", ["*"], true)).toBe("http://a.com"); + // '*' + credentials is illegal, and with no origin there is nothing to reflect. + expect(resolveAllowOrigin(null, ["*"], true)).toBeNull(); + }); + + it("reflects a listed origin and rejects an unlisted one", () => { + const allowed = ["http://a.com", "http://b.com"]; + expect(resolveAllowOrigin("http://b.com", allowed)).toBe("http://b.com"); + expect(resolveAllowOrigin("http://evil.com", allowed)).toBeNull(); + }); + + it("returns null when there is no request origin and no wildcard", () => { + expect(resolveAllowOrigin(null, ["http://a.com"])).toBeNull(); + expect(resolveAllowOrigin(undefined, ["http://a.com"])).toBeNull(); + }); + + it("never emits a comma-joined multi-origin value", () => { + const value = resolveAllowOrigin("http://a.com", ["http://a.com", "http://b.com"]); + expect(value).not.toContain(","); + expect(value).toBe("http://a.com"); + }); +}); + +describe("buildCorsHeaders", () => { + it("sets a single reflected origin plus Vary: Origin for a listed origin", () => { + const h = buildCorsHeaders("http://a.com", ["http://a.com", "http://b.com"]); + expect(h["Access-Control-Allow-Origin"]).toBe("http://a.com"); + expect(h["Vary"]).toBe("Origin"); + expect(h["Access-Control-Allow-Methods"]).toContain("GET"); + expect(h["Access-Control-Max-Age"]).toBe("86400"); + expect(h["Access-Control-Allow-Credentials"]).toBeUndefined(); + }); + + it("omits ACAO entirely for an unlisted origin (browser will block)", () => { + const h = buildCorsHeaders("http://evil.com", ["http://a.com"]); + expect(h["Access-Control-Allow-Origin"]).toBeUndefined(); + expect(h["Access-Control-Allow-Methods"]).toBeUndefined(); + // Still advertises Vary so caches key on Origin. + expect(h["Vary"]).toBe("Origin"); + }); + + it("emits '*' and no Vary for wildcard without credentials", () => { + const h = buildCorsHeaders("http://a.com", ["*"]); + expect(h["Access-Control-Allow-Origin"]).toBe("*"); + expect(h["Vary"]).toBeUndefined(); + }); + + it("adds Allow-Credentials and reflects origin under credentialed wildcard", () => { + const h = buildCorsHeaders("http://a.com", ["*"], { credentials: true }); + expect(h["Access-Control-Allow-Origin"]).toBe("http://a.com"); + expect(h["Access-Control-Allow-Credentials"]).toBe("true"); + expect(h["Vary"]).toBe("Origin"); + }); + + it("respects custom methods/headers/maxAge", () => { + const h = buildCorsHeaders("http://a.com", ["http://a.com"], { + methods: "GET", + headers: "Content-Type", + maxAge: 60, + }); + expect(h["Access-Control-Allow-Methods"]).toBe("GET"); + expect(h["Access-Control-Allow-Headers"]).toBe("Content-Type"); + expect(h["Access-Control-Max-Age"]).toBe("60"); + }); +}); diff --git a/src/api/nextjs-backend/src/lib/cors.ts b/src/api/nextjs-backend/src/lib/cors.ts new file mode 100644 index 0000000..24c964d --- /dev/null +++ b/src/api/nextjs-backend/src/lib/cors.ts @@ -0,0 +1,106 @@ +/** + * CORS helpers. + * + * The `Access-Control-Allow-Origin` (ACAO) header is NOT list-valued: per the + * Fetch/CORS spec it must be exactly one origin, the literal `*`, or `null`. + * Emitting a comma- or space-separated list (e.g. the raw value of an + * `ALLOWED_ORIGINS="a,b"` env var) produces a header every browser rejects. + * + * The correct pattern for a multi-origin allow-list is to match the incoming + * `Origin` against the list and reflect back the single matching origin, plus + * `Vary: Origin` so shared caches don't serve one origin's response to another. + */ + +export interface BuildCorsOptions { + /** Whether responses may carry credentials. With credentials, `*` is illegal. */ + credentials?: boolean; + /** Value for Access-Control-Allow-Methods. */ + methods?: string; + /** Value for Access-Control-Allow-Headers. */ + headers?: string; + /** Access-Control-Max-Age (seconds) for preflight caching. */ + maxAge?: number; +} + +const WILDCARD = "*"; + +/** + * Parse a raw allow-list string ("a, b ,c") into a clean array of origins. + * A list containing "*" collapses to ["*"]. + */ +export function parseAllowedOrigins(raw?: string | null): string[] { + if (!raw) { + return []; + } + const parts = raw + .split(",") + .map((o) => o.trim()) + .filter((o) => o.length > 0); + return parts.includes(WILDCARD) ? [WILDCARD] : parts; +} + +/** + * Decide the single value for Access-Control-Allow-Origin. + * + * - Wildcard + no credentials -> "*" + * - Wildcard + credentials -> reflect the request origin (never "*"), + * because "*" with credentials is rejected. + * - Explicit list -> reflect the request origin iff it is listed. + * - Otherwise -> null (omit the header; browser blocks it). + */ +export function resolveAllowOrigin( + requestOrigin: string | null | undefined, + allowed: string[], + credentials = false +): string | null { + const isWildcard = allowed.includes(WILDCARD); + + if (isWildcard && !credentials) { + return WILDCARD; + } + if (isWildcard && credentials) { + return requestOrigin ?? null; + } + if (requestOrigin && allowed.includes(requestOrigin)) { + return requestOrigin; + } + return null; +} + +/** + * Build the full set of CORS response headers for a request. Returns only the + * headers that should actually be set (no invalid/empty values). + */ +export function buildCorsHeaders( + requestOrigin: string | null | undefined, + allowed: string[], + options: BuildCorsOptions = {} +): Record { + const { + credentials = false, + methods = "GET, POST, PUT, DELETE, PATCH, OPTIONS", + headers = "Content-Type, Authorization, X-Requested-With", + maxAge = 86400, + } = options; + + const allowOrigin = resolveAllowOrigin(requestOrigin, allowed, credentials); + const result: Record = {}; + + if (allowOrigin) { + result["Access-Control-Allow-Origin"] = allowOrigin; + result["Access-Control-Allow-Methods"] = methods; + result["Access-Control-Allow-Headers"] = headers; + result["Access-Control-Max-Age"] = String(maxAge); + if (credentials) { + result["Access-Control-Allow-Credentials"] = "true"; + } + } + + // Always advertise that the response varies by Origin whenever the decision + // depends on it (i.e. anything other than a static "*"). + if (allowOrigin !== WILDCARD) { + result["Vary"] = "Origin"; + } + + return result; +} diff --git a/src/api/nextjs-backend/src/middleware.ts b/src/api/nextjs-backend/src/middleware.ts new file mode 100644 index 0000000..1fc1a49 --- /dev/null +++ b/src/api/nextjs-backend/src/middleware.ts @@ -0,0 +1,38 @@ +import { NextRequest, NextResponse } from "next/server"; +import { buildCorsHeaders, parseAllowedOrigins } from "@/lib/cors"; + +/** + * Dynamic CORS for /api routes. + * + * Replaces the previous static `Access-Control-Allow-Origin` header in + * next.config.js, which set the raw (comma-separated) ALLOWED_ORIGINS value — + * an invalid ACAO header that browsers reject. Here we match the request + * Origin against the allow-list and reflect a single valid origin, add + * `Vary: Origin`, and answer preflight (OPTIONS) with 204. + */ +export function middleware(request: NextRequest): NextResponse { + const requestOrigin = request.headers.get("origin"); + const isProduction = process.env.NODE_ENV === "production"; + + // Dev: allow any origin. Prod: only the configured allow-list. + const allowed = isProduction + ? parseAllowedOrigins(process.env.ALLOWED_ORIGINS) + : ["*"]; + + const credentials = process.env.CORS_ALLOW_CREDENTIALS === "true"; + const corsHeaders = buildCorsHeaders(requestOrigin, allowed, { credentials }); + + if (request.method === "OPTIONS") { + return new NextResponse(null, { status: 204, headers: corsHeaders }); + } + + const response = NextResponse.next(); + for (const [key, value] of Object.entries(corsHeaders)) { + response.headers.set(key, value); + } + return response; +} + +export const config = { + matcher: "/api/:path*", +};