diff options
| -rw-r--r-- | src/features/markdown/logic/markdown.test.ts | 40 | ||||
| -rw-r--r-- | src/features/markdown/logic/markdown.ts | 27 |
2 files changed, 64 insertions, 3 deletions
diff --git a/src/features/markdown/logic/markdown.test.ts b/src/features/markdown/logic/markdown.test.ts index 54b0086..90f1938 100644 --- a/src/features/markdown/logic/markdown.test.ts +++ b/src/features/markdown/logic/markdown.test.ts @@ -25,6 +25,46 @@ describe("renderMarkdown", () => { expect(html).toContain("<b>"); }); + describe("links open in a new tab", () => { + function anchors(html: string): HTMLAnchorElement[] { + const doc = new DOMParser().parseFromString(html, "text/html"); + return Array.from(doc.querySelectorAll("a")); + } + + it.each([ + ["a markdown link", "See [the docs](https://example.com/docs)."], + ["a GFM autolink", "Visit https://example.com today"], + ["an angle-bracket autolink", "<https://example.com>"], + ["a raw-HTML anchor", 'Raw <a href="https://example.com">link</a>'], + ["a relative link", "[home](/settings)"], + ])("%s gets target=_blank + rel=noopener noreferrer", (_label, src) => { + const links = anchors(renderMarkdown(src)); + expect(links).toHaveLength(1); + expect(links[0]?.getAttribute("target")).toBe("_blank"); + expect(links[0]?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("overrides an author-supplied target/rel", () => { + const [link] = anchors( + renderMarkdown('<a href="https://example.com" target="_self" rel="opener">x</a>'), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + expect(link?.getAttribute("rel")).toBe("noopener noreferrer"); + }); + + it("applies while streaming too", () => { + const [link] = anchors( + renderMarkdown("partial [link](https://example.com) **bo", { streaming: true }), + ); + expect(link?.getAttribute("target")).toBe("_blank"); + }); + + it("still strips javascript: hrefs", () => { + const html = renderMarkdown('<a href="javascript:alert(1)">x</a>'); + expect(html).not.toContain("javascript:"); + }); + }); + it("sanitizes dangerous HTML", () => { const html = renderMarkdown("Hi <script>alert(1)</script> there"); expect(html).not.toContain("<script>"); diff --git a/src/features/markdown/logic/markdown.ts b/src/features/markdown/logic/markdown.ts index 626b4da..fd6caf9 100644 --- a/src/features/markdown/logic/markdown.ts +++ b/src/features/markdown/logic/markdown.ts @@ -184,12 +184,33 @@ function addCopyButtons(html: string): string { .replace(/<\/pre>/g, "</pre></div>"); } +/** + * A DEDICATED DOMPurify instance (not the shared default export) so the + * link hook below only applies to chat Markdown, never to any other sanitizer + * user. Every link opens in a NEW TAB: after DOMPurify has allowlisted the + * attributes, each `<a href>` gets `target="_blank"` + `rel="noopener + * noreferrer"` (the opened page can't reach `window.opener` or see a referrer). + * Applied in the hook, so it covers marked links, autolinks, AND raw-HTML + * anchors alike — and the hook overwrites any author-supplied target/rel. + */ +const purify = DOMPurify(); +purify.addHook("afterSanitizeAttributes", (node) => { + if (node.tagName === "A" && node.hasAttribute("href")) { + node.setAttribute("target", "_blank"); + node.setAttribute("rel", "noopener noreferrer"); + } +}); + +function sanitize(html: string): string { + return purify.sanitize(html); +} + /** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */ export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string { const src = opts?.streaming === true ? closeOpenDelimiters(text) : text; try { const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(raw)); } catch { return ""; } @@ -208,7 +229,7 @@ export function renderMarkdownTail(text: string, seed: DelimiterCounts = ZERO_CO const src = closeOpenDelimiters(text, seed); try { const raw = md.parse(src) as string; - return DOMPurify.sanitize(addCopyButtons(raw)); + return sanitize(addCopyButtons(raw)); } catch { return ""; } @@ -238,7 +259,7 @@ export function renderOpenFence(info: string, code: string): string { const cls = lang === "" ? "hljs" : `hljs language-${escapeHtml(lang)}`; const html = `<pre><code class="${cls}">${escapeCode(code)}</code></pre>`; try { - return DOMPurify.sanitize(addCopyButtons(html)); + return sanitize(addCopyButtons(html)); } catch { return ""; } |
