fix(browser): preserve Link context semantics

Fixes #6504

Fixes #6505

Fixes #6506
This commit is contained in:
morluto
2026-10-23 17:40:13 +08:00
parent a4bf6f83a6
commit d53584a10a
4 changed files with 258 additions and 24 deletions
+10 -2
View File
@@ -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;
+96 -22
View File
@@ -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<string>,
): 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<string, string> => {
const parameters = new Map<string, string>();
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<string, string> => {
.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<string> = new Set([
+4
View File
@@ -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(),
+148
View File
@@ -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<unknown> }> = [];
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 = [
'</duplicate-first>; rel="mcp"; rel="preload"; type="application/mcp"; type="application/preload"',
'</duplicate-last>; rel="preload"; rel="mcp"',
String.raw`</escaped-rel>; rel="m\cp"`,
'</other-context>; rel="mcp"; anchor="/other-document"; anchor="/page"',
'</default-context>; rel="mcp"',
'</same-context>; 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,
`</credential-context>; 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<string, unknown> = 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<string, unknown>),
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 () => {