diff --git a/docs/browser-observation.md b/docs/browser-observation.md index d2a5124507..19b5ee0cdc 100644 --- a/docs/browser-observation.md +++ b/docs/browser-observation.md @@ -327,8 +327,16 @@ Cancellation and clock-request failures retain their own outcomes. ## Data minimization 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. +retains their declarations in order. 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` +agent hint is emitted only when the resolved context matches the response URL +before userinfo credentials are removed. Following [RFC 8288](https://www.rfc-editor.org/rfc/rfc8288.html), +the `rel`, `anchor`, and `type` parameters use their first occurrence, and +quoted-pair escapes are decoded as specified by [RFC 9110 ยง5.6.4](https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.4). +The context fields are optional when reading older saved captures. 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; diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 858ec5b74b..4a905afb07 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -6,6 +6,11 @@ import { structuredFieldToken } from "./StructuredFieldToken.js"; type ResponseMetadata = WebPageInspection["metadata"]["responses"][number]; type LinkMetadata = ResponseMetadata["links"][number]; type AgentHint = WebPageInspection["metadata"]["agent_hints"][number]; +type LinkParameter = { readonly name: string; readonly value: string }; +type ParsedLink = { + readonly metadata: LinkMetadata; + readonly contextMatchesResponse: boolean; +}; /** Normalize an allowlisted subset of response metadata and discard raw headers. */ export const safeResponseMetadata = ( @@ -18,16 +23,22 @@ export const safeResponseMetadata = ( readonly agentHints: AgentHint[]; } => { const headers = normalizedHeaders(response.headers); - const links = parseLinks(headers.get("link"), url, allowedOrigins); + const responseUrl = cdpStringValue(response.url) ?? url; + const parsedLinks = parseLinks( + headers.get("link"), + responseUrl, + allowedOrigins, + ); + const links = parsedLinks.map(({ metadata }) => metadata); const csp = parseCsp( headers.get("content-security-policy"), url, allowedOrigins, ); const agentHints = [ - ...links.flatMap((link) => - link.rel.some(isAgentRel) - ? [agentHint("link_rel", link.rel.join(" "), link.href)] + ...parsedLinks.flatMap(({ metadata, contextMatchesResponse }) => + contextMatchesResponse && metadata.rel.some(isAgentRel) + ? [agentHint("link_rel", metadata.rel.join(" "), metadata.href)] : [], ), ...agentHeaderNames.flatMap((name) => @@ -182,31 +193,49 @@ const schemeOf = (baseUrl: string): string | undefined => { const parseLinks = ( value: string | undefined, - baseUrl: string, + responseUrl: string, allowedOrigins: ReadonlySet, -): LinkMetadata[] => { - const links: LinkMetadata[] = []; +): 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)) { for (const entry of splitLinkHeader(line)) { const match = /^\s*<([^>]*)>(.*)$/u.exec(entry); if (match === null) continue; const parameters = linkParameters(match[2] ?? ""); + const anchor = firstLinkParameter(parameters, "anchor"); + const contextReference = anchor?.value ?? responseUrl; const destination = safeDestination( match[1] ?? "", - baseUrl, + responseUrl, allowedOrigins, ); + const context = safeDestination( + contextReference, + responseUrl, + allowedOrigins, + ); + const contextIdentity = resolvedUrl(contextReference, responseUrl); + // RFC 8288 assigns first-occurrence semantics to rel, anchor, and type. + const rel = firstLinkParameter(parameters, "rel")?.value ?? ""; links.push({ - href: destination.url, - destination_scope: destination.scope, - rel: (parameters.get("rel") ?? "") - .toLowerCase() - .split(/\s+/u) - .filter(Boolean), - as: boundedHeader(parameters.get("as")), - type: boundedHeader(parameters.get("type")), - crossorigin: boundedHeader(parameters.get("crossorigin")), + metadata: { + href: destination.url, + destination_scope: destination.scope, + context_url: context.url, + context_scope: context.scope, + rel: rel.toLowerCase().split(/\s+/u).filter(Boolean), + as: boundedHeader(lastLinkParameter(parameters, "as")?.value), + type: boundedHeader(firstLinkParameter(parameters, "type")?.value), + crossorigin: boundedHeader( + lastLinkParameter(parameters, "crossorigin")?.value, + ), + }, + contextMatchesResponse: + responseIdentity !== undefined && + contextIdentity === responseIdentity, }); } } @@ -242,8 +271,8 @@ const splitLinkHeader = ( return entries; }; -const linkParameters = (value: string): ReadonlyMap => { - const parameters = new Map(); +const linkParameters = (value: string): readonly LinkParameter[] => { + const parameters: LinkParameter[] = []; for (const raw of splitLinkHeader(value, ";").slice(1)) { const separator = raw.indexOf("="); const name = (separator < 0 ? raw : raw.slice(0, separator)) @@ -251,11 +280,41 @@ const linkParameters = (value: string): ReadonlyMap => { .toLowerCase(); if (!/^[a-z][a-z0-9\x2d]*$/u.test(name)) continue; const parameter = separator < 0 ? "" : raw.slice(separator + 1).trim(); - parameters.set(name, unquote(parameter)); + parameters.push({ name, value: decodeLinkParameter(parameter) }); } return parameters; }; +const firstLinkParameter = ( + parameters: readonly LinkParameter[], + name: string, +): LinkParameter | undefined => + parameters.find((parameter) => parameter.name === name); + +const lastLinkParameter = ( + parameters: readonly LinkParameter[], + name: string, +): LinkParameter | undefined => { + for (let index = parameters.length - 1; index >= 0; index -= 1) { + const parameter = parameters[index]; + if (parameter?.name === name) return parameter; + } + return undefined; +}; + +const resolvedUrl = ( + reference: string, + baseUrl: string, +): string | undefined => { + try { + return new URL(reference, baseUrl).href; + } catch (cause: unknown) { + // An unparseable Link URI has no usable identity for context comparison. + void cause; + return undefined; + } +}; + const safeDestination = ( value: string, baseUrl: string, @@ -354,8 +413,23 @@ const contentLength = (value: string | undefined): number | null => { const boundedHeader = (value: string | undefined): string | null => value === undefined ? null : value.trim().toLowerCase(); -const unquote = (value: string): string => - value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value; +const decodeLinkParameter = (value: string): string => { + if (!value.startsWith('"')) return value; + let decoded = ""; + for (let index = 1; index < value.length; index += 1) { + const character = value[index]; + if (character === "\\") { + index += 1; + if (index >= value.length) return decoded; + decoded += value[index]; + } else if (character === '"') { + return decoded; + } else { + decoded += character; + } + } + return decoded; +}; const agentHeaderNames = ["x-model-context", "x-webmcp", "mcp-server"] as const; const wellKnownAgentPaths: ReadonlySet = new Set([ diff --git a/src/domain/browserObservationSchemas.ts b/src/domain/browserObservationSchemas.ts index 567001a9b0..daae3bdcc8 100644 --- a/src/domain/browserObservationSchemas.ts +++ b/src/domain/browserObservationSchemas.ts @@ -199,6 +199,10 @@ const browserCspSourceSchema = z.object({ const browserLinkMetadataSchema = z.object({ href: z.string().nullable(), destination_scope: z.enum(["approved", "outside_policy", "unsupported"]), + context_url: z.string().nullable().optional(), + context_scope: z + .enum(["approved", "outside_policy", "unsupported"]) + .optional(), rel: z.array(z.string()), as: z.string().nullable(), type: z.string().nullable(), diff --git a/tests/boundary/mcp/browserMcp.test.ts b/tests/boundary/mcp/browserMcp.test.ts index 7bdd13a0a6..f10446165d 100644 --- a/tests/boundary/mcp/browserMcp.test.ts +++ b/tests/boundary/mcp/browserMcp.test.ts @@ -3,6 +3,7 @@ import { crc32, deflateSync } from "node:zlib"; import { parseEvidence } from "../../../src/domain/evidence.js"; import { ok as resultOk } from "../../../src/domain/result.js"; import { Client, InMemoryTransport } from "@modelcontextprotocol/client"; +import { Ajv2020 } from "ajv/dist/2020.js"; import { afterEach, expect, it } from "vitest"; import { createTestBinarySession } from "../../fixtures/binarySession.js"; @@ -27,6 +28,153 @@ const resources: Array<{ close(): Promise }> = []; const browsers: FakeCdpBrowser[] = []; const INTEGRATION_TEST_TIMEOUT_MS = 20_000; +it("preserves Link parameter order and context through the advertised MCP contract", async () => { + const links = [ + '; rel="mcp"; rel="preload"; type="application/mcp"; type="application/preload"', + '; rel="preload"; rel="mcp"', + String.raw`; rel="m\cp"`, + '; rel="mcp"; anchor="/other-document"; anchor="/page"', + '; rel="mcp"', + '; rel="mcp"; anchor="/page"', + ]; + const browser = await startFakeCdpBrowser({ + eventsBeforeReply: ["Network.enable"], + commandEvents(command, origin) { + if (command.method !== "Network.enable") return undefined; + const session = + command.sessionId === undefined ? {} : { sessionId: command.sessionId }; + return [ + { + ...session, + method: "Network.requestWillBeSent", + params: { + requestId: "link-metadata", + type: "Document", + request: { url: `${origin}/page`, method: "GET" }, + initiator: { type: "parser" }, + }, + }, + { + ...session, + method: "Network.responseReceived", + params: { + requestId: "link-metadata", + type: "Document", + response: { + url: `${origin}/page`, + status: 200, + mimeType: "text/html", + headers: { + Link: [ + ...links, + `; rel="mcp"; anchor="${new URL(origin).protocol}//other-user:other-pass@${new URL(origin).host}/page"`, + ].join(", "), + }, + }, + }, + }, + ]; + }, + }); + browsers.push(browser); + const connected = await connectBrowser(); + const advertised = (await connected.client.listTools()).tools.find( + ({ name }) => name === "inspect_web_page", + ); + if (advertised?.outputSchema === undefined) + throw new Error("inspect_web_page must advertise its output schema"); + + const inspected = await connected.client.callTool({ + name: "inspect_web_page", + arguments: { + cdp_endpoint: browser.endpoint, + target_id: "allowed-page", + observation_ms: 0, + }, + }); + expect(inspected.isError).not.toBe(true); + const ajv = new Ajv2020({ strict: false, validateFormats: false }); + const outputSchema: Record = advertised.outputSchema; + expect( + ajv.validate(outputSchema, inspected.structuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); + + const normalized = webPageInspectionSchema.parse( + normalizedResultOf(inspected.structuredContent), + ); + expect(normalized.metadata.responses).toContainEqual( + expect.objectContaining({ + request_id: "link-metadata", + links: [ + expect.objectContaining({ + href: `${browser.allowedOrigin}/duplicate-first`, + rel: ["mcp"], + type: "application/mcp", + context_url: `${browser.allowedOrigin}/page`, + context_scope: "approved", + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/duplicate-last`, + rel: ["preload"], + context_url: `${browser.allowedOrigin}/page`, + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/escaped-rel`, + rel: ["mcp"], + context_url: `${browser.allowedOrigin}/page`, + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/other-context`, + rel: ["mcp"], + context_url: `${browser.allowedOrigin}/other-document`, + context_scope: "approved", + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/default-context`, + rel: ["mcp"], + context_url: `${browser.allowedOrigin}/page`, + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/same-context`, + rel: ["mcp"], + context_url: `${browser.allowedOrigin}/page`, + }), + expect.objectContaining({ + href: `${browser.allowedOrigin}/credential-context`, + rel: ["mcp"], + context_url: `${browser.allowedOrigin}/page`, + }), + ], + }), + ); + const linkHints = normalized.metadata.agent_hints.filter( + ({ mechanism }) => mechanism === "link_rel", + ); + expect(linkHints.map(({ url }) => url)).toEqual([ + `${browser.allowedOrigin}/duplicate-first`, + `${browser.allowedOrigin}/escaped-rel`, + `${browser.allowedOrigin}/default-context`, + `${browser.allowedOrigin}/same-context`, + ]); + + const legacy = structuredClone(normalized); + for (const response of legacy.metadata.responses) + for (const projectedLink of response.links) { + delete projectedLink.context_url; + delete projectedLink.context_scope; + } + expect(webPageInspectionSchema.safeParse(legacy).success).toBe(true); + const legacyStructuredContent = { + ...(inspected.structuredContent as Record), + normalized_result: legacy, + }; + expect( + ajv.validate(outputSchema, legacyStructuredContent), + JSON.stringify(ajv.errors), + ).toBe(true); +}); + it( "preserves full document URL identity through CLI and MCP", async () => {