mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
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:
1 parent
e00ee310f5
commit
c2068b94d7
3 files changed
+75
-20
No files matched your search
@@ -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`
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user