mirror of
https://github.com/morluto/rea.git
synced 2026-10-26 17:35:11 +00:00
refactor(browser): resolve Link metadata identity once
This commit is contained in:
1 parent
38ebe11a52
commit
5a332d856a
2 files changed
+71
-29
No files matched your search
@@ -536,3 +536,59 @@ describe("safe CDP response metadata referrer policy", () => {
|
||||
expect(captured.response.content_length).toBe(expected);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Link context identity and display", () => {
|
||||
it.each([
|
||||
[
|
||||
"https://user:secret@app.example.test/page",
|
||||
"/page",
|
||||
true,
|
||||
`${origin}/page`,
|
||||
"approved",
|
||||
],
|
||||
[
|
||||
"https://user:secret@app.example.test/page",
|
||||
"https://user:secret@app.example.test/page",
|
||||
true,
|
||||
`${origin}/page`,
|
||||
"approved",
|
||||
],
|
||||
[
|
||||
`${origin}/page`,
|
||||
"https://other:secret@app.example.test/page",
|
||||
false,
|
||||
`${origin}/page`,
|
||||
"approved",
|
||||
],
|
||||
[`${origin}/page`, "http://[", false, "http://[", "unsupported"],
|
||||
["http://[", "/page", false, "/page", "unsupported"],
|
||||
[
|
||||
`${origin}/page`,
|
||||
"https://elsewhere.test/page",
|
||||
false,
|
||||
"https://elsewhere.test/page",
|
||||
"outside_policy",
|
||||
],
|
||||
] as const)(
|
||||
"preserves resolved context identity for response %s and anchor %s",
|
||||
(responseUrl, anchor, matches, displayUrl, scope) => {
|
||||
const captured = safeResponseMetadata(
|
||||
"context",
|
||||
`${origin}/page`,
|
||||
{
|
||||
url: responseUrl,
|
||||
headers: { Link: `</agent>; rel=mcp; anchor="${anchor}"` },
|
||||
},
|
||||
new Set([origin]),
|
||||
);
|
||||
expect(captured.response.links[0]).toMatchObject({
|
||||
context_url: displayUrl,
|
||||
context_scope: scope,
|
||||
});
|
||||
expect(
|
||||
captured.agentHints.filter(({ mechanism }) => mechanism === "link_rel"),
|
||||
).toHaveLength(matches ? 1 : 0);
|
||||
expect(JSON.stringify(captured)).not.toContain("secret");
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -214,8 +214,8 @@ const parseLinks = (
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): ParsedLink[] => {
|
||||
const links: ParsedLink[] = [];
|
||||
// Compare resolved identities before safeDestination removes userinfo.
|
||||
const responseIdentity = resolvedUrl(responseUrl, responseUrl);
|
||||
// Keep resolved identity separate from the credential-redacted display URL.
|
||||
const response = safeDestination(responseUrl, responseUrl, allowedOrigins);
|
||||
// Quoted delimiters belong to one field line.
|
||||
for (const line of lines ?? []) {
|
||||
for (const entry of splitLinkHeader(line)) {
|
||||
@@ -223,18 +223,15 @@ const parseLinks = (
|
||||
if (match === null) continue;
|
||||
const parameters = linkParameters(match[2] ?? "");
|
||||
const anchor = firstLinkParameter(parameters, "anchor");
|
||||
const contextReference = anchor?.value ?? responseUrl;
|
||||
const destination = safeDestination(
|
||||
match[1] ?? "",
|
||||
responseUrl,
|
||||
allowedOrigins,
|
||||
);
|
||||
const context = safeDestination(
|
||||
contextReference,
|
||||
responseUrl,
|
||||
allowedOrigins,
|
||||
);
|
||||
const contextIdentity = resolvedUrl(contextReference, responseUrl);
|
||||
const context =
|
||||
anchor === undefined
|
||||
? response
|
||||
: safeDestination(anchor.value, responseUrl, allowedOrigins);
|
||||
// RFC 8288 assigns first-occurrence semantics to rel, anchor, and type.
|
||||
const rel = firstLinkParameter(parameters, "rel")?.value ?? "";
|
||||
links.push({
|
||||
@@ -253,8 +250,8 @@ const parseLinks = (
|
||||
),
|
||||
},
|
||||
contextMatchesResponse:
|
||||
responseIdentity !== undefined &&
|
||||
contextIdentity === responseIdentity,
|
||||
response.identity !== undefined &&
|
||||
context.identity === response.identity,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -321,39 +318,28 @@ const lastLinkParameter = (
|
||||
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,
|
||||
allowedOrigins: ReadonlySet<string>,
|
||||
): {
|
||||
readonly identity: string | undefined;
|
||||
readonly url: string | null;
|
||||
readonly scope: LinkMetadata["destination_scope"];
|
||||
} => {
|
||||
try {
|
||||
const parsed = new URL(value, baseUrl);
|
||||
const sanitized = sanitizeBrowserUrl(parsed.href).url;
|
||||
const identity = parsed.href;
|
||||
const sanitized = sanitizeBrowserUrl(identity).url;
|
||||
if (parsed.protocol !== "http:" && parsed.protocol !== "https:")
|
||||
return { url: sanitized, scope: "unsupported" };
|
||||
return { identity, url: sanitized, scope: "unsupported" };
|
||||
if (!allowedOrigins.has(parsed.origin))
|
||||
return { url: sanitized, scope: "outside_policy" };
|
||||
return { url: sanitized, scope: "approved" };
|
||||
return { identity, url: sanitized, scope: "outside_policy" };
|
||||
return { identity, url: sanitized, scope: "approved" };
|
||||
} catch (cause: unknown) {
|
||||
// Unparseable link values keep their raw text for the analyst.
|
||||
void cause;
|
||||
return { url: value, scope: "unsupported" };
|
||||
return { identity: undefined, url: value, scope: "unsupported" };
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
Reference in new issue
Block a user