-
Notifications
You must be signed in to change notification settings - Fork 101
fix(cors): reject wildcard origin with credentials #366
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from 1 commit
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| /** Default browser origin for local development when CORS_ORIGIN is unset. */ | ||
| export const DEFAULT_DEV_CORS_ORIGIN = "http://localhost:3000"; | ||
|
|
||
| /** | ||
| * Parse and validate CORS_ORIGIN for credentialed cross-origin requests. | ||
| * Wildcard (*) is rejected: browsers forbid ACAO:* with Allow-Credentials. | ||
| */ | ||
| export function parseCorsOrigins(raw: string | undefined, nodeEnv: string): string[] { | ||
| const trimmed = raw?.trim(); | ||
|
|
||
| if (!trimmed) { | ||
| if (nodeEnv === "production") { | ||
| throw new Error( | ||
| "CORS_ORIGIN is required in production. Set a comma-separated list of exact origins " + | ||
| "(e.g. https://app.example.com). Wildcard * is not allowed with credentials.", | ||
| ); | ||
| } | ||
| return [DEFAULT_DEV_CORS_ORIGIN]; | ||
| } | ||
|
|
||
| const origins = trimmed | ||
| .split(",") | ||
| .map((entry) => entry.trim()) | ||
| .filter(Boolean); | ||
|
|
||
| if (origins.length === 0) { | ||
| throw new Error( | ||
| "CORS_ORIGIN must list at least one origin when set (wildcard * is not allowed).", | ||
| ); | ||
| } | ||
|
|
||
| if (origins.some((origin) => origin === "*")) { | ||
| throw new Error( | ||
| "CORS_ORIGIN must not contain wildcard (*). List explicit origins; " + | ||
| "credentialed responses require Access-Control-Allow-Origin to match the request origin.", | ||
| ); | ||
| } | ||
|
|
||
| const normalized: string[] = []; | ||
| for (const origin of origins) { | ||
| let url: URL; | ||
| try { | ||
| url = new URL(origin); | ||
| } catch { | ||
| throw new Error( | ||
| `Invalid CORS origin "${origin}". Use full origins such as https://app.example.com`, | ||
| ); | ||
| } | ||
| if (url.protocol !== "http:" && url.protocol !== "https:") { | ||
| throw new Error( | ||
| `Invalid CORS origin "${origin}". Only http:// and https:// origins are supported.`, | ||
| ); | ||
| } | ||
| if (url.username || url.password || url.pathname !== "/" || url.search || url.hash) { | ||
|
|
||
| throw new Error( | ||
| `Invalid CORS origin "${origin}". Provide scheme, host, and port only (no path or credentials).`, | ||
| ); | ||
| } | ||
| normalized.push(url.origin); | ||
| } | ||
|
|
||
| return [...new Set(normalized)]; | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,17 +1,47 @@ | ||
| import cors from "cors"; | ||
| import cors, { type CorsOptions } from "cors"; | ||
| import { config } from "../config/env"; | ||
|
|
||
| export const corsMiddleware = cors({ | ||
| origin: (origin, callback) => { | ||
| if ( | ||
| !origin || config.corsOrigin.includes(origin) | ||
| ) { | ||
| callback(null, true); | ||
| } else { | ||
| callback(new Error("Not allowed by CORS")); | ||
| } | ||
| }, | ||
| /** | ||
| * Resolve the Access-Control-Allow-Origin value for a request. | ||
| * Returns the exact origin string when allowed, false when denied, | ||
| * or null when the request has no Origin (non-browser / same-origin tools). | ||
| */ | ||
| export function resolveCorsOrigin( | ||
| requestOrigin: string | undefined, | ||
| allowedOrigins: readonly string[], | ||
| ): string | false | null { | ||
| if (!requestOrigin) { | ||
| return null; | ||
| } | ||
| if (allowedOrigins.includes(requestOrigin)) { | ||
| return requestOrigin; | ||
| } | ||
| return false; | ||
| } | ||
|
|
||
| const corsOptionsBase: Omit<CorsOptions, "origin"> = { | ||
| credentials: true, | ||
| methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"], | ||
| allowedHeaders: ["Content-Type", "Authorization", "X-API-Key"], | ||
| }); | ||
| }; | ||
|
|
||
| /** Build CORS middleware for a fixed allowlist (used in tests and production). */ | ||
| export function createCorsMiddleware(allowedOrigins: readonly string[]) { | ||
| return cors({ | ||
| ...corsOptionsBase, | ||
| origin: (origin, callback) => { | ||
| const resolved = resolveCorsOrigin(origin, allowedOrigins); | ||
| if (resolved === null) { | ||
| callback(null, true); | ||
| return; | ||
| } | ||
| if (resolved === false) { | ||
| callback(new Error("Not allowed by CORS")); | ||
| return; | ||
| } | ||
| callback(null, resolved); | ||
| }, | ||
| }); | ||
| } | ||
|
|
||
| export const corsMiddleware = createCorsMiddleware(config.corsOrigin); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,101 @@ | ||
| import express from "express"; | ||
| import request from "supertest"; | ||
| import { parseCorsOrigins, DEFAULT_DEV_CORS_ORIGIN } from "../src/config/corsOrigins"; | ||
| import { createCorsMiddleware, resolveCorsOrigin } from "../src/middleware/cors"; | ||
|
|
||
| describe("parseCorsOrigins", () => { | ||
| it("defaults to localhost in non-production when unset", () => { | ||
| expect(parseCorsOrigins(undefined, "development")).toEqual([DEFAULT_DEV_CORS_ORIGIN]); | ||
| expect(parseCorsOrigins(undefined, "test")).toEqual([DEFAULT_DEV_CORS_ORIGIN]); | ||
| }); | ||
|
|
||
| it("requires explicit origins in production when unset", () => { | ||
| expect(() => parseCorsOrigins(undefined, "production")).toThrow( | ||
| /CORS_ORIGIN is required in production/, | ||
| ); | ||
| }); | ||
|
|
||
| it("rejects wildcard *", () => { | ||
| expect(() => parseCorsOrigins("*", "development")).toThrow(/wildcard/i); | ||
| expect(() => parseCorsOrigins("https://a.com,*", "development")).toThrow(/wildcard/i); | ||
| }); | ||
|
|
||
| it("parses, trims, and deduplicates comma-separated origins", () => { | ||
| expect( | ||
| parseCorsOrigins( | ||
| " https://app.example.com ,https://admin.example.com,https://app.example.com ", | ||
| "development", | ||
| ), | ||
| ).toEqual(["https://app.example.com", "https://admin.example.com"]); | ||
| }); | ||
|
|
||
| it("normalizes valid origins to URL.origin form", () => { | ||
| expect(parseCorsOrigins("https://app.example.com:443", "development")).toEqual([ | ||
| "https://app.example.com", | ||
| ]); | ||
| }); | ||
|
|
||
| it("rejects origins with paths", () => { | ||
| expect(() => parseCorsOrigins("https://app.example.com/dashboard", "development")).toThrow( | ||
| /scheme, host, and port only/i, | ||
| ); | ||
| }); | ||
| }); | ||
|
|
||
| describe("resolveCorsOrigin", () => { | ||
| const allowed = ["http://localhost:3000", "https://app.example.com"]; | ||
|
|
||
| it("returns null when Origin header is absent", () => { | ||
| expect(resolveCorsOrigin(undefined, allowed)).toBeNull(); | ||
| }); | ||
|
|
||
| it("returns the request origin when it is on the allowlist", () => { | ||
| expect(resolveCorsOrigin("https://app.example.com", allowed)).toBe("https://app.example.com"); | ||
| }); | ||
|
|
||
| it("returns false for origins not on the allowlist", () => { | ||
| expect(resolveCorsOrigin("https://evil.example.com", allowed)).toBe(false); | ||
| }); | ||
| }); | ||
|
|
||
| describe("createCorsMiddleware", () => { | ||
| const allowedOrigin = "http://localhost:3000"; | ||
|
|
||
| function buildApp(allowedOrigins: string[]) { | ||
| const app = express(); | ||
| app.use(createCorsMiddleware(allowedOrigins)); | ||
| app.get("/ping", (_req, res) => { | ||
| res.json({ ok: true }); | ||
| }); | ||
| return app; | ||
| } | ||
|
|
||
| it("reflects an allowed Origin (never *) with credentials", async () => { | ||
| const res = await request(buildApp([allowedOrigin])) | ||
| .get("/ping") | ||
| .set("Origin", allowedOrigin) | ||
| .expect(200); | ||
|
|
||
| expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); | ||
| expect(res.headers["access-control-allow-origin"]).not.toBe("*"); | ||
| expect(res.headers["access-control-allow-credentials"]).toBe("true"); | ||
| }); | ||
|
|
||
| it("rejects disallowed cross-origin requests", async () => { | ||
| await request(buildApp([allowedOrigin])) | ||
| .get("/ping") | ||
| .set("Origin", "https://evil.example.com") | ||
| .expect(500); | ||
|
|
||
| }); | ||
|
Comment on lines
+90
to
+97
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Description: Check cors library behavior when origin callback returns an error
# Search for error handling in the cors middleware and related Express error patterns
rg -nP -C5 'callback\(new Error.*CORS' --type=ts
# Look for any existing documentation or tests about expected status codes
rg -nP 'expect\(\d{3}\)|toHaveStatus|status.*\d{3}' tests/cors.test.tsRepository: Pi-Defi-world/acbu-backend Length of output: 641 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Show the whole relevant test file section around the disallowed-origin test
nl -ba tests/cors.test.ts | sed -n '1,220p'
# 2) Inspect CORS middleware implementation
nl -ba src/middleware/cors.ts | sed -n '1,220p'
# 3) Find where buildApp registers error handlers (to see what status errors map to)
# (Look for common Express error-handler signatures or `app.use((err, req, res, next) => ...)`)
rg -n --hidden --glob='!**/node_modules/**' "buildApp|function buildApp|app\.use\(\s*\(err" src tests || true
rg -n --hidden --glob='!**/node_modules/**' "res\.status\(|status\(" src | head -n 50
# 4) Check if there's a centralized error handler file
fd -t f -a 'error|errors|middleware.*error|http.*error' . 2>/dev/null | head -n 50
rg -n --hidden --glob='!**/node_modules/**' "Not allowed by CORS|cors" src | head -n 50Repository: Pi-Defi-world/acbu-backend Length of output: 113 🏁 Script executed: #!/bin/bash
set -euo pipefail
# 1) Show relevant parts of tests/cors.test.ts (first 220 lines; adjust if needed)
echo "=== tests/cors.test.ts (1-220) ==="
cat -n tests/cors.test.ts | sed -n '1,220p'
# 2) Inspect CORS middleware implementation
echo "=== src/middleware/cors.ts (1-220) ==="
cat -n src/middleware/cors.ts | sed -n '1,220p'
# 3) Locate Express error handler(s) in the app setup
echo "=== searching for error-handler signatures ==="
rg -n --hidden --glob='!**/node_modules/**' "(err\s*,\s*req\s*,\s*res\s*,\s*next)|(function\s*\(\s*err\s*,\s*req)|(app\.use\(\s*\(\s*err)" src tests || true
# 4) Find buildApp registration of middleware/error handling
echo "=== searching for buildApp ==="
rg -n --hidden --glob='!**/node_modules/**' "buildApp" src tests || true
# 5) If there's an error-handling module, inspect it
echo "=== searching for error handling modules ==="
fd -t f -a 'error|errors|http.*error|middleware.*error' . 2>/dev/null | head -n 50
# 6) If we find a candidate error handler file, print it (top portion)
# (Try common filename matches first)
for f in $(fd -t f -a 'error|errors' src 2>/dev/null | head -n 10); do
echo "=== candidate: $f (first 200 lines) ==="
cat -n "$f" | sed -n '1,200p'
done
# 7) Also inspect where routes/middleware are wired (if there is an app file)
for f in $(fd -t f -a 'app\.ts$|server\.ts$|index\.ts$|main\.ts$' src 2>/dev/null | head -n 10); do
echo "=== candidate: $f (first 200 lines) ==="
cat -n "$f" | sed -n '1,200p'
doneRepository: Pi-Defi-world/acbu-backend Length of output: 34068 🌐 Web query:
💡 Result:
Citations:
Update the disallowed-origin CORS test to assert the correct HTTP status (200, not 500). In 🤖 Prompt for AI Agents |
||
|
|
||
| it("handles preflight OPTIONS with reflected origin", async () => { | ||
| const res = await request(buildApp([allowedOrigin])) | ||
| .options("/ping") | ||
| .set("Origin", allowedOrigin) | ||
| .set("Access-Control-Request-Method", "GET") | ||
| .expect(204); | ||
|
|
||
| expect(res.headers["access-control-allow-origin"]).toBe(allowedOrigin); | ||
| expect(res.headers["access-control-allow-credentials"]).toBe("true"); | ||
| }); | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.