From e0aac5aa04f8130f7bfff0762b889468da647d67 Mon Sep 17 00:00:00 2001 From: claude code agent 227 Date: Sun, 21 Jun 2026 00:04:18 +0300 Subject: [PATCH 1/2] feat(share): public-share AI chat reuses the internal chat's presentation (#41) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The public-share widget was a separate minimal impl: plain-text answer, static 'Thinking…', no markdown, no tool-cards. Now it renders through the internal chat's debugged presentational layer (MessageList/MessageItem/TypingIndicator/ ToolCallCard), so a share gets the same incremental streaming, animated typing indicator, markdown, and tool-call cards. The share keeps its anonymous transport (useChat + DefaultChatTransport '/api/shares/ai/stream', credentials:'omit'). The shared components were already prop-driven (UIMessage[] + isStreaming) with no transport/auth coupling; made the new props additive optionals (emptyState, showCitations, neutralizeInternalLinks) all defaulting to current behavior, so the internal chat is unchanged. Security (review-caught): rendering assistant markdown on the ANONYMOUS share made internal links (/p/{id}, /settings/...) clickable, which the old plain-text render didn't. renderChatMarkdown gains neutralizeInternalLinks (true only on the share): a one-shot DOMPurify afterSanitizeAttributes hook (added/removed by reference around a single sanitize) strips href from internal/relative/non-http(s) links (rendered inert) and keeps external http(s) links with rel=noopener noreferrer nofollow target=_blank. Tests cover both the link neutralization and the absence of any global-hook leak into internal renders. Co-Authored-By: Claude Opus 4.8 --- .../ai-chat/components/message-item.tsx | 30 +++++- .../ai-chat/components/message-list.tsx | 47 ++++++-- .../components/show-typing-indicator.test.ts | 55 ++++++++++ .../ai-chat/components/tool-call-card.tsx | 15 ++- .../features/ai-chat/utils/markdown.test.ts | 69 ++++++++++++ .../src/features/ai-chat/utils/markdown.ts | 71 +++++++++++- .../share/components/share-ai-widget.tsx | 101 ++++++++---------- 7 files changed, 315 insertions(+), 73 deletions(-) create mode 100644 apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts create mode 100644 apps/client/src/features/ai-chat/utils/markdown.test.ts diff --git a/apps/client/src/features/ai-chat/components/message-item.tsx b/apps/client/src/features/ai-chat/components/message-item.tsx index 4ba1d934..e8709d5c 100644 --- a/apps/client/src/features/ai-chat/components/message-item.tsx +++ b/apps/client/src/features/ai-chat/components/message-item.tsx @@ -10,6 +10,18 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageItemProps { message: UIMessage; + /** + * Forwarded to ToolCallCard: whether tool cards render page citation links. + * Defaults to true (internal chat). The public share passes false. + */ + showCitations?: boolean; + /** + * Neutralize internal/relative markdown links in the rendered answer (drop + * their href so they become inert text). Defaults to false (internal chat, + * links stay clickable). The anonymous public share passes true so internal + * UUIDs/routes in the assistant's markdown don't leak as clickable links. + */ + neutralizeInternalLinks?: boolean; } /** @@ -24,7 +36,11 @@ interface MessageItemProps { * `message` prop identity (and its `parts`) changes each tick. Re-rendering the * text parts on each delta is what makes the answer stream in progressively. */ -export default function MessageItem({ message }: MessageItemProps) { +export default function MessageItem({ + message, + showCitations = true, + neutralizeInternalLinks = false, +}: MessageItemProps) { const { t } = useTranslation(); const isUser = message.role === "user"; @@ -53,7 +69,9 @@ export default function MessageItem({ message }: MessageItemProps) { // starts with an empty text part before the first token arrives); the // typing indicator covers that gap until real content streams in. if (!part.text.trim()) return null; - const html = renderChatMarkdown(part.text); + const html = renderChatMarkdown(part.text, { + neutralizeInternalLinks, + }); if (html) { return (
; + return ( + + ); } return null; diff --git a/apps/client/src/features/ai-chat/components/message-list.tsx b/apps/client/src/features/ai-chat/components/message-list.tsx index ed0fb73d..3d9c5024 100644 --- a/apps/client/src/features/ai-chat/components/message-list.tsx +++ b/apps/client/src/features/ai-chat/components/message-list.tsx @@ -1,4 +1,4 @@ -import { useEffect, useRef } from "react"; +import { ReactNode, useEffect, useRef } from "react"; import { Center, ScrollArea, Stack, Text } from "@mantine/core"; import { useTranslation } from "react-i18next"; import type { UIMessage } from "@ai-sdk/react"; @@ -10,6 +10,26 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface MessageListProps { messages: UIMessage[]; isStreaming: boolean; + /** + * Content shown when the transcript is empty and no turn is in flight. + * Defaults to the internal chat's prompt. The public share passes its own + * documentation-focused copy. This is purely the empty-state text; the + * streaming/typing/markdown/tool-card paths below are shared verbatim. + */ + emptyState?: ReactNode; + /** + * Forwarded to MessageItem -> ToolCallCard: whether tool cards render page + * citation links. Defaults to true (internal chat). The public share passes + * false because an anonymous reader cannot open the linked internal pages. + */ + showCitations?: boolean; + /** + * Forwarded to MessageItem: neutralize internal/relative markdown links in + * the rendered answers (drop their href so they render as inert text). + * Defaults to false (internal chat). The public share passes true so internal + * UUIDs/routes don't leak as clickable links to anonymous readers. + */ + neutralizeInternalLinks?: boolean; } // Distance (px) from the bottom within which the viewport still counts as @@ -24,7 +44,7 @@ const BOTTOM_THRESHOLD = 40; * - the last (assistant) message has no non-empty text and no tool part. * Once any text/tool part arrives, MessageItem renders it and this hides. */ -function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean { +export function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boolean { if (!isStreaming) return false; const last = messages[messages.length - 1]; if (!last) return true; // submitted with nothing rendered yet. @@ -41,7 +61,13 @@ function showTypingIndicator(messages: UIMessage[], isStreaming: boolean): boole * but only while the user is pinned to the bottom — if they scrolled up to read * earlier messages, streamed deltas no longer yank them back down. */ -export default function MessageList({ messages, isStreaming }: MessageListProps) { +export default function MessageList({ + messages, + isStreaming, + emptyState, + showCitations = true, + neutralizeInternalLinks = false, +}: MessageListProps) { const { t } = useTranslation(); const viewportRef = useRef(null); // Whether the viewport is currently pinned to the bottom. Starts true so the @@ -104,9 +130,11 @@ export default function MessageList({ messages, isStreaming }: MessageListProps) if (messages.length === 0 && !typing) { return (
- - {t("Ask the AI agent anything about your workspace.")} - + {emptyState ?? ( + + {t("Ask the AI agent anything about your workspace.")} + + )}
); } @@ -115,7 +143,12 @@ export default function MessageList({ messages, isStreaming }: MessageListProps) {messages.map((message) => ( - + ))} {typing && } diff --git a/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts new file mode 100644 index 00000000..5cc023d9 --- /dev/null +++ b/apps/client/src/features/ai-chat/components/show-typing-indicator.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import type { UIMessage } from "@ai-sdk/react"; +import { showTypingIndicator } from "@/features/ai-chat/components/message-list.tsx"; + +/** + * Pure-helper tests for the typing-indicator bridging logic that the internal + * chat and the public share widget now share. This is the behavior that decides + * whether the animated "AI agent is typing…" placeholder shows in the gap + * between sending and the first streamed token. + */ +const msg = ( + role: "user" | "assistant", + parts: UIMessage["parts"], +): UIMessage => ({ id: Math.random().toString(), role, parts }) as UIMessage; + +describe("showTypingIndicator", () => { + it("is hidden when not streaming", () => { + expect(showTypingIndicator([], false)).toBe(false); + expect( + showTypingIndicator([msg("assistant", [{ type: "text", text: "hi" }])], false), + ).toBe(false); + }); + + it("shows while streaming with no messages yet (just submitted)", () => { + expect(showTypingIndicator([], true)).toBe(true); + }); + + it("shows while streaming when the last message is still the user's", () => { + expect( + showTypingIndicator([msg("user", [{ type: "text", text: "q" }])], true), + ).toBe(true); + }); + + it("shows while streaming when the assistant row has no visible content", () => { + expect( + showTypingIndicator([msg("assistant", [{ type: "text", text: "" }])], true), + ).toBe(true); + expect( + showTypingIndicator([msg("assistant", [{ type: "text", text: " " }])], true), + ).toBe(true); + }); + + it("hides once the assistant streams non-empty text", () => { + expect( + showTypingIndicator([msg("assistant", [{ type: "text", text: "answer" }])], true), + ).toBe(false); + }); + + it("hides once a tool part appears (even before any text)", () => { + const toolPart = { type: "tool-searchPages" } as unknown as UIMessage["parts"][number]; + expect( + showTypingIndicator([msg("assistant", [toolPart])], true), + ).toBe(false); + }); +}); diff --git a/apps/client/src/features/ai-chat/components/tool-call-card.tsx b/apps/client/src/features/ai-chat/components/tool-call-card.tsx index 921be2fb..d337bd1f 100644 --- a/apps/client/src/features/ai-chat/components/tool-call-card.tsx +++ b/apps/client/src/features/ai-chat/components/tool-call-card.tsx @@ -13,6 +13,14 @@ import classes from "@/features/ai-chat/components/ai-chat.module.css"; interface ToolCallCardProps { part: ToolUiPart; + /** + * Whether to render page citation links. Defaults to true (the internal chat, + * where the reader is authenticated and the `/p/{id}` links resolve). The + * public share passes false: an anonymous reader cannot open internal pages, + * so the links would 404/redirect to login. Suppressing them keeps the card + * (the action log itself) while dropping the unusable links. + */ + showCitations?: boolean; } /** @@ -20,12 +28,15 @@ interface ToolCallCardProps { * agent DID (the agent writes without confirmation — D2), its run state * (running / done / error), and citation link(s) to any referenced page(s). */ -export default function ToolCallCard({ part }: ToolCallCardProps) { +export default function ToolCallCard({ + part, + showCitations = true, +}: ToolCallCardProps) { const { t } = useTranslation(); const toolName = getToolName(part); const state = toolRunState(part.state); const { key, values } = toolLabelKey(toolName); - const citations = toolCitations(part); + const citations = showCitations ? toolCitations(part) : []; return (
diff --git a/apps/client/src/features/ai-chat/utils/markdown.test.ts b/apps/client/src/features/ai-chat/utils/markdown.test.ts new file mode 100644 index 00000000..993dcf4d --- /dev/null +++ b/apps/client/src/features/ai-chat/utils/markdown.test.ts @@ -0,0 +1,69 @@ +import { describe, expect, it } from "vitest"; +import { renderChatMarkdown } from "@/features/ai-chat/utils/markdown.ts"; + +/** + * Tests for the internal-link neutralization used by the anonymous public + * share. Now that the share renders the assistant's MARKDOWN (not plain text), + * internal app links (e.g. `[page](/p/{uuid})`) would otherwise become clickable + * ``, 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. + */ + +/** Parse the rendered HTML and return the first element (or null). */ +function firstAnchor(html: string): HTMLAnchorElement | null { + const doc = new DOMParser().parseFromString(html, "text/html"); + return doc.querySelector("a"); +} + +describe("renderChatMarkdown — internal link neutralization", () => { + it("makes an internal link inert when the flag is ON (no href, text kept)", () => { + const html = renderChatMarkdown("[x](/p/abc)", { + neutralizeInternalLinks: true, + }); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.hasAttribute("href")).toBe(false); + expect(a!.hasAttribute("target")).toBe(false); + // Visible link text is preserved. + expect(a!.textContent).toBe("x"); + }); + + it("neutralizes bare-fragment links when the flag is ON", () => { + const html = renderChatMarkdown("[here](#section)", { + neutralizeInternalLinks: true, + }); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.hasAttribute("href")).toBe(false); + }); + + it("keeps an external http(s) link with a safe rel/target when the flag is ON", () => { + const html = renderChatMarkdown("[y](https://example.com)", { + neutralizeInternalLinks: true, + }); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.getAttribute("href")).toBe("https://example.com"); + expect(a!.getAttribute("rel")).toBe("noopener noreferrer nofollow"); + expect(a!.getAttribute("target")).toBe("_blank"); + }); + + it("keeps internal links clickable when the flag is OFF (internal default)", () => { + const html = renderChatMarkdown("[x](/p/abc)"); + const a = firstAnchor(html); + expect(a).not.toBeNull(); + expect(a!.getAttribute("href")).toBe("/p/abc"); + }); + + 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). + renderChatMarkdown("[x](/p/abc)", { neutralizeInternalLinks: true }); + const html = renderChatMarkdown("[x](/p/abc)"); + const a = firstAnchor(html); + expect(a!.getAttribute("href")).toBe("/p/abc"); + }); +}); diff --git a/apps/client/src/features/ai-chat/utils/markdown.ts b/apps/client/src/features/ai-chat/utils/markdown.ts index 529b3140..d7ba4e74 100644 --- a/apps/client/src/features/ai-chat/utils/markdown.ts +++ b/apps/client/src/features/ai-chat/utils/markdown.ts @@ -1,6 +1,51 @@ import { markdownToHtml } from "@docmost/editor-ext"; import DOMPurify from "dompurify"; +export interface RenderChatMarkdownOptions { + /** + * Neutralize INTERNAL links so they render as inert text (no `href`/`target`). + * Used by the anonymous public share: the assistant's answer can contain + * 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. + */ + 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. + */ +function isExternalHttpUrl(href: string): boolean { + return /^https?:\/\//i.test(href.trim()); +} + +/** + * DOMPurify `afterSanitizeAttributes` hook that neutralizes internal links. + * Hooks are GLOBAL on the DOMPurify instance, so this is only ever registered + * for the duration of a single sanitize call (added then removed in + * `renderChatMarkdown`) — it must never leak into the internal chat's renders. + */ +function neutralizeInternalLinksHook(node: Element): void { + if (node.nodeName !== "A") return; + const href = node.getAttribute("href"); + if (href !== null && isExternalHttpUrl(href)) { + // Genuinely external link: keep it, but force a safe rel/target. + node.setAttribute("rel", "noopener noreferrer nofollow"); + node.setAttribute("target", "_blank"); + return; + } + // Internal/relative/fragment link (or no href): make it inert text. Drop the + // href and any target so it is no longer clickable; the visible text stays. + node.removeAttribute("href"); + node.removeAttribute("target"); +} + /** * Render AI markdown to sanitized HTML for read-only display. We reuse the * app's `markdownToHtml` (the same `marked` pipeline used for paste/import) so @@ -12,9 +57,31 @@ import DOMPurify from "dompurify"; * synchronously, but we guard the Promise case by returning a safe empty string * for that branch (the caller renders the raw text fallback instead). */ -export function renderChatMarkdown(markdown: string): string { +export function renderChatMarkdown( + markdown: string, + options: RenderChatMarkdownOptions = {}, +): string { if (!markdown) return ""; const html = markdownToHtml(markdown); if (typeof html !== "string") return ""; - return DOMPurify.sanitize(html); + + if (!options.neutralizeInternalLinks) { + // Internal chat: unchanged behavior, no hook registered. + return DOMPurify.sanitize(html); + } + + // Public share: register the neutralization hook only for THIS sanitize call, + // then remove it immediately so it can never affect the internal chat (hooks + // are global on the shared DOMPurify instance). + DOMPurify.addHook("afterSanitizeAttributes", neutralizeInternalLinksHook); + try { + return DOMPurify.sanitize(html); + } finally { + // Remove by reference (not a bare pop) so we only ever remove OUR hook, + // robust to any other afterSanitizeAttributes hook registered in future. + DOMPurify.removeHook( + "afterSanitizeAttributes", + neutralizeInternalLinksHook, + ); + } } diff --git a/apps/client/src/features/share/components/share-ai-widget.tsx b/apps/client/src/features/share/components/share-ai-widget.tsx index 90d0b9af..5212e2c4 100644 --- a/apps/client/src/features/share/components/share-ai-widget.tsx +++ b/apps/client/src/features/share/components/share-ai-widget.tsx @@ -7,8 +7,6 @@ import { Box, Group, Paper, - ScrollArea, - Stack, Text, Textarea, Tooltip, @@ -22,6 +20,7 @@ import { import { useTranslation } from "react-i18next"; import { useChat, type UIMessage } from "@ai-sdk/react"; import { DefaultChatTransport } from "ai"; +import MessageList from "@/features/ai-chat/components/message-list.tsx"; interface ShareAiWidgetProps { /** The share id (or key) the assistant is scoped to. */ @@ -30,17 +29,6 @@ interface ShareAiWidgetProps { pageId: string; } -/** Concatenate the visible text parts of a UIMessage. */ -function messageText(message: UIMessage): string { - return (message.parts ?? []) - .filter( - (p): p is { type: "text"; text: string } => - p?.type === "text" && typeof (p as { text?: string }).text === "string", - ) - .map((p) => p.text) - .join(""); -} - /** * Lightweight, EPHEMERAL "Ask AI" widget for a public shared page. * @@ -49,6 +37,15 @@ function messageText(message: UIMessage): string { * memory (this component's `useChat` store) and is sent with `credentials: * "omit"` to the anonymous `/api/shares/ai/stream` endpoint. The server stores * nothing. + * + * Presentation is now shared with the internal chat: the same `MessageList` + * renders the streamed transcript, so the public share gets the SAME + * incremental markdown render, animated typing indicator, and tool-call cards + * as the internal chat. Only the anonymous specifics differ — no auth, no + * history, `credentials: "omit"`, suppressed page citations (an anonymous + * reader cannot open the linked internal pages), neutralized internal markdown + * links (so internal UUIDs/auth-gated routes in the answer don't leak as + * clickable links), and a documentation-focused empty state. */ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) { const { t } = useTranslation(); @@ -147,53 +144,39 @@ export default function ShareAiWidget({ shareId, pageId }: ShareAiWidgetProps) { - - {messages.length === 0 ? ( - - {t("Ask a question about this documentation.")} - - ) : ( - - {messages.map((message) => ( - - - - {messageText(message) || - (isStreaming ? t("Thinking…") : "")} - - - - ))} - - )} + {/* Shared transcript: same incremental streaming render, animated typing + indicator, markdown, and tool-call cards as the internal chat. The + share is anonymous, so page citation links are suppressed (an + anonymous reader cannot open the linked internal pages). */} + + + {t("Ask a question about this documentation.")} + + } + /> + - {error && ( - } - mt="sm" - title={t("Something went wrong")} - > - {t("The assistant is unavailable right now. Please try again.")} - - )} - + {error && ( + } + mx="sm" + mb="xs" + title={t("Something went wrong")} + > + {t("The assistant is unavailable right now. Please try again.")} + + )} Date: Sun, 21 Jun 2026 01:20:11 +0300 Subject: [PATCH 2/2] 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 + } } /**