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
- 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.
- 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'; ...".
- 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.
- 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
Problem
Sending multiple
Content-Security-Policyheaders in one response is valid, spec-legitimate HTTP/CSP (e.g. a CDN/edge layer adds a small policy likereport-to csp-endpointorupgrade-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:Node's
fetch()(undici)Headersobject automatically coalesces multiple same-name headers into one comma-joined string (this is whySet-Cookieneeds its own special-casedgetSetCookie()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 atcheckCSP(src/rules.ts) as a single string like:extractCspDirectiveinsrc/rules.ts:18-27splits this on;and takestokens[0]of each segment as the directive name. Because the first;-segment now contains two merged directives (report-to csp-endpointfrom policy A, glued by,todefault-src *from policy B),tokens[0]for that segment becomes"report-to", not"default-src"— sodefault-srcis never found, and the wildcard is never checked.Concrete repro
A response with a genuinely wildcarded
default-src *— a real XSS-enabling misconfiguration — gets gradedgoodwith 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 areport-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
checkCSPever runs —res.headers.forEach()(andres.headers.get()) give no way to recover the original, distinct header lines for any header other thanSet-Cookie, which has the dedicatedgetSetCookie()API specifically because this exact problem is well-known for it. There is nogetContentSecurityPolicy()equivalent, and nores.headers.raw()on the standard FetchHeadersobject.Recovering the real, un-coalesced header lines requires bypassing
fetch()'s header handling — e.g. usingnode:http/node:https(or undici's lower-levelrequest()) to readres.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 forSet-Cookie, seesplitSetCookieHeaderinsrc/rules.ts:302-304) instead of relying onHeaders' 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
src/fetch.ts, replace (or supplement) thefetch()-based header retrieval with one that exposes raw, non-coalesced headers (e.g.node:https/node:httprequest, or undici'srequest()API), preserving duplicate header lines.'\n'(same delimiter already used for Set-Cookie), producing something likeheaders['content-security-policy'] = "report-to csp-endpoint\ndefault-src *; script-src 'self'; ...".checkCSP/extractCspDirectiveinsrc/rules.tsto 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.test/fetch.test.ts(mocked multi-header response) andtest/analyzer.test.ts/wherevercheckCSPis 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-srcpresence-checks split across multiple headers.References