From 054c838176ddbcb4b25bc98a40f829ce4a8a2c11 Mon Sep 17 00:00:00 2001 From: pq198363-ops <246611021+pq198363-ops@users.noreply.github.com> Date: Sat, 4 Jul 2026 09:21:33 +0800 Subject: [PATCH] security: validate cors allowlist origins --- README.md | 15 +++++++ src/cors.test.ts | 94 +++++++++++++++++++++++++++++++++++++++++ src/middleware/index.ts | 51 +++++++++++++++++++--- 3 files changed, 153 insertions(+), 7 deletions(-) create mode 100644 src/cors.test.ts diff --git a/README.md b/README.md index 337ee4f..a592d49 100644 --- a/README.md +++ b/README.md @@ -74,6 +74,21 @@ agentpay-backend/ stroops, `priceStroops`, `billedStroops`, `/api/v1/billing/*`, and why `POST /api/v1/settle` drains backend counters without moving funds. +## CORS Configuration + +Set `CORS_ALLOWED_ORIGINS` to a comma-separated list of explicit HTTP(S) +origins when browser clients need cross-origin access: + +```bash +CORS_ALLOWED_ORIGINS=https://app.example.com,https://admin.example.com +``` + +Origins are matched after trimming whitespace, lowercasing scheme/host, and +removing a trailing slash. Entries with paths, query strings, fragments, or +non-HTTP schemes are ignored. The wildcard `*` is rejected at startup; this API +does not set `Access-Control-Allow-Credentials`, and deployments should list +trusted origins explicitly instead of relying on wildcard reflection. + ## Quickstart Start a local backend on `http://localhost:3001` with the checked-in diff --git a/src/cors.test.ts b/src/cors.test.ts new file mode 100644 index 0000000..c3706a1 --- /dev/null +++ b/src/cors.test.ts @@ -0,0 +1,94 @@ +import assert from "node:assert"; +import { describe, it } from "node:test"; +import request from "supertest"; +import { createApp } from "./index.js"; + +function withCorsAllowedOrigins(value: string | undefined, fn: () => T): T { + const previous = process.env.CORS_ALLOWED_ORIGINS; + if (value === undefined) { + delete process.env.CORS_ALLOWED_ORIGINS; + } else { + process.env.CORS_ALLOWED_ORIGINS = value; + } + + try { + return fn(); + } finally { + if (previous === undefined) { + delete process.env.CORS_ALLOWED_ORIGINS; + } else { + process.env.CORS_ALLOWED_ORIGINS = previous; + } + } +} + +void describe("CORS origin allowlist", () => { + void it("normalizes configured origins before matching request origins", async () => { + const app = withCorsAllowedOrigins(" https://APP.example.com/ ", () => createApp()); + + const res = await request(app) + .options("/api/v1/version") + .set("Origin", "https://app.example.com"); + + assert.strictEqual(res.status, 204); + assert.strictEqual( + res.headers["access-control-allow-origin"], + "https://app.example.com" + ); + assert.strictEqual(res.headers.vary, "Origin"); + }); + + void it("does not reflect unlisted origins", async () => { + const app = withCorsAllowedOrigins("https://app.example.com", () => createApp()); + + const res = await request(app) + .options("/api/v1/version") + .set("Origin", "https://evil.example.com"); + + assert.strictEqual(res.status, 204); + assert.strictEqual(res.headers["access-control-allow-origin"], undefined); + assert.strictEqual(res.headers.vary, "Origin"); + }); + + void it("logs and skips malformed allowlist entries while keeping valid entries", async () => { + const warnings: string[] = []; + const previousWarn = console.warn; + console.warn = (message?: unknown) => { + warnings.push(String(message)); + }; + + let app: ReturnType; + try { + app = withCorsAllowedOrigins( + "not a url, https://api.example.com/path, https://app.example.com", + () => createApp() + ); + } finally { + console.warn = previousWarn; + } + + const malformed = await request(app) + .options("/api/v1/version") + .set("Origin", "https://api.example.com"); + const valid = await request(app) + .options("/api/v1/version") + .set("Origin", "https://app.example.com"); + + assert.strictEqual(malformed.headers["access-control-allow-origin"], undefined); + assert.strictEqual( + valid.headers["access-control-allow-origin"], + "https://app.example.com" + ); + assert.deepStrictEqual(warnings, [ + "Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: not a url", + "Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: https://api.example.com/path", + ]); + }); + + void it("rejects wildcard allowlist configuration at startup", () => { + assert.throws( + () => withCorsAllowedOrigins("*", () => createApp()), + /CORS_ALLOWED_ORIGINS.*wildcard/i + ); + }); +}); diff --git a/src/middleware/index.ts b/src/middleware/index.ts index 53859a7..364a91e 100644 --- a/src/middleware/index.ts +++ b/src/middleware/index.ts @@ -40,16 +40,16 @@ export function installRequestStateMiddleware(app: Application): void { * CORS allowlist middleware backed by CORS_ALLOWED_ORIGINS. */ function createCorsMiddleware() { - const corsAllowed = (process.env.CORS_ALLOWED_ORIGINS ?? "") - .split(",") - .map((s) => s.trim()) - .filter(Boolean); + const corsAllowed = parseCorsOrigins(process.env.CORS_ALLOWED_ORIGINS); return (req: Request, res: Response, next: NextFunction) => { const origin = req.header("origin"); - if (origin && corsAllowed.includes(origin)) { - res.setHeader("Access-Control-Allow-Origin", origin); - res.setHeader("Vary", "Origin"); + if (origin) { + res.vary("Origin"); + } + const normalizedOrigin = origin ? normalizeCorsOrigin(origin) : undefined; + if (normalizedOrigin && corsAllowed.has(normalizedOrigin)) { + res.setHeader("Access-Control-Allow-Origin", normalizedOrigin); res.setHeader( "Access-Control-Allow-Methods", "GET,POST,PUT,PATCH,DELETE,OPTIONS" @@ -68,6 +68,43 @@ function createCorsMiddleware() { }; } +/** + * Parses configured CORS origins into canonical scheme://host[:port] entries. + */ +function parseCorsOrigins(raw: string | undefined): Set { + const origins = new Set(); + for (const entry of (raw ?? "").split(",")) { + const trimmed = entry.trim(); + if (!trimmed) continue; + if (trimmed === "*") { + throw new Error( + "CORS_ALLOWED_ORIGINS wildcard '*' is not supported; list explicit http(s) origins" + ); + } + + const normalized = normalizeCorsOrigin(trimmed); + if (normalized) { + origins.add(normalized); + } else { + console.warn( + `Ignoring malformed CORS origin in CORS_ALLOWED_ORIGINS: ${trimmed}` + ); + } + } + return origins; +} + +function normalizeCorsOrigin(value: string): string | undefined { + try { + const url = new URL(value); + if (url.protocol !== "http:" && url.protocol !== "https:") return undefined; + if (url.pathname !== "/" || url.search || url.hash) return undefined; + return url.origin.toLowerCase(); + } catch { + return undefined; + } +} + /** Adds the minimal hardening headers used by the original app. */ function securityHeadersMiddleware( _req: Request,