BUG-045 — CORS misconfiguration: localhost:3000 receives duplicate Access-Control-Allow-Origin + Access-Control-Allow-Credentials headers
| Field | Value |
|---|---|
| Severity | High (CORS misconfiguration enabling credentialed cross-origin access from localhost) |
| Surface | API / headers |
| Status | Open |
| Discovered | 2026-04-20 |
| Discovered by | Manual (curl) |
For every non-chamaconnect.io origin the API correctly returns access-control-allow-origin: https://chamaconnect.io (the canonical origin, not the requester's). However, http://localhost:3000 triggers anomalous behaviour:
Request: GET /api/proxy/users/current-user
Origin: http://localhost:3000
Authorization: Bearer <user token>
Response headers:
vary: rsc, next-router-state-tree, next-router-prefetch, next-router-segment-prefetch
vary: Origin
access-control-allow-credentials: true, true
access-control-allow-headers: Origin, X-Requested-With, Content-Type, Accept, Authorization
access-control-allow-methods: GET, POST, PUT, DELETE, PATCH, OPTIONS
access-control-allow-origin: http://localhost:3000, https://chamaconnect.io
Two problems:
-
Duplicate
Access-Control-Allow-Originheader. RFC 7230 §3.2.2 permits multiple header values, but the browser interpretsACAO: a, bas two origin values. Chrome and Firefox differ on which one "wins" — in Chromium the first value is used, meaninghttp://localhost:3000is the reflected ACAO. Combined withACAC: true, JavaScript from anylocalhost:3000context (browser extension, local HTML file loaded from a web server on port 3000, any npm dev server) can make authenticated credentialed requests to the production API and receive sensitive data. -
Duplicate
Access-Control-Allow-Credentials: true, true. The header has twotruevalues — this is also a protocol violation. Some browsers treat the multi-value header asfalse(since it doesn't equal the single string"true"), breaking legitimate dev flows. Others ignore the duplication and allow it.
# Proof: from a localhost:3000 context, a fetch like this works:
fetch('https://chamaconnect.io/api/proxy/users/current-user', {
credentials: 'include', // or include Authorization header from localStorage
})
# Returns 200 with the logged-in user's full profileReal-world exploit: a malicious browser extension that injects a script on any page, or a malicious local app on port 3000, can silently exfiltrate the victim's transactions, group memberships, PII, and M-Pesa credentials without user interaction — if the victim is currently logged in.
Two CORS middleware layers are running simultaneously: Next.js's built-in CORS handler (configured in next.config.mjs or middleware.ts) and the Express/backend CORS middleware forwarded from the upstream. Both are inserting ACAO and ACAC headers, and one of them reflects localhost origins. The conflict produces duplicate headers.
-
Single source of truth for CORS. Pick one layer — preferably the Next.js edge middleware — and disable CORS on the upstream backend (it should only accept requests from the BFF's internal IP anyway, per BUG-033 fix).
-
Never reflect the
Originheader back asACAOunless the origin is on a known allow-list:
// middleware.ts (or next.config.mjs headers)
const ALLOWED_ORIGINS = new Set([
'https://chamaconnect.io',
'https://www.chamaconnect.io',
// localhost only in development:
...(process.env.NODE_ENV === 'development' ? ['http://localhost:3000', 'http://localhost:3100'] : []),
]);
export function middleware(req: NextRequest) {
const origin = req.headers.get('origin') ?? '';
const acao = ALLOWED_ORIGINS.has(origin) ? origin : 'https://chamaconnect.io';
const resp = NextResponse.next();
resp.headers.set('Access-Control-Allow-Origin', acao);
resp.headers.set('Access-Control-Allow-Credentials', 'true');
resp.headers.set('Vary', 'Origin');
return resp;
}-
Do not allow localhost in production CORS. The
NODE_ENV === 'development'guard above means localhost is only permitted when the server is running locally, never in the deployed version. -
Confirm the upstream Express also stops setting CORS headers once Next.js is the sole CORS layer.
curl -H 'Origin: http://localhost:3000' https://chamaconnect.io/api/proxy/users/current-user→access-control-allow-origin: https://chamaconnect.io(not localhost).- Exactly one
access-control-allow-credentials: trueheader in every response. fetch('https://chamaconnect.io/api/...', { credentials:'include' })fromhttp://localhost:3000context → CORS error in browser, not 200.- Automated test in
/recon/tests/cors.spec.ts.