From f63719a21ce73deccf3e5a3c12b8fb5f9a87b0e3 Mon Sep 17 00:00:00 2001 From: claude_code Date: Sun, 21 Jun 2026 01:20:11 +0300 Subject: [PATCH] fix(share): neutralize own-origin absolute links in public-share AI chat isExternalHttpUrl treated any http(s):// URL as external, so an absolute link back to the app's own host (e.g. https://self/p/{uuid}, /settings/members) emitted by the assistant stayed clickable on the anonymous share, leaking internal UUIDs/structure and pointing at auth-gated routes. Classify a link as external only when its host differs from window.location.host; unparseable URLs are treated as internal (fail-closed). Tests cover own-origin absolute (flag on -> inert), external host (kept with safe rel/target), dangerous schemes, and no behavior change for the internal chat (flag off). Co-Authored-By: Claude Opus 4.8 --- .../features/ai-chat/utils/markdown.test.ts | 56 +++++++++++++++++-- .../src/features/ai-chat/utils/markdown.ts | 29 +++++++--- 2 files changed, 74 insertions(+), 11 deletions(-) diff --git a/apps/client/src/features/ai-chat/utils/markdown.test.ts b/apps/client/src/features/ai-chat/utils/markdown.test.ts index 993dcf4d..ae993bff 100644 --- a/apps/client/src/features/ai-chat/utils/markdown.test.ts +++ b/apps/client/src/features/ai-chat/utils/markdown.test.ts @@ -8,8 +8,10 @@ import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; * ``, leaking internal UUIDs/structure and linking to auth-gated * routes. With the flag ON those links are made inert (href removed) while the * visible text and the rest of the markdown formatting are preserved; genuinely - * EXTERNAL http(s) links are kept with a safe rel/target. With the flag OFF - * (internal default) links keep their href so the authenticated chat is unchanged. + * EXTERNAL http(s) links (a DIFFERENT host than the app's own origin) are kept + * with a safe rel/target, while absolute links back to our OWN origin are + * neutralized too. With the flag OFF (internal default) links keep their href so + * the authenticated chat is unchanged. */ /** Parse the rendered HTML and return the first element (or null). */ @@ -41,16 +43,54 @@ describe("renderChatMarkdown — internal link neutralization", () => { }); it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => { - const html = renderChatMarkdown("[y](https://example.com)", { + const html = renderChatMarkdown("[y](https://example.com/x)", { neutralizeInternalLinks: true, }); const a = firstAnchor(html); expect(a).not.toBeNull(); - expect(a!.getAttribute("href")).toBe("https://example.com"); + expect(a!.getAttribute("href")).toBe("https://example.com/x"); expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow"); expect(a!.getAttribute("target")).toBe("_blank"); }); + it("neutralizes an absolute link to our OWN origin when the flag is ON", () => { + // An LLM can emit an absolute URL back at our own host (e.g. + // `http://self/p/{uuid}`); it is internal and must be made inert just like a + // relative `/p/...` link, not kept clickable as if it were external. + const ownOrigin = `${window.location.origin}/p/abc`; + const html = renderChatMarkdown(`[x](${ownOrigin})`, { + neutralizeInternalLinks: true, + }); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.hasAttribute("href")).toBe(false); + expect(a!.hasAttribute("target")).toBe(false); + expect(a!.textContent).toBe("x"); + }); + + it("neutralizes dangerous/unsafe schemes when the flag is ON", () => { + // javascript:, data:, and protocol-relative `//...` must never stay + // clickable on the anonymous share — they are not genuinely external + // http(s) links to a different host, so the href is dropped (or sanitized + // away entirely by DOMPurify). + for (const markdown of [ + "[a](javascript:alert(1))", + "[b](data:text/html,)", + "[c](//evil.com/x)", + ]) { + const html = renderChatMarkdown(markdown, { + neutralizeInternalLinks: true, + }); + const a = firstAnchor(html); + // Either the anchor was stripped of its href, or DOMPurify removed the + // unsafe href outright; in both cases nothing dangerous remains. + if (a !== null) { + expect(a.hasAttribute("href")).toBe(false); + expect(a.hasAttribute("target")).toBe(false); + } + } + }); + it("keeps internal links clickable when the flag is OFF (internal default)", () => { const html = renderChatMarkdown("[x](/p/abc)"); const a = firstAnchor(html); @@ -58,6 +98,14 @@ describe("renderChatMarkdown — internal link neutralization", () => { expect(a!.getAttribute("href")).toBe("/p/abc"); }); + it("keeps an absolute own-origin link clickable when the flag is OFF (internal default)", () => { + const ownOrigin = `${window.location.origin}/p/abc`; + const html = renderChatMarkdown(`[x](${ownOrigin})`); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.getAttribute("href")).toBe(ownOrigin); + }); + it("does not leave a global DOMPurify hook that affects a later internal render", () => { // A neutralizing render first, then an internal render: the internal link // must survive (the hook is removed after the share render). diff --git a/apps/client/src/features/ai-chat/utils/markdown.ts b/apps/client/src/features/ai-chat/utils/markdown.ts index d7ba4e74..c48e5002 100644 --- a/apps/client/src/features/ai-chat/utils/markdown.ts +++ b/apps/client/src/features/ai-chat/utils/markdown.ts @@ -8,21 +8,36 @@ export interface RenderChatMarkdownOptions { * relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`) * that would otherwise become clickable ``, leaking internal * UUIDs/structure and pointing at auth-gated routes. An anonymous reader can - * still follow genuinely EXTERNAL `http(s)` links, so those are kept (with a - * safe `rel`/`target`). Defaults to false — the internal chat keeps internal - * links clickable for authenticated users. + * still follow genuinely EXTERNAL `http(s)` links (a DIFFERENT host than the + * app's own origin), so those are kept (with a safe `rel`/`target`); absolute + * links back to our OWN origin (e.g. `https://self/p/{uuid}`) are internal and + * neutralized too. Defaults to false — the internal chat keeps internal links + * clickable for authenticated users. */ neutralizeInternalLinks?: boolean; } /** * Whether `href` points at an EXTERNAL absolute URL we are happy for an - * anonymous reader to follow. Only absolute `http(s)://` URLs qualify; - * everything else (relative `/...`, bare fragments `#...`, protocol-relative - * `//...`, other schemes) is treated as internal/unsafe and neutralized. + * anonymous reader to follow. A link qualifies only if it is absolute + * `http(s)://` AND its host differs from the app's own origin + * (`window.location.host`): absolute links back to our OWN host (e.g. + * `https://self/p/{uuid}`) are internal and must be neutralized, exactly like + * relative `/p/...` links. Everything else (relative `/...`, bare fragments + * `#...`, protocol-relative `//...`, other schemes, or anything that does not + * parse) is treated as internal/unsafe and neutralized — fail closed. */ function isExternalHttpUrl(href: string): boolean { - return /^https?:\/\//i.test(href.trim()); + const value = href.trim(); + if (!/^https?:\/\//i.test(value)) return false; + try { + // External only if it points at a DIFFERENT host than the app's own origin. + // Absolute links back to our own host (e.g. https://self/p/{uuid}) are + // internal and must be neutralized, same as relative `/p/...` links. + return new URL(value).host !== window.location.host; + } catch { + return false; // unparseable -> treat as internal/unsafe, neutralize + } } /**