Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion .github/workflows/catalog-seed.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
12 changes: 12 additions & 0 deletions src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 18 additions & 1 deletion src/cors.test.ts
Original file line number Diff line number Diff line change
@@ -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: {
Expand Down Expand Up @@ -78,3 +78,20 @@ describe("createCorsOptions", () => {
});
});
});

describe("createCorsOptions methods", () => {
it("allows PATCH in preflight", async () => {
const { createCorsOptions } = await import("./cors.js");
expect(createCorsOptions().methods).toContain("PATCH");
});
});

describe("markOriginWarned", () => {
it("never exceeds the maximum size", () => {
const set = new Set<string>();
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);
});
});
18 changes: 15 additions & 3 deletions src/cors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string>();

/** 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<string> = 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);
Expand All @@ -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",
Expand All @@ -86,7 +98,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] : "*",
};
Expand Down