From 9db0d6a82ccb9d521d868560f932661a09565f24 Mon Sep 17 00:00:00 2001 From: bobthecomputer Date: Fri, 24 Jul 2026 05:14:19 +0200 Subject: [PATCH] fix: validate configured CORS origins --- README.md | 2 +- src/config.test.ts | 31 +++++++++++++++++++++++++++++++ src/config.ts | 33 ++++++++++++++++++++++++++++++++- 3 files changed, 64 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 65c3640..2cc0847 100644 --- a/README.md +++ b/README.md @@ -237,7 +237,7 @@ operators can pause writes without taking the whole API down. | `PORT` | `3001` | HTTP port | | `FEE_BPS` | `10` | Protocol fee in basis points; must be `0`-`10000` or the process fails to start | | `API_KEY` | – | If set, mutating requests must send `x-api-key` | -| `CORS_ORIGIN` | – | Comma-separated allowlist of origins; unset allows any origin | +| `CORS_ORIGIN` | – | Comma-separated allowlist of HTTP(S) origins, including scheme and optional port; invalid entries fail startup, while unset allows any origin | | `BODY_LIMIT` | `100kb` | Maximum accepted JSON request body size (`express.json` `limit` syntax) | | `MAINTENANCE_MODE` | `false` | When `1`/`true`, mutating requests are rejected with `503` | | `NODE_ENV` | `development` | Environment name | diff --git a/src/config.test.ts b/src/config.test.ts index 21ba172..f7a66dd 100644 --- a/src/config.test.ts +++ b/src/config.test.ts @@ -63,6 +63,37 @@ describe("loadConfig", () => { expect(loadConfig({ CORS_ORIGIN: " , " }).corsOrigins).toBeUndefined(); }); + it.each([ + "http://localhost:3000", + "https://api.example.com", + "https://api.example.com:8443", + "https://[2001:db8::1]:8443", + ])("accepts the valid CORS origin %p", (origin) => { + expect(loadConfig({ CORS_ORIGIN: origin }).corsOrigins).toEqual([origin]); + }); + + it.each([ + "example.com", + "*", + "ftp://example.com", + "https://user:password@example.com", + "https://example.com/path", + "https://example.com?query=value", + "https://example.com#fragment", + ])("rejects the invalid CORS origin %p", (origin) => { + expect(() => loadConfig({ CORS_ORIGIN: origin })).toThrow( + `CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`, + ); + }); + + it("rejects the full allowlist when one CORS origin is malformed", () => { + expect(() => + loadConfig({ + CORS_ORIGIN: "https://valid.example, example.com", + }), + ).toThrow(/example\.com/); + }); + it("defaults the JSON body size limit to 100kb", () => { expect(loadConfig({}).bodyLimit).toBe("100kb"); }); diff --git a/src/config.ts b/src/config.ts index 5f2edd3..2e779dd 100644 --- a/src/config.ts +++ b/src/config.ts @@ -2,6 +2,8 @@ * Typed application configuration loaded from environment variables. */ +import { URL } from "node:url"; + export interface Config { /** Port the HTTP server binds to. */ port: number; @@ -40,7 +42,9 @@ function intFromEnv(value: string | undefined, fallback: number): number { /** * Parses a comma-separated `CORS_ORIGIN` value into a list of allowed - * origins, trimming whitespace and dropping empty entries. Returns + * origins, trimming whitespace and dropping empty entries. Only HTTP(S) + * origins are accepted; paths, credentials, queries, and fragments are + * rejected so configuration mistakes fail visibly at startup. Returns * `undefined` when unset or when every entry is blank. */ function parseCorsOrigins(value: string | undefined): string[] | undefined { @@ -49,6 +53,33 @@ function parseCorsOrigins(value: string | undefined): string[] | undefined { .split(",") .map((origin) => origin.trim()) .filter((origin) => origin !== ""); + + for (const origin of origins) { + let parsed: URL; + try { + parsed = new URL(origin); + } catch { + throw new Error( + `CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`, + ); + } + + const isHttpOrigin = + parsed.protocol === "http:" || parsed.protocol === "https:"; + const hasOriginOnly = + parsed.username === "" && + parsed.password === "" && + parsed.pathname === "/" && + parsed.search === "" && + parsed.hash === ""; + + if (!isHttpOrigin || !hasOriginOnly) { + throw new Error( + `CORS_ORIGIN contains an invalid origin: ${JSON.stringify(origin)}`, + ); + } + } + return origins.length > 0 ? origins : undefined; }