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
+ }
}
/**