fix(web): render HTML in markdown previews (#1707)
This commit is contained in:
@@ -14,6 +14,8 @@ import {
|
||||
} from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import ReactMarkdown, { type ExtraProps, type Components } from "react-markdown";
|
||||
import rehypeRaw from "rehype-raw";
|
||||
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
|
||||
import { Button } from "@kandev/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
|
||||
import { IconCode, IconMessagePlus } from "@tabler/icons-react";
|
||||
@@ -355,7 +357,11 @@ export const MarkdownPreviewContent = memo(function MarkdownPreviewContent({
|
||||
<div ref={scrollRef} className="flex-1 overflow-auto p-6">
|
||||
<div ref={rootRef} className="markdown-body max-w-3xl" tabIndex={commentsEnabled ? 0 : -1}>
|
||||
<PreviewCommentContext.Provider value={previewCommentContextValue}>
|
||||
<ReactMarkdown remarkPlugins={remarkPlugins} components={markdownPreviewComponents}>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[rehypeRaw, [rehypeSanitize, defaultSchema]]}
|
||||
components={markdownPreviewComponents}
|
||||
>
|
||||
{content}
|
||||
</ReactMarkdown>
|
||||
</PreviewCommentContext.Provider>
|
||||
|
||||
@@ -23,6 +23,21 @@ console.log("hello");
|
||||
\`\`\`
|
||||
`;
|
||||
|
||||
const HTML_MARKDOWN_CONTENT = `<div align="center">
|
||||
<a href="https://github.com/kdlbs/kandev">
|
||||
<img width="120" height="80" src="./logo.svg" alt="Kandev logo" onerror="alert('xss')">
|
||||
</a>
|
||||
<br>
|
||||
<h1>Embedded HTML</h1>
|
||||
<p>Rendered from a README.</p>
|
||||
<details open>
|
||||
<summary>More information</summary>
|
||||
<p>Collapsible README content.</p>
|
||||
</details>
|
||||
<a href="javascript:alert('xss')">Unsafe link</a>
|
||||
<script>window.markdownXss = true</script>
|
||||
</div>`;
|
||||
|
||||
async function seedTaskWithSession(
|
||||
testPage: Page,
|
||||
apiClient: ApiClient,
|
||||
@@ -147,6 +162,42 @@ test.describe("Markdown preview", () => {
|
||||
await expect(preview).not.toBeVisible({ timeout: 5_000 });
|
||||
});
|
||||
|
||||
test("renders safe embedded HTML and strips executable content", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
backend,
|
||||
}) => {
|
||||
const repoDir = path.join(backend.tmpDir, "repos", "e2e-repo");
|
||||
fs.writeFileSync(path.join(repoDir, "html-readme.md"), HTML_MARKDOWN_CONTENT);
|
||||
|
||||
const { session } = await seedTaskWithSession(
|
||||
testPage,
|
||||
apiClient,
|
||||
seedData,
|
||||
"Markdown Embedded HTML Test",
|
||||
);
|
||||
await openFileInPreview(testPage, session, "html-readme.md");
|
||||
|
||||
const preview = testPage.getByTestId("markdown-preview");
|
||||
const centeredContent = preview.locator('div[align="center"]');
|
||||
await expect(centeredContent).toBeVisible();
|
||||
await expect(centeredContent.getByRole("heading", { name: "Embedded HTML" })).toBeVisible();
|
||||
|
||||
const image = centeredContent.getByRole("img", { name: "Kandev logo" });
|
||||
await expect(image).toHaveAttribute("src", "./logo.svg");
|
||||
await expect(image).toHaveAttribute("width", "120");
|
||||
await expect(image).not.toHaveAttribute("onerror");
|
||||
await expect(centeredContent.locator("p").first()).toHaveAttribute("data-md-source-start", "7");
|
||||
await expect(centeredContent.locator("details")).toContainText("Collapsible README content.");
|
||||
await expect(centeredContent.locator("summary")).toHaveText("More information");
|
||||
await expect(centeredContent.locator("a", { hasText: "Unsafe link" })).not.toHaveAttribute(
|
||||
"href",
|
||||
);
|
||||
await expect(preview.locator("script")).toHaveCount(0);
|
||||
expect(await testPage.evaluate(() => "markdownXss" in window)).toBe(false);
|
||||
});
|
||||
|
||||
test("markdown preview persists across page refresh", async ({
|
||||
testPage,
|
||||
apiClient,
|
||||
|
||||
@@ -263,7 +263,10 @@ test.describe("Mobile file viewer panel", () => {
|
||||
seedData,
|
||||
backend,
|
||||
taskTitle: "Mobile FV Markdown",
|
||||
options: { extension: "md", content: "# Heading\n\nBody text" },
|
||||
options: {
|
||||
extension: "md",
|
||||
content: '# Heading\n\n<div align="center"><p>Embedded HTML body</p></div>',
|
||||
},
|
||||
});
|
||||
|
||||
await testPage.getByRole("button", { name: "Files" }).tap();
|
||||
@@ -277,6 +280,7 @@ test.describe("Mobile file viewer panel", () => {
|
||||
|
||||
await viewer.getByTestId("markdown-preview-toggle").tap();
|
||||
await expect(viewer.getByTestId("markdown-preview")).toBeVisible();
|
||||
await expect(viewer.locator('div[align="center"]')).toContainText("Embedded HTML body");
|
||||
|
||||
await selectMarkdownPreviewText(viewer.getByTestId("markdown-preview").locator("p").first());
|
||||
const commentButton = testPage.getByTestId("markdown-preview-comment-button");
|
||||
|
||||
Reference in New Issue
Block a user