mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
fix(browser): preserve Link context semantics
Fixes #6504 Fixes #6505 Fixes #6506
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user