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\nMore
x \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 `