diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts index c0adb15432..efa72d212e 100644 --- a/src/browser/CdpSafeMetadata.test.ts +++ b/src/browser/CdpSafeMetadata.test.ts @@ -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"); + }); +}); diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 7c72168604..7001be2d6a 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -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()