From c2068b94d718d4d133cdd18779a8514f1314a320 Mon Sep 17 00:00:00 2001 From: Adolanium <94890352+Adolanium@users.noreply.github.com> Date: Sat, 24 Oct 2026 10:45:05 +0300 Subject: [PATCH] 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. --- docs/browser-observation.md | 8 +++-- src/browser/CdpSafeMetadata.test.ts | 34 ++++++++++++++++++ src/browser/CdpSafeMetadata.ts | 53 +++++++++++++++++++---------- 3 files changed, 75 insertions(+), 20 deletions(-) diff --git a/docs/browser-observation.md b/docs/browser-observation.md index 923a2cf786..00a255b2b2 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -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` diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts index f8f053a0fb..036ecf0029 100644 --- a/src/browser/CdpSafeMetadata.test.ts +++ b/src/browser/CdpSafeMetadata.test.ts @@ -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], diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index c61a6b6d25..276e2689eb 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -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 => { +/** + * 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 => { if (typeof value !== "object" || value === null || Array.isArray(value)) return new Map(); - const headers = new Map(); + const headers = new Map(); for (const [name, raw] of Object.entries(value)) { const normalized = name.toLowerCase(); const header = @@ -84,20 +91,30 @@ const normalizedHeaders = (value: unknown): ReadonlyMap => { : 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, ): 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, ): 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;