From 5a97d4c9971dba79c5b0fa6468ebc33c597b3533 Mon Sep 17 00:00:00 2001 From: xcel Date: Tue, 25 Aug 2026 23:49:05 +0100 Subject: [PATCH] config: support multiple CORS origins and fail closed in production MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CORS_ORIGIN was a single string defaulting to "http://localhost:3000" — a preview deployment and production couldn't each have their own origin, and an unset variable silently fell back to a development origin in production instead of failing startup. - CORS_ORIGIN is now comma-separated, parsed into a string array (parseCorsOrigins), so multiple origins are supported at once. - No default in the schema. In production, CORS_ORIGIN must be set explicitly and must not contain "*" — enforced by a superRefine on the env schema, so a missing or wildcard value fails startup rather than the first request. - Outside production, an unset CORS_ORIGIN still falls back to the dev default (resolveCorsOrigins) so local development is unaffected. - @fastify/cors is now configured with credentials and an explicit methods list instead of leaving both implicit. Tests cover comma-separated parsing, the dev fallback, the production guard rejecting a missing value, the production guard rejecting "*" (alone and mixed into a list), and production accepting a valid explicit origin. Closes #2. --- src/config/env.test.ts | 50 +++++++++++++++++++++++++++++++++++-- src/config/env.ts | 56 +++++++++++++++++++++++++++++++++++++++--- src/index.ts | 2 ++ 3 files changed, 103 insertions(+), 5 deletions(-) diff --git a/src/config/env.test.ts b/src/config/env.test.ts index 1d3a673..93ea96d 100644 --- a/src/config/env.test.ts +++ b/src/config/env.test.ts @@ -19,7 +19,9 @@ const baseEnv = { Object.assign(process.env, baseEnv); -const { envSchema, parseEnv } = await import("./env.js"); +const { baseEnvSchema, parseEnv, parseCorsOrigins, resolveCorsOrigins } = await import( + "./env.js" +); function exampleEnvKeys(): string[] { return readFileSync(".env.example", "utf8") @@ -53,8 +55,52 @@ test("contract IDs must be Soroban contract-shaped strkeys", () => { ); }); +test("CORS_ORIGIN parses a comma-separated list into multiple origins", () => { + assert.deepEqual(parseCorsOrigins("https://a.example, https://b.example"), [ + "https://a.example", + "https://b.example", + ]); + assert.deepEqual(parseCorsOrigins(undefined), []); + assert.deepEqual(parseCorsOrigins(""), []); +}); + +test("resolveCorsOrigins falls back to the dev default outside production", () => { + assert.deepEqual(resolveCorsOrigins(undefined, "development"), ["http://localhost:3000"]); + assert.deepEqual(resolveCorsOrigins("https://a.example", "development"), [ + "https://a.example", + ]); +}); + +test("production requires CORS_ORIGIN to be set explicitly", () => { + const { CORS_ORIGIN, ...withoutCors } = baseEnv; + assert.throws( + () => parseEnv({ ...withoutCors, NODE_ENV: "production" }), + /CORS_ORIGIN/, + ); +}); + +test('production rejects "*" as a CORS origin', () => { + assert.throws( + () => parseEnv({ ...baseEnv, NODE_ENV: "production", CORS_ORIGIN: "*" }), + /CORS_ORIGIN/, + ); + assert.throws( + () => parseEnv({ ...baseEnv, NODE_ENV: "production", CORS_ORIGIN: "https://a.example,*" }), + /CORS_ORIGIN/, + ); +}); + +test("production accepts an explicit, non-wildcard CORS_ORIGIN", () => { + const parsed = parseEnv({ + ...baseEnv, + NODE_ENV: "production", + CORS_ORIGIN: "https://app.example.com", + }); + assert.equal(parsed.CORS_ORIGIN, "https://app.example.com"); +}); + test(".env.example carries every required schema key", () => { - const schemaKeys = Object.keys(envSchema.shape); + const schemaKeys = Object.keys(baseEnvSchema.shape); const optionalKeys = new Set(["SIGNING_KMS_KEY_ID", "SIGNING_ENV_SECRET_KEY"]); const exampleKeys = new Set(exampleEnvKeys()); diff --git a/src/config/env.ts b/src/config/env.ts index 337f88a..624154a 100644 --- a/src/config/env.ts +++ b/src/config/env.ts @@ -5,11 +5,19 @@ const contractId = z .string() .regex(/^C[A-Z2-7]{55}$/, "must be a Soroban contract ID"); -export const envSchema = z.object({ +const DEV_DEFAULT_ORIGIN = "http://localhost:3000"; + +const baseEnvSchema = z.object({ NODE_ENV: z.string().default("development"), PORT: z.coerce.number().default(8080), API_PREFIX: z.string().default("/api/v1"), - CORS_ORIGIN: z.string().default("http://localhost:3000"), + // No default: a CORS default that's fine in development and silently + // persists into production is how this becomes a real vulnerability. + // Comma-separated so a preview deployment and production can each have + // their own origin(s) at once. Required outright in production — + // enforced below rather than here, since the requirement is conditional + // on NODE_ENV. + CORS_ORIGIN: z.string().optional(), STELLAR_NETWORK: z.enum(["testnet", "futurenet", "mainnet"]), SOROBAN_RPC_URL: z.string().url(), AUDIT_REGISTRY_CONTRACT_ID: contractId, @@ -24,6 +32,48 @@ export const envSchema = z.object({ SIGNING_ENV_SECRET_KEY: z.string().optional(), }); +/** Exposed separately so callers that need `.shape` (e.g. tests) can get at + * it — `envSchema` itself is a refined schema and no longer exposes it. */ +export { baseEnvSchema }; + +export const envSchema = baseEnvSchema.superRefine((env, ctx) => { + if (env.NODE_ENV !== "production") return; + + if (!env.CORS_ORIGIN || env.CORS_ORIGIN.trim().length === 0) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["CORS_ORIGIN"], + message: "CORS_ORIGIN must be set explicitly in production — no default is allowed.", + }); + return; + } + + if (parseCorsOrigins(env.CORS_ORIGIN).includes("*")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + path: ["CORS_ORIGIN"], + message: 'CORS_ORIGIN may not be "*" in production.', + }); + } +}); + +/** Splits a comma-separated origin list into trimmed, non-empty entries. */ +export function parseCorsOrigins(raw: string | undefined): string[] { + if (!raw) return []; + return raw + .split(",") + .map((origin) => origin.trim()) + .filter((origin) => origin.length > 0); +} + +export function resolveCorsOrigins(raw: string | undefined, nodeEnv: string): string[] { + const parsed = parseCorsOrigins(raw); + // Production requires an explicit, non-wildcard value — already enforced + // by envSchema at parse time, so reaching here with an empty list means + // we're outside production and can fall back to the dev default. + return parsed.length > 0 ? parsed : [DEV_DEFAULT_ORIGIN]; +} + export function parseEnv(env: NodeJS.ProcessEnv) { return envSchema.parse(env); } @@ -34,7 +84,7 @@ export const config = { nodeEnv: raw.NODE_ENV, port: raw.PORT, apiPrefix: raw.API_PREFIX, - corsOrigin: raw.CORS_ORIGIN, + corsOrigin: resolveCorsOrigins(raw.CORS_ORIGIN, raw.NODE_ENV), stellar: { network: raw.STELLAR_NETWORK, sorobanRpcUrl: raw.SOROBAN_RPC_URL, diff --git a/src/index.ts b/src/index.ts index 9b05214..7572950 100644 --- a/src/index.ts +++ b/src/index.ts @@ -61,6 +61,8 @@ async function buildServer() { await app.register(cors, { origin: config.corsOrigin, + credentials: true, + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], }); await app.register(healthRoutes);