fix(markdown): drop raw style and form markup from rendered Markdown (#1320)

Markdown from comments, hovers, agent replies and files renders into the IDE's own document, so a raw <style> restyled the whole IDE and <form> / style= reached the page. Every Markdown sanitizer (LSP hovers and comments, agent messages, Markdown preview) now forbids <style>, <form> and the style attribute; agent messages also drop data-* written in a reply, since their click handlers trust data-copy / data-path. align, width and <details> stay.

Closes #1319

Co-authored-by: ntbang0901 <[email protected]>
Цей коміт міститься в:
ntbang0901 authored and GitHub committed 2026-11-05 00:49:11 +08:00
1 джерело c0a74244b4
коміт d0910d3313
7 files changed
+128 -3

No files matched your search

+33
Переглянути файл
@@ -7,6 +7,39 @@ async function freshMarkdown() {
}
describe("markdown rendering", () => {
it.each([
"<style>body{outline:7px solid red}</style>",
'<form action="https://example.com/x"><button>go</button></form>',
'<div style="position:fixed">x</div>',
'<link rel="stylesheet" href="https://example.com/x.css">',
'<meta http-equiv="refresh" content="0;url=https://example.com">',
'<base href="https://example.com/">',
'<iframe src="https://example.com"></iframe>',
'<button formaction="https://example.com/x">go</button>',
])("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** <img src=x onerror=alert(1)>" });
+7 -1
Переглянути файл
@@ -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 `<style>` here would restyle the whole IDE.
return DOMPurify.sanitize(html, {
USE_PROFILES: { html: true },
ALLOWED_URI_REGEXP: ALLOWED_URI,
FORBID_TAGS: ["style", "form"],
FORBID_ATTR: ["style"],
});
}
+31
Переглянути файл
@@ -11,6 +11,37 @@ vi.mock("@/platform/openUrl", () => ({ openExternalUrl }));
beforeEach(() => vi.clearAllMocks());
it.each([
"<style>body{outline:7px solid red}</style>",
'<form action="https://example.com/x"><button>go</button></form>',
'<div style="position:fixed">x</div>',
'<link rel="stylesheet" href="https://example.com/x.css">',
'<meta http-equiv="refresh" content="0;url=https://example.com">',
'<base href="https://example.com/">',
'<iframe src="https://example.com"></iframe>',
'<button formaction="https://example.com/x">go</button>',
])("keeps raw markup from reaching the page: %s", (raw) => {
const { container } = render(<Markdown text={`text\n\n${raw}`} cwd="/repo" />);
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(
<Markdown
text={'<button data-copy="evil">x</button>\n\n<code data-path="/etc/passwd">y</code>\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<HTMLElement>("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],
+9 -1
Переглянути файл
@@ -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;
}
+15
Переглянути файл
@@ -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([
"<style>body{outline:7px solid red}</style>",
'<form action="https://example.com/x"><button>go</button></form>',
'<div style="position:fixed">x</div>',
'<link rel="stylesheet" href="https://example.com/x.css">',
'<meta http-equiv="refresh" content="0;url=https://example.com">',
'<base href="https://example.com/">',
'<iframe src="https://example.com"></iframe>',
'<button formaction="https://example.com/x">go</button>',
])("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(`
+26
Переглянути файл
@@ -30,6 +30,32 @@ describe("markdown preview rendering", () => {
expect(root.querySelector("a")?.getAttribute("href")).toBeNull();
});
it.each([
"<style>body{outline:7px solid red}</style>",
'<form action="https://example.com/x"><button>go</button></form>',
'<div style="position:fixed">x</div>',
'<link rel="stylesheet" href="https://example.com/x.css">',
'<meta http-equiv="refresh" content="0;url=https://example.com">',
'<base href="https://example.com/">',
'<iframe src="https://example.com"></iframe>',
'<button formaction="https://example.com/x">go</button>',
])("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(
'<div align="center"><img src="a.png" width="20"></div>\n\n<details><summary>More</summary>x</details>\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");
+7 -1
Переглянути файл
@@ -81,7 +81,13 @@ export function renderMarkdown(text: string): string {
}
html += `<div data-line="${start}">${body}</div>`;
}
return purify().sanitize(html, { USE_PROFILES: { html: true }, ADD_ATTR: ["data-src", "data-line"] });
// The preview shares the IDE's document: a `<style>` in a file would restyle the whole IDE.
return purify().sanitize(html, {
USE_PROFILES: { html: true },
ADD_ATTR: ["data-src", "data-line"],
FORBID_TAGS: ["style", "form"],
FORBID_ATTR: ["style"],
});
}
export type LinkTarget =