Skip to content

Multiple Content-Security-Policy headers get coalesced by fetch(), silently hiding real misconfigurations (false "good" grade) #117

Description

@dmchaledev

Problem

Sending multiple Content-Security-Policy headers in one response is valid, spec-legitimate HTTP/CSP (e.g. a CDN/edge layer adds a small policy like report-to csp-endpoint or upgrade-insecure-requests, and the origin app adds its own full policy separately). Per the CSP spec, when multiple CSP headers are present, the browser enforces each one independently — a resource must satisfy all of them.

fetchHeadersWithMeta (src/fetch.ts:103-110) builds its headers map with:

res.headers.forEach((value, key) => {
  if (key.toLowerCase() === 'set-cookie') return;
  headers[key.toLowerCase()] = value;
});

Node's fetch() (undici) Headers object automatically coalesces multiple same-name headers into one comma-joined string (this is why Set-Cookie needs its own special-cased getSetCookie() handling right below it — it's the only header with an escape hatch from this behavior). For CSP, that means two independently-enforced policies arrive at checkCSP (src/rules.ts) as a single string like:

report-to csp-endpoint, default-src *; script-src 'self'; object-src 'none'; form-action 'self'; base-uri 'self'

extractCspDirective in src/rules.ts:18-27 splits this on ; and takes tokens[0] of each segment as the directive name. Because the first ;-segment now contains two merged directives (report-to csp-endpoint from policy A, glued by , to default-src * from policy B), tokens[0] for that segment becomes "report-to", not "default-src" — so default-src is never found, and the wildcard is never checked.

Concrete repro

import { checkCSP } from './src/rules.ts';

const edge = 'report-to csp-endpoint';                 // e.g. added by a CDN
const origin = "default-src *; script-src 'self'; object-src 'none'; form-action 'self'; base-uri 'self'"; // origin app's real policy

// This is exactly what fetch.ts's res.headers.forEach sees today when both
// headers are sent on the same response (undici coalesces them for us):
const joined = edge + ', ' + origin;

checkCSP({ 'content-security-policy': joined });
// => score: 20/30, status: 'good', findings: []   <-- WRONG, wildcard is hidden

checkCSP({ 'content-security-policy': origin });
// => score: 15/30, status: 'warning',
//    findings: ["Wildcard or bare-scheme source in default-src allows any origin"]
//    <-- this is the correct result if the origin policy were seen alone

A response with a genuinely wildcarded default-src * — a real XSS-enabling misconfiguration — gets graded good with zero findings instead of flagged, purely as a side effect of a second, perfectly legitimate CSP header being present. For a tool whose entire purpose is grading header security, silently downgrading a real finding to "no issues" is the worst class of bug it can have: it produces a false sense of security. The direction of the corruption is arbitrary (it depends on which directives happen to land in the merged segment), so this can just as easily produce false positives (spurious "missing directive" findings) in other header orderings.

This isn't hypothetical: sending a small edge/CDN-added CSP (upgrade-insecure-requests, or a report-to/reporting-only policy) alongside an app's main policy is a common real-world pattern precisely because CSP is designed to support layering multiple independently-enforced policies from different layers of infrastructure.

Why it can't be fixed with a small patch to rules.ts alone

The corruption happens before checkCSP ever runs — res.headers.forEach() (and res.headers.get()) give no way to recover the original, distinct header lines for any header other than Set-Cookie, which has the dedicated getSetCookie() API specifically because this exact problem is well-known for it. There is no getContentSecurityPolicy() equivalent, and no res.headers.raw() on the standard Fetch Headers object.

Recovering the real, un-coalesced header lines requires bypassing fetch()'s header handling — e.g. using node:http/node:https (or undici's lower-level request()) to read res.rawHeaders, which preserves order and duplicates as a flat array — and then joining same-name CSP headers with a safe delimiter (mirroring the existing '\n'-join pattern already used for Set-Cookie, see splitSetCookieHeader in src/rules.ts:302-304) instead of relying on Headers' automatic , coalescing.

That's a change to the HTTP transport layer in src/fetch.ts, which already carries hand-tuned SSRF protections (see #116, currently open on this same file for a DNS-rebinding TOCTOU gap) — a security-sensitive area that deserves review of the approach before landing, rather than a blind PR. I'm filing this as an issue rather than opening a PR directly for that reason.

Suggested approach

  1. In src/fetch.ts, replace (or supplement) the fetch()-based header retrieval with one that exposes raw, non-coalesced headers (e.g. node:https/node:http request, or undici's request() API), preserving duplicate header lines.
  2. Join multiple same-name CSP headers with '\n' (same delimiter already used for Set-Cookie), producing something like headers['content-security-policy'] = "report-to csp-endpoint\ndefault-src *; script-src 'self'; ...".
  3. Update checkCSP/extractCspDirective in src/rules.ts to split the raw value on '\n' into separate policies first, then evaluate each directive check against the union of all policies that define that directive (matching the browser's independent-enforcement/intersection semantics — e.g. if directive X is wildcarded in any individual policy that defines it, or missing from all policies, that's the reportable finding). This is different from simply concatenating — a directive present in only one of several policies is still fully enforced by that policy.
  4. Add regression tests in test/fetch.test.ts (mocked multi-header response) and test/analyzer.test.ts/wherever checkCSP is unit-tested, covering: (a) a second short/reporting-only CSP header hiding a wildcard in the main policy, and (b) form-action/base-uri/object-src presence-checks split across multiple headers.

References

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't workingsecurity

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions