fix(browser): respect response metadata grammars

This commit is contained in:
morluto
2026-10-27 10:51:32 +08:00
parent e4cdf65e2c
commit 82d3ab7aa8
2 changed files with 110 additions and 12 deletions
+91 -1
View File
@@ -540,7 +540,7 @@ describe("safe CDP response metadata referrer policy", () => {
],
["origin, future-policy", "origin"],
["future-policy, no-referrer", "no-referrer"],
["unsafe-url, NO-REFERRER", "no-referrer"],
["unsafe-url, NO-REFERRER", "unsafe-url"],
["origin,, no-referrer", "no-referrer"],
// CDP joins repeated field lines with LF.
["no-referrer\nstrict-origin", "strict-origin"],
@@ -548,6 +548,18 @@ describe("safe CDP response metadata referrer policy", () => {
["future-policy", null],
['unsafe-url, "no-referrer"', null],
["no-referrer; report-to=x, origin", null],
["origin, no-referrer; report-to=x", null],
["origin, \u00a0no-referrer", null],
["\u00a0no-referrer, origin", null],
["NO-REFERRER", null],
["NO-REFERRER, origin", "origin"],
["origin, \u212a", null],
["origin, \u017f", null],
// Legacy meta names do not declare a policy through the response header.
["never, always, default, origin-when-crossorigin", null],
["no-referrer, never, always", "no-referrer"],
['"no-referrer"', null],
["no-referrer; report-to=x", null],
["", null],
[undefined, null],
...[
@@ -653,3 +665,81 @@ describe("Link context identity and display", () => {
},
);
});
describe("response metadata whitespace boundaries", () => {
it.each(["\u00a0", "\u2003", "\ufeff", "\v"])(
"keeps %j out of CSP delimiters and HTTP optional whitespace",
(whitespace) => {
const captured = safeResponseMetadata(
"whitespace",
`${origin}/page`,
{
mimeType: `${whitespace}Text/Plain${whitespace}`,
headers: {
"Content-Security-Policy":
`default-src${whitespace}'self'; ` +
`script-src 'self'${whitespace}https:; ` +
`${whitespace}img-src data:`,
"Content-Length": `${whitespace}100${whitespace}`,
"Cross-Origin-Resource-Policy": `${whitespace}same-origin${whitespace}`,
"X-Content-Type-Options": `${whitespace}nosniff${whitespace}`,
"Referrer-Policy": `${whitespace}no-referrer${whitespace}`,
"Permissions-Policy": `${whitespace}camera=(), geolocation=()`,
"Content-Encoding": `${whitespace}GZip${whitespace}`,
},
},
new Set([origin]),
);
expect(captured.response.csp).toEqual({
directives:
whitespace === "\v"
? [
{
name: "script-src",
sources: [{ kind: "other", value: null }],
},
]
: [],
nonce_count: 0,
hash_count: 0,
});
expect(captured.response.content_length).toBeNull();
expect(captured.response.policies).toMatchObject({
corp: null,
x_content_type_options: null,
referrer_policy: null,
permissions_policy_features: ["geolocation"],
});
expect(captured.response.mime_type).toBe(
`${whitespace}text/plain${whitespace}`,
);
expect(captured.response.content_encoding).toBe(
`${whitespace}gzip${whitespace}`,
);
},
);
it("accepts CSP ASCII whitespace and HTTP SP/HTAB at their owning boundaries", () => {
const captured = safeResponseMetadata(
"ascii-whitespace",
`${origin}/page`,
{
headers: {
"Content-Security-Policy":
"\tdefault-src\f'self'\r; script-src\t'data' ",
"Content-Length": "\t100 \t",
"Cross-Origin-Resource-Policy": "\tsame-origin \t",
"Referrer-Policy": "\tno-referrer \t",
},
},
new Set([origin]),
);
expect(captured.response.csp.directives).toEqual([
{ name: "default-src", sources: [{ kind: "keyword", value: "'self'" }] },
{ name: "script-src", sources: [{ kind: "keyword", value: "'data'" }] },
]);
expect(captured.response.content_length).toBe(100);
expect(captured.response.policies.corp).toBe("same-origin");
expect(captured.response.policies.referrer_policy).toBe("no-referrer");
});
});
+19 -11
View File
@@ -115,7 +115,11 @@ const parseCsp = (
let hashCount = 0;
const policies = (lines ?? []).flatMap((line) => line.split(","));
for (const rawDirective of policies.flatMap((policy) => policy.split(";"))) {
const [rawName, ...tokens] = rawDirective.trim().split(/\s+/u);
// CSP3 rejects non-ASCII directives and uses Infra ASCII whitespace.
if (/[^\x00-\x7f]/u.test(rawDirective)) continue;
const [rawName, ...tokens] = rawDirective
.replace(/^[\t\n\f\r ]+|[\t\n\f\r ]+$/gu, "")
.split(/[\t\n\f\r ]+/u);
const name = (rawName ?? "").toLowerCase();
if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
const sources: ResponseMetadata["csp"]["directives"][number]["sources"] =
@@ -292,19 +296,19 @@ const splitLinkHeader = (
};
// HTTP OWS/BWS never includes Unicode or HTML-only whitespace.
const trimLinkWhitespace = (value: string): string =>
const trimHttpWhitespace = (value: string): string =>
value.replace(/^[\t ]+|[\t ]+$/gu, "");
const linkParameters = (value: string): readonly LinkParameter[] => {
const parameters: LinkParameter[] = [];
for (const raw of splitLinkHeader(value, ";").slice(1)) {
const separator = raw.indexOf("=");
const name = trimLinkWhitespace(
const name = trimHttpWhitespace(
separator < 0 ? raw : raw.slice(0, separator),
).toLowerCase();
if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue;
const parameter =
separator < 0 ? "" : trimLinkWhitespace(raw.slice(separator + 1));
separator < 0 ? "" : trimHttpWhitespace(raw.slice(separator + 1));
parameters.push({ name, value: decodeLinkParameter(parameter) });
}
return parameters;
@@ -357,7 +361,9 @@ const permissionFeatures = (value: string | undefined): string[] =>
...new Set(
(value ?? "")
.split(",")
.map((entry) => entry.split("=", 1)[0]?.trim().toLowerCase() ?? "")
.map((entry) =>
trimHttpWhitespace(entry.split("=", 1)[0] ?? "").toLowerCase(),
)
.filter((feature) => /^[a-z][a-z0-9\x2d]*$/u.test(feature)),
),
].sort();
@@ -403,34 +409,36 @@ const referrerPolicies = new Set([
const referrerPolicy = (value: string | undefined): string | null => {
let policy: string | null = null;
for (const raw of (value ?? "").split(",")) {
const token = raw.trim().toLowerCase();
const token = trimHttpWhitespace(raw);
if (token === "") continue;
if (!/^[a-z\x2d]+$/u.test(token)) return null;
// Fetch header extraction first validates Referrer-Policy's ABNF;
// only syntactically valid extension tokens can be ignored as unknown.
if (!/^[A-Za-z\x2d]+$/u.test(token)) return null;
if (referrerPolicies.has(token)) policy = token;
}
return policy;
};
const policyToken = (value: string | undefined): string | null => {
const token = (value ?? "").trim().toLowerCase();
const token = trimHttpWhitespace(value ?? "").toLowerCase();
return /^[a-z][a-z0-9_.\x2d]*$/u.test(token) ? token : null;
};
// RFC 9110 section 8.6: Content-Length = 1*DIGIT. Other spellings declare no length.
const contentLength = (value: string | undefined): number | null => {
const token = (value ?? "").trim();
const token = trimHttpWhitespace(value ?? "");
if (!/^[0-9]+$/u.test(token)) return null;
const length = Number(token);
return Number.isSafeInteger(length) ? length : null;
};
const boundedHeader = (value: string | undefined): string | null =>
value === undefined ? null : value.trim().toLowerCase();
value === undefined ? null : trimHttpWhitespace(value).toLowerCase();
// RFC 9110 section 8.3.1: type/subtype are case-insensitive; parameters may not be.
const mediaTypeDeclaration = (value: string | undefined): string | null => {
if (value === undefined) return null;
const declaration = value.trim();
const declaration = trimHttpWhitespace(value);
const separator = declaration.indexOf(";");
return separator < 0
? declaration.toLowerCase()