diff --git a/src/editor/lsp/markdown.test.ts b/src/editor/lsp/markdown.test.ts index d73575a6b..c3b5e2f51 100644 --- a/src/editor/lsp/markdown.test.ts +++ b/src/editor/lsp/markdown.test.ts @@ -7,6 +7,39 @@ async function freshMarkdown() { } describe("markdown rendering", () => { + it.each([ + "", + '
', + '
x
', + '', + '', + '', + '', + '', + ])("keeps raw markup from reaching the page: %s", async (raw) => { + const md = await freshMarkdown(); + await md.loadMarkdown(); + const root = document.createElement("div"); + root.innerHTML = md.markupToHtml({ kind: "markdown", value: `text\n\n${raw}\n` }); + expect(root.querySelector("style, form, link, meta, base, iframe, [style], [formaction]")).toBeNull(); + expect(root.textContent).not.toContain("outline"); + }); + + it("keeps the links and tables comments and docs rely on", async () => { + const md = await freshMarkdown(); + await md.loadMarkdown(); + const root = document.createElement("div"); + root.innerHTML = md.markupToHtml({ + kind: "markdown", + value: "[doc](jdt://contents/a.class) [web](https://example.com)\n\n| A |\n| - |\n| 1 |\n", + }); + expect([...root.querySelectorAll("a")].map((a) => a.getAttribute("href"))).toEqual([ + "jdt://contents/a.class", + "https://example.com", + ]); + expect(root.querySelector("td")?.textContent).toBe("1"); + }); + it("shows plain text until the parser loads, then the rendered markup", async () => { const md = await freshMarkdown(); const el = md.renderMarkup({ kind: "markdown", value: "**bold** " }); diff --git a/src/editor/lsp/markdownParser.ts b/src/editor/lsp/markdownParser.ts index 2f4698aaf..e4473eda6 100644 --- a/src/editor/lsp/markdownParser.ts +++ b/src/editor/lsp/markdownParser.ts @@ -9,5 +9,11 @@ const marked = new Marked({ gfm: true, breaks: false }); export function markdownToSafeHtml(markdown: string): string { const html = marked.parse(markdown, { async: false }) as string; - return DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, ALLOWED_URI_REGEXP: ALLOWED_URI }); + // Comments and docs come from anyone; a `", + '
', + '
x
', + '', + '', + '', + '', + '', +])("keeps raw markup from reaching the page: %s", (raw) => { + const { container } = render(); + expect(container.querySelector("style, form, link, meta, base, iframe, [style], [formaction]")).toBeNull(); + expect(container.textContent).not.toContain("outline"); +}); + +it("ignores data attributes written in a reply, keeping its own", () => { + const { container } = render( + x\n\ny\n\n```ts\nconst a = 1;\n```'} + cwd="/repo" + />, + ); + expect(container.querySelector("code[data-path]")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "x" })); + fireEvent.click(container.querySelector("code")!); + expect(openFile).not.toHaveBeenCalled(); + expect( + [...container.querySelectorAll("button[data-copy]")].map((button) => button.dataset.copy), + ).toEqual(["const a = 1;"]); +}); + it.each([ ["/Users/thomas/vmos-shots/tiktok-home.jpg", "/Users/thomas/vmos-shots/tiktok-home.jpg", undefined], ["images/home.jpg", "/repo/images/home.jpg", undefined], diff --git a/src/features/agents/Markdown.tsx b/src/features/agents/Markdown.tsx index fe80c6828..a0da3f508 100644 --- a/src/features/agents/Markdown.tsx +++ b/src/features/agents/Markdown.tsx @@ -19,7 +19,15 @@ const marked = new Marked({ gfm: true, breaks: true }); export function markdownHtml(text: string): string { const html = marked.parse(text, { async: false }) as string; - const root = DOMPurify.sanitize(html, { USE_PROFILES: { html: true }, RETURN_DOM: true }) as HTMLElement; + // Replies can quote repository content. Styles would reach the whole IDE, and the click + // handlers below trust `data-copy` / `data-path`, which only `enhance` may set. + const root = DOMPurify.sanitize(html, { + USE_PROFILES: { html: true }, + RETURN_DOM: true, + FORBID_TAGS: ["style", "form"], + FORBID_ATTR: ["style"], + ALLOW_DATA_ATTR: false, + }) as HTMLElement; root.querySelectorAll("li").forEach(wrapLooseText); return root.innerHTML; } diff --git a/src/features/notebook/security.test.ts b/src/features/notebook/security.test.ts index 6ef2b8298..e8282a3ec 100644 --- a/src/features/notebook/security.test.ts +++ b/src/features/notebook/security.test.ts @@ -4,6 +4,21 @@ import { markdownRoot, resolveMarkdownAttachments, sanitizedSvgUrl, staticOutput const parse = (html: string) => new DOMParser().parseFromString(html, "text/html"); describe("static notebook output sanitization", () => { + it.each([ + "", + '
', + '
x
', + '', + '', + '', + '', + '', + ])("keeps raw Markdown markup from reaching the page: %s", (raw) => { + const root = markdownRoot(`text\n\n${raw}\n`); + expect(root.querySelector("style, form, link, meta, base, iframe, [style], [formaction]")).toBeNull(); + expect(root.textContent).not.toContain("outline"); + }); + it("retains table content while removing executable, navigation and external-resource surfaces", () => { const document = parse( staticOutputDocument(` diff --git a/src/features/viewers/markdown/render.test.ts b/src/features/viewers/markdown/render.test.ts index 28031903f..6519c8bc5 100644 --- a/src/features/viewers/markdown/render.test.ts +++ b/src/features/viewers/markdown/render.test.ts @@ -30,6 +30,32 @@ describe("markdown preview rendering", () => { expect(root.querySelector("a")?.getAttribute("href")).toBeNull(); }); + it.each([ + "", + '
', + '
x
', + '', + '', + '', + '', + '', + ])("keeps raw markup from reaching the page: %s", (raw) => { + const root = document.createElement("div"); + root.innerHTML = renderMarkdown(`text\n\n${raw}\n`); + expect(root.querySelector("style, form, link, meta, base, iframe, [style], [formaction]")).toBeNull(); + expect(root.textContent).not.toContain("outline"); + }); + + it("keeps the raw HTML READMEs rely on", () => { + const root = document.createElement("div"); + root.innerHTML = renderMarkdown( + '
\n\n
Morex
\n\n- [x] done\n', + ); + expect(root.querySelector("div[align='center'] img")?.getAttribute("width")).toBe("20"); + expect(root.querySelector("details summary")).not.toBeNull(); + expect(root.querySelector("input[type='checkbox']")).not.toBeNull(); + }); + it("dedupes heading anchors", () => { const root = document.createElement("div"); root.innerHTML = renderMarkdown("## Same\n\n## Same\n"); diff --git a/src/features/viewers/markdown/render.ts b/src/features/viewers/markdown/render.ts index 1be1d6334..a08c39dcd 100644 --- a/src/features/viewers/markdown/render.ts +++ b/src/features/viewers/markdown/render.ts @@ -81,7 +81,13 @@ export function renderMarkdown(text: string): string { } html += `
${body}
`; } - return purify().sanitize(html, { USE_PROFILES: { html: true }, ADD_ATTR: ["data-src", "data-line"] }); + // The preview shares the IDE's document: a `