From 64f4ff85b4b88d8c9f6a25ae994de94cf9013038 Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Mon, 19 Oct 2026 19:45:28 +0100 Subject: [PATCH 1/4] fix(config): require BASE_URL and WEB_APP_URL in production (#63) --- src/config.ts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/src/config.ts b/src/config.ts index b8029ab..b0b8fe3 100644 --- a/src/config.ts +++ b/src/config.ts @@ -290,6 +290,18 @@ const envSchema = z.object({ DB_STATEMENT_TIMEOUT_MS: z.coerce.number().int().nonnegative().default(30_000), }); +// In production the localhost defaults would leak into accessUrl values and the +// CORS allowlist, so both URLs must be set explicitly. +if (envWithDefaults.NODE_ENV === "production") { + const missing = (["BASE_URL", "WEB_APP_URL"] as const).filter((k) => !envWithDefaults[k]?.trim()); + if (missing.length > 0) { + for (const key of missing) { + console.error(`[config] ${key}: required when NODE_ENV=production (no localhost default is applied)`); + } + process.exit(1); + } +} + const parsed = envSchema.safeParse(envWithDefaults); if (!parsed.success) { From 5b1c738c7690fa27ef0d1844a5e905b837c6510d Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Mon, 19 Oct 2026 19:45:28 +0100 Subject: [PATCH 2/4] fix(cors): allow PATCH in preflight (#64) --- src/cors.test.ts | 7 +++++++ src/cors.ts | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/cors.test.ts b/src/cors.test.ts index 7595560..9fdbe6c 100644 --- a/src/cors.test.ts +++ b/src/cors.test.ts @@ -78,3 +78,10 @@ describe("createCorsOptions", () => { }); }); }); + +describe("createCorsOptions methods", () => { + it("allows PATCH in preflight", async () => { + const { createCorsOptions } = await import("./cors.js"); + expect(createCorsOptions().methods).toContain("PATCH"); + }); +}); diff --git a/src/cors.ts b/src/cors.ts index 14e0936..80b1ec4 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -86,7 +86,7 @@ export function createCorsOptions(): CorsOptions { } : true, credentials: true, - methods: ["GET", "POST", "PUT", "DELETE", "OPTIONS"], + methods: ["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"], allowedHeaders: isProduction ? [...X402_ALLOWED_HEADERS] : "*", exposedHeaders: isProduction ? [...X402_EXPOSED_HEADERS] : "*", }; From b44702e2f2ee1106a0ad837c349795f4d28e90f7 Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Mon, 19 Oct 2026 19:45:28 +0100 Subject: [PATCH 3/4] fix(cors): bound rejected-origin tracking (#65) --- src/cors.test.ts | 12 +++++++++++- src/cors.ts | 16 ++++++++++++++-- 2 files changed, 25 insertions(+), 3 deletions(-) diff --git a/src/cors.test.ts b/src/cors.test.ts index 9fdbe6c..5873eae 100644 --- a/src/cors.test.ts +++ b/src/cors.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { isOriginAllowed, parseAllowedOrigins, X402_ALLOWED_HEADERS, X402_EXPOSED_HEADERS } from "./cors.js"; +import { isOriginAllowed, markOriginWarned, parseAllowedOrigins, X402_ALLOWED_HEADERS, X402_EXPOSED_HEADERS } from "./cors.js"; vi.mock("./config.js", () => ({ config: { @@ -85,3 +85,13 @@ describe("createCorsOptions methods", () => { expect(createCorsOptions().methods).toContain("PATCH"); }); }); + +describe("markOriginWarned", () => { + it("never exceeds the maximum size", () => { + const set = new Set(); + for (let i = 0; i < 20; i++) markOriginWarned(`https://evil${i}.example`, set, 5); + expect(set.size).toBe(5); + expect(set.has("https://evil19.example")).toBe(true); + expect(set.has("https://evil0.example")).toBe(false); + }); +}); diff --git a/src/cors.ts b/src/cors.ts index 80b1ec4..6a98c92 100644 --- a/src/cors.ts +++ b/src/cors.ts @@ -56,8 +56,21 @@ export function isOriginAllowed(origin: string, allowedOrigins: string[]): boole }); } +/** Cap on remembered rejected origins so attacker-supplied values cannot grow memory unbounded. */ +export const MAX_WARNED_ORIGINS = 500; const warnedOrigins = new Set(); +/** Records an origin as warned; returns true if it was newly recorded. Evicts the oldest at the cap. */ +export function markOriginWarned(origin: string, set: Set = warnedOrigins, max = MAX_WARNED_ORIGINS): boolean { + if (set.has(origin)) return false; + if (set.size >= max) { + const oldest = set.values().next().value; + if (oldest !== undefined) set.delete(oldest); + } + set.add(origin); + return true; +} + export function createCorsOptions(): CorsOptions { const isProduction = config.NODE_ENV === "production"; const allowedOrigins = parseAllowedOrigins(config.ALLOWED_ORIGINS, config.WEB_APP_URL); @@ -75,8 +88,7 @@ export function createCorsOptions(): CorsOptions { } // Omit CORS headers (the browser blocks the response) rather than // erroring, which would turn every such request into a 500. - if (!warnedOrigins.has(origin)) { - warnedOrigins.add(origin); + if (markOriginWarned(origin)) { rootLogger.warn( { event: "cors_origin_rejected", origin, allowedOrigins }, "origin not in ALLOWED_ORIGINS / WEB_APP_URL; browser requests will be blocked", From 585f677c68f89212ee8282063da603676cbb6ebc Mon Sep 17 00:00:00 2001 From: Dev-makeem Date: Mon, 19 Oct 2026 19:45:38 +0100 Subject: [PATCH 4/4] fix(ci): replace wildcard ALLOWED_ORIGINS in catalog seed workflow (#66) --- .github/workflows/catalog-seed.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/catalog-seed.yml b/.github/workflows/catalog-seed.yml index db45ce2..3ac4236 100644 --- a/.github/workflows/catalog-seed.yml +++ b/.github/workflows/catalog-seed.yml @@ -37,7 +37,9 @@ jobs: SUPABASE_URL: https://placeholder.supabase.co SUPABASE_SERVICE_KEY: placeholder_for_ci ADMIN_API_KEY: ${{ secrets.ADMIN_API_KEY }} - ALLOWED_ORIGINS: "*" + ALLOWED_ORIGINS: "http://localhost:3000" + BASE_URL: http://localhost:3000 + WEB_APP_URL: http://localhost:3000 steps: - uses: actions/checkout@v4