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 <noreply@anthropic.com>
This commit is contained in:
@@ -8,8 +8,10 @@ import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts";
|
||||
* `<a href="/p/...">`, 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 <a> 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,<script>alert(1)</script>)",
|
||||
"[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).
|
||||
|
||||
@@ -8,21 +8,36 @@ export interface RenderChatMarkdownOptions {
|
||||
* relative app links (e.g. `[page](/p/{uuid})`, `[settings](/settings/members)`)
|
||||
* that would otherwise become clickable `<a href="/p/...">`, 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
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user