fix(browser): parse repeated CSP and policy header field lines separately (#6963)

CDP joins repeated response header field lines with LF. Only the Link projection split them, so a repeated Content-Security-Policy lost the first directive of each later policy, and a repeated Referrer-Policy reported no policy. Header normalization now keeps each field line, CSP parses every line and comma-separated policy, single-valued fields combine lines with a comma, and Link uses the shared lines.
This commit is contained in:
Adolanium authored and GitHub committed 2026-10-24 15:45:05 +08:00
1 parent e00ee310f5
commit c2068b94d7
3 files changed
+75 -20

No files matched your search

+6 -2
View File
@@ -404,8 +404,12 @@ allowance; exhaustion fails the observation explicitly instead of guessing scope
## Data minimization
The Link projection parses repeated CDP header field lines independently and
retains their declarations in order. Each link keeps its target in `href` and
Response metadata parses repeated CDP header field lines independently. The
Link projection retains their declarations in order. `csp.directives` lists the
directives of every CSP policy in order, across field lines and comma-separated
policies, without recording policy boundaries, so a directive name can repeat.
Single-valued policy fields combine their lines as one comma-separated value,
as RFC 9110 specifies. Each link keeps its target in `href` and
its relationship context in `context_url` with a separate `context_scope`;
absent `anchor` uses the response URL, while a relative `anchor` resolves
against that URL. Anchored links remain in the projection, but a `link_rel`
+34
View File
@@ -250,6 +250,37 @@ describe("safe CDP response metadata CSP sources", () => {
]);
});
it.each(["\n", "\r\n", ", "])(
"parses every policy of a repeated CSP field joined by %j",
(delimiter) => {
const captured = safeResponseMetadata(
"request-csp-repeated",
`${origin}/api`,
{
headers: {
"Content-Security-Policy": [
"default-src 'self'",
"script-src https://private.example.test; img-src data:",
].join(delimiter),
},
},
new Set([origin]),
);
expect(captured.response.csp.directives).toEqual([
{
name: "default-src",
sources: [{ kind: "keyword", value: "'self'" }],
},
{
name: "script-src",
sources: [{ kind: "external_origin", value: null }],
},
{ name: "img-src", sources: [{ kind: "scheme", value: "data:" }] },
]);
},
);
it("inherits the protected resource scheme for a scheme-less host source", () => {
const captured = safeResponseMetadata(
"request-csp-scheme",
@@ -385,6 +416,9 @@ describe("safe CDP response metadata referrer policy", () => {
["future-policy, no-referrer", "no-referrer"],
["unsafe-url, NO-REFERRER", "no-referrer"],
["origin,, no-referrer", "no-referrer"],
// CDP joins repeated field lines with LF.
["no-referrer\nstrict-origin", "strict-origin"],
["no-referrer\r\nfuture-policy", "no-referrer"],
["future-policy", null],
['unsafe-url, "no-referrer"', null],
["no-referrer; report-to=x, origin", null],
+35 -18
View File
@@ -23,6 +23,9 @@ export const safeResponseMetadata = (
readonly agentHints: AgentHint[];
} => {
const headers = normalizedHeaders(response.headers);
// RFC 9110 section 5.3: repeated field lines combine as one comma-separated value.
const field = (name: string): string | undefined =>
headers.get(name)?.join(", ");
const responseUrl = cdpStringValue(response.url) ?? url;
const parsedLinks = parseLinks(
headers.get("link"),
@@ -51,20 +54,18 @@ export const safeResponseMetadata = (
request_id: requestId,
url,
mime_type: boundedHeader(cdpStringValue(response.mimeType)),
content_length: contentLength(headers.get("content-length")),
content_encoding: boundedHeader(headers.get("content-encoding")),
content_length: contentLength(field("content-length")),
content_encoding: boundedHeader(field("content-encoding")),
csp,
links,
policies: {
coop: structuredFieldToken(headers.get("cross-origin-opener-policy")),
coep: structuredFieldToken(headers.get("cross-origin-embedder-policy")),
corp: policyToken(headers.get("cross-origin-resource-policy")),
referrer_policy: referrerPolicy(headers.get("referrer-policy")),
x_content_type_options: policyToken(
headers.get("x-content-type-options"),
),
coop: structuredFieldToken(field("cross-origin-opener-policy")),
coep: structuredFieldToken(field("cross-origin-embedder-policy")),
corp: policyToken(field("cross-origin-resource-policy")),
referrer_policy: referrerPolicy(field("referrer-policy")),
x_content_type_options: policyToken(field("x-content-type-options")),
permissions_policy_features: permissionFeatures(
headers.get("permissions-policy"),
field("permissions-policy"),
),
},
},
@@ -72,10 +73,16 @@ export const safeResponseMetadata = (
};
};
const normalizedHeaders = (value: unknown): ReadonlyMap<string, string> => {
/**
* Lowercase header names and keep each field line. CDP joins repeated field
* lines with LF, so each line is parsed on its own.
*/
const normalizedHeaders = (
value: unknown,
): ReadonlyMap<string, readonly string[]> => {
if (typeof value !== "object" || value === null || Array.isArray(value))
return new Map();
const headers = new Map<string, string>();
const headers = new Map<string, string[]>();
for (const [name, raw] of Object.entries(value)) {
const normalized = name.toLowerCase();
const header =
@@ -84,20 +91,30 @@ const normalizedHeaders = (value: unknown): ReadonlyMap<string, string> => {
: typeof raw === "number" && Number.isFinite(raw)
? String(raw)
: undefined;
if (header !== undefined) headers.set(normalized, header);
if (header !== undefined)
headers.set(normalized, [
...(headers.get(normalized) ?? []),
...header.split(/\r?\n/u),
]);
}
return headers;
};
/**
* Each field line is a CSP3 serialized policy list, with policies separated by
* commas. Directives of every policy are returned in order; the schema does not
* record which policy a directive came from.
*/
const parseCsp = (
value: string | undefined,
lines: readonly string[] | undefined,
baseUrl: string,
allowedOrigins: ReadonlySet<string>,
): ResponseMetadata["csp"] => {
const directives: ResponseMetadata["csp"]["directives"] = [];
let nonceCount = 0;
let hashCount = 0;
for (const rawDirective of (value ?? "").split(";")) {
const policies = (lines ?? []).flatMap((line) => line.split(","));
for (const rawDirective of policies.flatMap((policy) => policy.split(";"))) {
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
const name = (rawName ?? "").toLowerCase();
if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
@@ -192,15 +209,15 @@ const schemeOf = (baseUrl: string): string | undefined => {
};
const parseLinks = (
value: string | undefined,
lines: readonly string[] | undefined,
responseUrl: string,
allowedOrigins: ReadonlySet<string>,
): ParsedLink[] => {
const links: ParsedLink[] = [];
// Compare resolved identities before safeDestination removes userinfo.
const responseIdentity = resolvedUrl(responseUrl, responseUrl);
// CDP joins repeated field lines with LF; quoted delimiters belong to one line.
for (const line of (value ?? "").split(/\r?\n/u)) {
// Quoted delimiters belong to one field line.
for (const line of lines ?? []) {
for (const entry of splitLinkHeader(line)) {
const match = /^\s*<([^>]*)>(.*)$/u.exec(entry);
if (match === null) continue;