mirror of
https://github.com/morluto/rea
synced 2026-10-28 15:07:26 -04:00
fix(browser): respect response metadata grammars
This commit is contained in:
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user