feat(dashboard): shared markdown renders embedded HTML (sanitized) + mermaid diagrams

MailboxMessageContent pipeline gains rehype-raw -> rehype-sanitize (GitHub-like allow list: details/summary/kbd/tables/etc; strips script/style/iframe/on*/javascript:) so raw HTML in markdown renders and HTML comments are dropped. Fenced ```mermaid blocks render via a lazy-loaded MermaidDiagram component (dynamic import keeps mermaid out of the main bundle; theme-aware; falls back to the raw block on parse error).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-22 11:08:25 -07:00
parent fefc91b30e
commit 8640a747aa
7 changed files with 974 additions and 10 deletions

View File

@@ -0,0 +1,5 @@
---
"@runfusion/fusion": minor
---
The shared markdown renderer (GitHub PR/issue bodies + comments, mailbox, chat) now renders embedded raw HTML and mermaid diagrams. Raw HTML (`<details>`/`<summary>`, `<kbd>`, `<sub>`, tables) renders as real elements via `rehype-raw`, with `rehype-sanitize` stripping XSS (script/style/iframe, event handlers, `javascript:` URLs) since these bodies come from GitHub; HTML comments (`<!-- -->`) are dropped. Fenced ```mermaid blocks render as actual diagrams via a lazy-loaded `mermaid` import (kept out of the main bundle, loaded only when a diagram is present), falling back to the raw code block on parse error and following the dashboard theme.

View File

@@ -1,8 +1,51 @@
import { memo } from "react";
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import rehypeRaw from "rehype-raw";
import rehypeSanitize, { defaultSchema } from "rehype-sanitize";
import type { Options as SanitizeSchema } from "rehype-sanitize";
import type { Components } from "react-markdown";
import type { PluggableList } from "unified";
import { linkifyReactChildren } from "../utils/filePathLinkify";
import { MermaidDiagram } from "./MermaidDiagram";
/*
FNXC:Markdown 2026-06-23-03:15:
GitHub PR/issue bodies + comments (and mailbox/chat) embed raw HTML (`<details>`,
`<summary>`, `<kbd>`, `<sub>`, tables), HTML comments (`<!-- -->`), and ```mermaid
blocks. Previously raw HTML was escaped to literal text and mermaid showed as code.
Pipeline (ORDER MATTERS): remark-gfm -> rehype-raw -> rehype-sanitize.
- rehype-raw parses embedded HTML into the hast tree so it renders as real elements.
It also DROPS HTML comments by default, so `<!-- ... -->` never appears in output.
- rehype-sanitize runs AFTER raw to strip XSS: <script>/<style>/<iframe>, event
handlers (onClick etc.), and javascript: URLs. Because these bodies come from
GitHub (untrusted), sanitize is mandatory — raw without sanitize would be an XSS
hole. Running sanitize last guarantees nothing injected via raw survives.
*/
/*
FNXC:Markdown 2026-06-23-03:15:
Sanitize schema = rehype-sanitize defaultSchema (a conservative GitHub-like allow
list that already permits details/summary/kbd/sub/sup/b/i/em/strong/a/img/code/pre/
tables/br/hr/blockquote/lists/headings/span/div and strips script/style/event
handlers/javascript: URLs) EXTENDED to ensure the `className` attribute survives on
common elements (needed for our `language-*` code fences and styled wrappers). We do
NOT widen tagNames beyond defaults, so script/style/iframe stay stripped.
*/
const mailboxSanitizeSchema: SanitizeSchema = {
...defaultSchema,
attributes: {
...defaultSchema.attributes,
// Preserve className on code/span/div/pre so language fences + wrapper styling work.
code: [...(defaultSchema.attributes?.code ?? []), "className"],
span: [...(defaultSchema.attributes?.span ?? []), "className"],
div: [...(defaultSchema.attributes?.div ?? []), "className"],
pre: [...(defaultSchema.attributes?.pre ?? []), "className"],
// `<details open>` disclosure state should round-trip.
details: [...(defaultSchema.attributes?.details ?? []), "open"],
},
};
const mailboxMarkdownComponents: Components = {
p: ({ children, ...props }) => <p {...props}>{linkifyReactChildren(children)}</p>,
@@ -17,8 +60,26 @@ const mailboxMarkdownComponents: Components = {
{children}
</table>
),
// Open links in a new tab. ReactMarkdown does not allow raw HTML by default,
// so the rendered output here is safe.
/*
FNXC:Markdown 2026-06-23-03:15:
Code-block override: a fenced ```mermaid block arrives as `<code class="language-mermaid">`.
Render it via <MermaidDiagram>, which lazy-imports mermaid so the heavy library is
only pulled in when a diagram is present. All other code (inline + other languages)
keeps the default rendering.
*/
code: ({ className, children, ...props }) => {
if (className === "language-mermaid") {
const chart = String(children ?? "").replace(/\n$/, "");
return <MermaidDiagram chart={chart} testId="mailbox-mermaid-diagram" />;
}
return (
<code className={className} {...props}>
{children}
</code>
);
},
// Open links in a new tab. Sanitize strips javascript: URLs and event handlers
// before this runs, so href is safe.
a: ({ children, ...props }) => (
<a {...props} target="_blank" rel="noopener noreferrer">
{children}
@@ -26,6 +87,10 @@ const mailboxMarkdownComponents: Components = {
),
};
const remarkPlugins: PluggableList = [remarkGfm];
// Raw must run before sanitize: parse HTML, then strip anything unsafe.
const rehypePlugins: PluggableList = [rehypeRaw, [rehypeSanitize, mailboxSanitizeSchema]];
interface MailboxMessageContentProps {
/** Raw message body. Rendered as GitHub-flavored markdown. */
content: string;
@@ -38,9 +103,10 @@ interface MailboxMessageContentProps {
/**
* Renders a mailbox message body as GitHub-flavored markdown.
*
* Uses ReactMarkdown defaults (no raw HTML) so untrusted message content is
* safe. Plain-text messages render unchanged (markdown is a strict superset
* for the formatting we care about — bold, lists, code, links, tables).
* Supports embedded raw HTML (details/summary/kbd/sub/tables) via rehype-raw, with
* rehype-sanitize stripping XSS (script/style/iframe/event-handlers/javascript:).
* Fenced ```mermaid blocks render as diagrams via the lazy-loaded MermaidDiagram.
* HTML comments (`<!-- -->`) are dropped and never rendered.
*
* Memoized because mailbox detail panes can re-render on selection / SSE
* updates while the underlying message body is unchanged.
@@ -55,7 +121,11 @@ export const MailboxMessageContent = memo(function MailboxMessageContent({
: "mailbox-markdown";
return (
<div className={wrapperClass} data-testid={testId}>
<ReactMarkdown remarkPlugins={[remarkGfm]} components={mailboxMarkdownComponents}>
<ReactMarkdown
remarkPlugins={remarkPlugins}
rehypePlugins={rehypePlugins}
components={mailboxMarkdownComponents}
>
{content}
</ReactMarkdown>
</div>

View File

@@ -371,6 +371,35 @@
padding: var(--space-xs) var(--space-sm);
}
/*
FNXC:Markdown 2026-06-23-03:15:
Wrappers for embedded raw HTML + mermaid diagrams. `<details>` from GitHub bodies
needs a clickable summary affordance; mermaid SVGs should scroll horizontally rather
than overflow the message column. Theme tokens only — no hard-coded colors.
*/
.mailbox-markdown details {
border: var(--btn-border-width) solid color-mix(in srgb, var(--border) 80%, transparent);
border-radius: var(--radius-md);
padding: var(--space-xs) var(--space-sm);
background: color-mix(in srgb, var(--surface) 85%, transparent);
}
.mailbox-markdown summary {
cursor: pointer;
font-weight: 600;
}
.mailbox-mermaid {
display: block;
max-width: 100%;
overflow-x: auto;
}
.mailbox-mermaid svg {
max-width: 100%;
height: auto;
}
.mailbox-reply-context-wrapper {
margin-bottom: var(--space-xs);
}

View File

@@ -0,0 +1,95 @@
import { memo, useEffect, useRef, useState } from "react";
/*
FNXC:Markdown 2026-06-23-03:15:
GitHub PR/issue bodies and comments embed ```mermaid fenced blocks. Render them
as real diagrams instead of literal code. The `mermaid` library is heavy (~600kb+
of parser/renderer), so it is LAZY-LOADED via `await import("mermaid")` only when a
mermaid block is actually present — keeping it out of the main dashboard bundle.
Race/unmount safety: each render gets a unique element id, an incrementing render
token guards against overlapping async renders (theme/chart change mid-flight), and
an `unmounted` flag prevents state updates after teardown. On parse error we fall
back to the raw fenced code block so a malformed diagram never crashes the message.
*/
let mermaidIdCounter = 0;
/** Theme follows the dashboard token: `data-theme="light"` => mermaid `default`, else `dark`. */
function resolveMermaidTheme(): "dark" | "default" {
if (typeof document === "undefined") return "default";
return document.documentElement.dataset.theme === "light" ? "default" : "dark";
}
interface MermaidDiagramProps {
/** Raw mermaid source from the fenced ```mermaid block. */
chart: string;
/** Optional data-testid for test selectors. */
testId?: string;
}
/**
* Renders a mermaid diagram from raw mermaid source.
*
* Lazy-imports `mermaid` inside an effect, calls `mermaid.render` to produce an
* SVG string, and injects it. On any parse/render failure, falls back to the raw
* code block so the surrounding message keeps rendering.
*/
export const MermaidDiagram = memo(function MermaidDiagram({
chart,
testId,
}: MermaidDiagramProps) {
const containerRef = useRef<HTMLDivElement | null>(null);
const [errored, setErrored] = useState(false);
useEffect(() => {
let unmounted = false;
// Bump the token per effect run; only the latest run is allowed to commit.
const renderToken = ++mermaidIdCounter;
const elementId = `mermaid-${renderToken}`;
setErrored(false);
void (async () => {
try {
const mermaidModule = await import("mermaid");
const mermaid = mermaidModule.default;
mermaid.initialize({
startOnLoad: false,
theme: resolveMermaidTheme(),
securityLevel: "strict",
});
const { svg } = await mermaid.render(elementId, chart);
if (unmounted || renderToken !== mermaidIdCounter) return;
if (containerRef.current) {
containerRef.current.innerHTML = svg;
}
} catch {
if (unmounted || renderToken !== mermaidIdCounter) return;
setErrored(true);
}
})();
return () => {
unmounted = true;
};
}, [chart]);
if (errored) {
// Fallback: show the raw mermaid source as a normal code block.
return (
<pre className="mailbox-markdown-pre mailbox-mermaid-fallback" data-testid={testId}>
<code>{chart}</code>
</pre>
);
}
return (
<div
ref={containerRef}
className="mailbox-mermaid"
data-testid={testId}
aria-label="Mermaid diagram"
/>
);
});

View File

@@ -1,9 +1,19 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, cleanup, screen } from "@testing-library/react";
import { render, cleanup, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { FileBrowserProvider } from "../../context/FileBrowserContext";
import { MailboxMessageContent } from "../MailboxMessageContent";
// FNXC:Markdown 2026-06-23-03:15: Mock the heavy `mermaid` library so the mermaid
// rendering tests do not pull in the real parser/renderer bundle. The component
// lazy-imports `mermaid` (default export), so we mock the module default.
vi.mock("mermaid", () => ({
default: {
initialize: vi.fn(),
render: vi.fn().mockResolvedValue({ svg: "<svg data-testid='mock-mermaid-svg'></svg>" }),
},
}));
afterEach(() => {
cleanup();
});
@@ -71,11 +81,10 @@ describe("MailboxMessageContent", () => {
expect(table?.querySelectorAll("tbody td")).toHaveLength(2);
});
it("does NOT execute raw HTML in messages", () => {
it("sanitizes raw <script> out of messages (no execution, no element)", () => {
const content = "<script>window.__pwned = true;</script>Hello";
const { container } = render(<MailboxMessageContent content={content} />);
// ReactMarkdown defaults disallow raw HTML — the <script> tag should be
// rendered as escaped text, not as a real script element.
// rehype-raw parses HTML, but rehype-sanitize strips <script> before render.
expect(container.querySelector("script")).toBeNull();
expect(
(globalThis as unknown as { __pwned?: boolean }).__pwned,
@@ -83,6 +92,53 @@ describe("MailboxMessageContent", () => {
expect(container.textContent).toContain("Hello");
});
it("renders raw <details>/<summary> as a working disclosure element", () => {
const content =
"<details><summary>More info</summary>Hidden body text here.</details>";
const { container } = render(<MailboxMessageContent content={content} />);
const details = container.querySelector("details");
expect(details).not.toBeNull();
expect(details?.querySelector("summary")?.textContent).toBe("More info");
expect(details?.textContent).toContain("Hidden body text here.");
});
it("renders other safe raw HTML (kbd/sub) as real elements", () => {
const content = "Press <kbd>Cmd</kbd> and H<sub>2</sub>O.";
const { container } = render(<MailboxMessageContent content={content} />);
expect(container.querySelector("kbd")?.textContent).toBe("Cmd");
expect(container.querySelector("sub")?.textContent).toBe("2");
});
it("does NOT render HTML comments in the output", () => {
const content = "Before<!-- secret hidden note -->After";
const { container } = render(<MailboxMessageContent content={content} />);
expect(container.innerHTML).not.toContain("secret hidden note");
expect(container.innerHTML).not.toContain("<!--");
expect(container.textContent).toContain("Before");
expect(container.textContent).toContain("After");
});
it("strips javascript: URLs and event handlers from raw HTML", () => {
const content = '<a href="javascript:alert(1)" onclick="alert(2)">click</a>';
const { container } = render(<MailboxMessageContent content={content} />);
const link = container.querySelector("a");
// sanitize drops the javascript: href and the onclick handler.
expect(link?.getAttribute("href") ?? "").not.toContain("javascript:");
expect(link?.getAttribute("onclick")).toBeNull();
});
it("renders a ```mermaid block as the MermaidDiagram container", async () => {
const content = "```mermaid\ngraph TD; A-->B;\n```";
render(<MailboxMessageContent content={content} />);
const diagram = await screen.findByTestId("mailbox-mermaid-diagram");
expect(diagram).toBeInTheDocument();
expect(diagram).toHaveClass("mailbox-mermaid");
// The mocked mermaid.render SVG is injected into the container.
await waitFor(() => {
expect(diagram.querySelector("svg")).not.toBeNull();
});
});
it("forwards testId to the wrapper", () => {
render(<MailboxMessageContent content="x" testId="mailbox-message-body" />);
expect(screen.getByTestId("mailbox-message-body")).toBeInTheDocument();

View File

@@ -130,6 +130,7 @@
"i18next-resources-to-backend": "^1.2.1",
"ioredis": "^5.6.0",
"lucide-react": "^1.7.0",
"mermaid": "^11.4.0",
"multer": "^2.1.1",
"node-pty": "npm:@homebridge/node-pty-prebuilt-multiarch@^0.13.1",
"qrcode": "^1.5.4",
@@ -138,6 +139,8 @@
"react-dom": "^19.0.0",
"react-i18next": "^17.0.8",
"react-markdown": "^10.1.0",
"rehype-raw": "^7.0.0",
"rehype-sanitize": "^6.0.0",
"remark-gfm": "^4.0.1",
"ws": "^8.18.0",
"zod": "^3.25.76"

706
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff