From c360cfcd6d7901514b16dd6113eba2e0b0fdf422 Mon Sep 17 00:00:00 2001 From: morluto <76467478+morluto@users.noreply.github.com> Date: Sat, 17 Oct 2026 13:26:13 +0800 Subject: [PATCH] fix(browser): parse complete COOP and COEP structured items Validate token items and their parameters before projecting exact declared policy tokens. Keep malformed and non-token values unknown; retain separate grammar for other policy headers. Validation: real pinned Chrome public CLI baseline/candidate, 101 focused checks and 90 overlapping final checks, 834 upstream item-vector adaptations with two permitted refusals, check:fast and docs:check. Other browser/platform and policy-specific MCP delivery unverified; no performance measurement. Refs #4677. --- docs/browser-observation.md | 4 ++ src/browser/CdpSafeMetadata.test.ts | 65 +++++++++++++++++++ src/browser/CdpSafeMetadata.ts | 5 +- src/browser/StructuredFieldToken.ts | 47 ++++++++++++++ .../cdpBrowserProvider.network.test.ts | 13 +++- 5 files changed, 130 insertions(+), 4 deletions(-) create mode 100644 src/browser/StructuredFieldToken.ts diff --git a/docs/browser-observation.md b/docs/browser-observation.md index dc940f49cf..4c9f5fc388 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -288,6 +288,10 @@ REA establishes a main-frame origin boundary before enabling Runtime, Debugger, The Link projection parses repeated CDP header field lines independently and retains their declarations in order, including derived untrusted agent hints. Commas inside URI targets or quoted parameters remain part of those values. +COOP and COEP projections retain the exact declared token after validating the +complete Structured Field Item, including parameters such as `report-to`. +These are declarations, not an assessment of the document's effective policy; +absent, malformed or non-token items project as `null`. REA retains local observation data and removes only URL userinfo credentials, explicitly declared scenario secrets, and values from structured credential/storage fields: diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts index 7ff6e7142f..8e51376ac2 100644 --- a/src/browser/CdpSafeMetadata.test.ts +++ b/src/browser/CdpSafeMetadata.test.ts @@ -4,6 +4,71 @@ import { safeResponseMetadata } from "./CdpSafeMetadata.js"; const origin = "https://app.example.test"; +describe("structured COOP and COEP declarations", () => { + it.each([ + ["same-origin", "same-origin"], + ['same-origin; report-to="coop"', "same-origin"], + ['require-corp; report-to="coep"', "require-corp"], + ['credentialless; report-to="a; b, c"', "credentialless"], + [String.raw`same-origin; report-to="a\"b\\c"`, "same-origin"], + [ + "same-origin; enabled; n=-999999999999999; f=999999999999.999", + "same-origin", + ], + ["same-origin; flag=?0; bin=:YQ:; date=@123", "same-origin"], + ['same-origin; label=%"%c3%a9"', "same-origin"], + ["same-origin; value=one; value=two", "same-origin"], + [' same-origin; report-to="coop" ', "same-origin"], + ["FuturePolicy", "FuturePolicy"], + ['same-origin; report-to="unterminated', null], + [String.raw`same-origin; report-to="bad\q"`, null], + ["same-origin; Report-To=endpoint", null], + ["same-origin ; report-to=endpoint", null], + ["same-origin; report-to =endpoint", null], + ["same-origin; report-to= endpoint", null], + ["same-origin; report-to=", null], + ["same-origin;", null], + ["same-origin, require-corp", null], + ["same-origin\nrequire-corp", null], + ['"same-origin"; report-to="coop"', null], + ["same-origin; n=1000000000000000", null], + ["same-origin; f=1.1234", null], + ["same-origin; b=?2", null], + ["same-origin; bin=:a:", null], + ["same-origin; bin=:YQ=:", null], + ['same-origin; label=%"%ff"', null], + ['same-origin; label=%"%C3%A9"', null], + ['same-origin; label="é"', null], + ["same-origin\t", null], + ["", null], + [undefined, null], + ] as const)( + "parses the complete structured token item %j", + (value, expected) => { + const captured = safeResponseMetadata( + "policies", + `${origin}/page`, + { + headers: { + "Cross-Origin-Opener-Policy": value, + "Cross-Origin-Embedder-Policy": value, + "Cross-Origin-Resource-Policy": + 'same-origin; report-to="not-structured"', + "X-Content-Type-Options": 'nosniff; report-to="not-structured"', + }, + }, + new Set([origin]), + ); + expect(captured.response.policies).toMatchObject({ + coop: expected, + coep: expected, + corp: null, + x_content_type_options: null, + }); + }, + ); +}); + describe("safe CDP response metadata", () => { it("drops credential headers and preserves complete local link URLs", () => { const captured = safeResponseMetadata( diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index db8589fb71..858ec5b74b 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -1,6 +1,7 @@ import type { WebPageInspection } from "../domain/browserObservationSchemas.js"; import { sanitizeBrowserUrl } from "../domain/browserObservation.js"; import { cdpStringValue, type UnknownRecord } from "./CdpCaptureValues.js"; +import { structuredFieldToken } from "./StructuredFieldToken.js"; type ResponseMetadata = WebPageInspection["metadata"]["responses"][number]; type LinkMetadata = ResponseMetadata["links"][number]; @@ -44,8 +45,8 @@ export const safeResponseMetadata = ( csp, links, policies: { - coop: policyToken(headers.get("cross-origin-opener-policy")), - coep: policyToken(headers.get("cross-origin-embedder-policy")), + 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( diff --git a/src/browser/StructuredFieldToken.ts b/src/browser/StructuredFieldToken.ts new file mode 100644 index 0000000000..5af2f09680 --- /dev/null +++ b/src/browser/StructuredFieldToken.ts @@ -0,0 +1,47 @@ +// RFC 9651 sections 4.2.3–4.2.10. Consume complete items, without retaining +// parameter values that the response-declaration projection does not expose. +const token = /[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/\x2d]*/uy; +const key = /[a-z*][a-z0-9_.*\x2d]*/uy; +const bareItem = + /(?:[A-Za-z*][A-Za-z0-9!#$%&'*+.^_`|~:/\x2d]*|"(?:[\x20-\x21\x23-\x5b\x5d-\x7e]|\\["\\])*"|-?[0-9]{1,12}\.[0-9]{1,3}|-?[0-9]{1,15}|\?[01]|@-?[0-9]{1,15}|:(?[A-Za-z0-9+/]*={0,2}):|%"(?(?:[\x20-\x21\x23-\x24\x26-\x7e]|%[0-9a-f]{2})*)")/uy; + +/** Return the exact declared token only if the entire Structured Field Item is valid. */ +export const structuredFieldToken = ( + value: string | undefined, +): string | null => { + if (value === undefined) return null; + let cursor = 0; + while (value[cursor] === " ") cursor += 1; + token.lastIndex = cursor; + const declared = token.exec(value)?.[0]; + if (declared === undefined) return null; + cursor = token.lastIndex; + while (value[cursor] === ";") { + cursor += 1; + while (value[cursor] === " ") cursor += 1; + key.lastIndex = cursor; + if (key.exec(value) === null) return null; + cursor = key.lastIndex; + if (value[cursor] !== "=") continue; + bareItem.lastIndex = cursor + 1; + const parameter = bareItem.exec(value); + if (parameter === null) return null; + cursor = bareItem.lastIndex; + const bytes = parameter.groups?.bytes; + if (bytes !== undefined) { + const padded = bytes.endsWith("="); + if (padded ? bytes.length % 4 !== 0 : bytes.length % 4 === 1) return null; + } + const display = parameter.groups?.display; + if (display !== undefined) { + try { + // Percent syntax was checked above; this rejects invalid UTF-8 octets. + decodeURIComponent(display); + } catch { + return null; + } + } + } + while (value[cursor] === " ") cursor += 1; + return cursor === value.length ? declared : null; +}; diff --git a/tests/boundary/browser/cdpBrowserProvider.network.test.ts b/tests/boundary/browser/cdpBrowserProvider.network.test.ts index 7e3ab444e4..73a3e34fdf 100644 --- a/tests/boundary/browser/cdpBrowserProvider.network.test.ts +++ b/tests/boundary/browser/cdpBrowserProvider.network.test.ts @@ -5,7 +5,7 @@ import { inspectWebPageInputSchema } from "../../../src/domain/browserObservatio import { startFakeCdpBrowser } from "../../fixtures/fakeCdpBrowser.js"; import { trackBrowser } from "./cdpBrowserProvider.support.js"; -it("preserves repeated Document Link fields and their derived agent hints", async () => { +it("preserves Document Link fields, structured policies and derived agent hints", async () => { const browser = await startFakeCdpBrowser({ commandEvents(command, origin) { if (command.method !== "Network.enable") return undefined; @@ -32,7 +32,12 @@ it("preserves repeated Document Link fields and their derived agent hints", asyn url: `${origin}/frame`, status: 200, mimeType: "text/html", - headers: { Link: "; rel=next\n; rel=mcp" }, + headers: { + Link: "; rel=next\n; rel=mcp", + "Cross-Origin-Opener-Policy": 'same-origin; report-to="coop"', + "Cross-Origin-Embedder-Policy": + 'require-corp; report-to="coep"', + }, }, }, }, @@ -52,6 +57,10 @@ it("preserves repeated Document Link fields and their derived agent hints", asyn expect(result.value.metadata.responses).toContainEqual( expect.objectContaining({ request_id: "repeated-document", + policies: expect.objectContaining({ + coop: "same-origin", + coep: "require-corp", + }), links: [ expect.objectContaining({ href: `${browser.allowedOrigin}/next`,