дзеркало
https://github.com/l0ng-ai/openidea.git
синхронізовано 2026-11-09 03:05:40 +02:00
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]>
Цей коміт міститься в:
1 джерело
c0a74244b4
коміт
d0910d3313
7 files changed
+128
-3
No files matched your search
@@ -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)>" });
|
||||
|
||||
@@ -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"],
|
||||
});
|
||||
}
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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(`
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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 =
|
||||
|
||||
Reference in new issue
Заблокувати користувача