summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
-rw-r--r--src/features/markdown/logic/markdown.test.ts40
-rw-r--r--src/features/markdown/logic/markdown.ts27
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 "";
}