diff --git a/src/browser/CdpSafeMetadata.test.ts b/src/browser/CdpSafeMetadata.test.ts index a9478c3aa9..5f7bb8c0c6 100644 --- a/src/browser/CdpSafeMetadata.test.ts +++ b/src/browser/CdpSafeMetadata.test.ts @@ -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: `; 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"); + }, + ); +}); diff --git a/src/browser/CdpSafeMetadata.ts b/src/browser/CdpSafeMetadata.ts index 1adcd87edd..be1a743f86 100644 --- a/src/browser/CdpSafeMetadata.ts +++ b/src/browser/CdpSafeMetadata.ts @@ -214,8 +214,8 @@ const parseLinks = ( allowedOrigins: ReadonlySet, ): 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, ): { + 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" }; } };