Compare commits

...

2 Commits

Author SHA1 Message Date
J
ad7f70ab0d fix(markdown): allow data:image/* in ReadonlyContent too (MUL-3961)
Issue comments and other read-only surfaces render through ReadonlyContent,
which keeps its own sanitize schema + urlTransform separate from the base
Markdown component. Both still stripped data: URIs, so an agent inlining an
auth QR code in an issue comment still saw a broken image.

Apply the same image/*-narrowed data: allowance (protocols.src +
attributes.img + urlTransform) and add a regression test covering the
comment / readonly path. file-cards.ts data: rejection stays untouched.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 13:51:53 +08:00
J
d6ff71bc9c fix(markdown): render inline data-URI images (MUL-3961)
Inline data:image/* URIs (QR codes, charts, base64 screenshots) were
stripped and rendered as broken images. Two gates dropped the src:

- rehype-sanitize's protocols.src only allowed http/https
- react-markdown's defaultUrlTransform blanks any data: URL to ''

Allow data:image/* through both gates, narrowed to image subtypes only
(non-image data URIs stay rejected) and leaving every other src form
unchanged. file-cards.ts data: rejection is intentional and untouched.

Co-authored-by: multica-agent <github@multica.ai>
2026-07-02 12:55:37 +08:00
4 changed files with 108 additions and 2 deletions

View File

@@ -84,6 +84,10 @@ const sanitizeSchema = {
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), 'mention', 'slash'],
// Permit inline data-URI images (QR codes, charts, base64 screenshots).
// The scheme gate only allows `data:` through here; attributes.img below
// narrows it to image/* so non-image data URIs are still rejected.
src: [...(defaultSchema.protocols?.src ?? []), 'data'],
},
attributes: {
...defaultSchema.attributes,
@@ -100,8 +104,17 @@ const sanitizeSchema = {
['className', /^hljs/],
],
img: [
...(defaultSchema.attributes?.img ?? []),
// Drop the default plain `src` entry so the value allow-list below is the
// one findDefinition resolves — it returns the first match by name, so a
// bare `src` string would otherwise shadow (and disable) the allow-list.
...(defaultSchema.attributes?.img ?? []).filter(
(attr) => (typeof attr === 'string' ? attr : attr[0]) !== 'src',
),
'alt',
// Allow inline data:image/* URIs while leaving every other src form
// (http/https/site-relative) exactly as before: the negative lookahead
// keeps all non-data values, and data: is narrowed to images only.
['src', /^data:image\//i, /^(?!data:)/i],
],
},
}
@@ -113,6 +126,11 @@ const sanitizeSchema = {
function urlTransform(url: string): string {
if (url.startsWith('mention://')) return url
if (url.startsWith('slash://skill/')) return url
// Allow inline data:image/* URIs — defaultUrlTransform strips every data: URL
// to '', which would blank the src even after rehype-sanitize keeps it. Kept
// in sync with the image/* narrowing in sanitizeSchema (protocols.src +
// attributes.img) so both gates agree on what a valid inline image is.
if (/^data:image\//i.test(url)) return url
return defaultUrlTransform(url)
}

View File

@@ -1,6 +1,7 @@
import type { ReactNode } from "react";
import { render, screen } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { Markdown as MarkdownBase } from "@multica/ui/markdown";
import { Markdown } from "./markdown";
vi.mock("@multica/core/config", () => ({
@@ -90,3 +91,38 @@ describe("Markdown", () => {
expect(screen.getByRole("link")).toHaveAttribute("href", "/projects/project-123");
});
});
// The base renderer uses a plain <img>; exercising it here (instead of the
// views wrapper, which swaps in <Attachment>) lets us assert the sanitized
// `src` directly. Covers the two gates that used to blank data-URI images:
// rehype-sanitize's protocols.src and react-markdown's urlTransform.
describe("Markdown inline data-URI images", () => {
const PNG_1X1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
it("preserves the src of an inline data:image/png image", () => {
render(<MarkdownBase>{`![demo](${PNG_1X1})`}</MarkdownBase>);
const img = screen.getByAltText("demo");
expect(img.tagName).toBe("IMG");
expect(img).toHaveAttribute("src", PNG_1X1);
});
it("keeps regular http(s) images working", () => {
render(<MarkdownBase>{"![cat](https://cdn.example.com/cat.png)"}</MarkdownBase>);
expect(screen.getByAltText("cat")).toHaveAttribute(
"src",
"https://cdn.example.com/cat.png",
);
});
it("strips non-image data URIs (data:text/html)", () => {
render(
<MarkdownBase>{"![x](data:text/html,<script>alert(1)</script>)"}</MarkdownBase>,
);
const img = screen.getByAltText("x");
expect(img.getAttribute("src") ?? "").toBe("");
});
});

View File

@@ -562,6 +562,40 @@ describe("ReadonlyContent file-card → AttachmentBlock HTML routing", () => {
});
});
describe("ReadonlyContent inline data-URI images", () => {
// Issue comments render through ReadonlyContent, which has its own sanitize
// schema + urlTransform separate from the base Markdown component. Agents
// inline auth QR codes as `![](data:image/png;base64,...)`; both gates used
// to strip the src and surface a broken image (MUL-3961).
const PNG_1X1 =
"data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
function renderWithQuery(ui: ReactElement) {
const qc = new QueryClient({
defaultOptions: { queries: { retry: false, gcTime: 0 } },
});
return render(<QueryClientProvider client={qc}>{ui}</QueryClientProvider>);
}
it("preserves the src of an inline data:image/png image", () => {
const { container } = renderWithQuery(
<ReadonlyContent content={`![QR Code](${PNG_1X1})`} />,
);
expect(container.querySelector("img")?.getAttribute("src")).toBe(PNG_1X1);
});
it("strips non-image data URIs (data:text/html)", () => {
const { container } = renderWithQuery(
<ReadonlyContent content={"![x](data:text/html,<script>alert(1)</script>)"} />,
);
// The value allow-list rejects non-image data URIs, so no usable src reaches
// the <img>. AttachmentRenderer still mounts an <img>, but with an empty src.
expect(container.querySelector("img")?.getAttribute("src") ?? "").toBe("");
});
});
describe("ReadonlyContent slash command rendering", () => {
it("renders slash skill links as slash command pills", () => {
const { container } = render(

View File

@@ -76,6 +76,10 @@ const sanitizeSchema = {
protocols: {
...defaultSchema.protocols,
href: [...(defaultSchema.protocols?.href ?? []), "mention", "slash"],
// Permit inline data-URI images (QR codes, charts, base64 screenshots).
// The scheme gate only allows `data:` through here; attributes.img below
// narrows it to image/* so non-image data URIs are still rejected.
src: [...(defaultSchema.protocols?.src ?? []), "data"],
},
attributes: {
...defaultSchema.attributes,
@@ -92,8 +96,17 @@ const sanitizeSchema = {
["className", /^hljs/],
],
img: [
...(defaultSchema.attributes?.img ?? []),
// Drop the default plain `src` entry so the value allow-list below is the
// one findDefinition resolves — it returns the first match by name, so a
// bare `src` string would otherwise shadow (and disable) the allow-list.
...(defaultSchema.attributes?.img ?? []).filter(
(attr) => (typeof attr === "string" ? attr : attr[0]) !== "src",
),
"alt",
// Allow inline data:image/* URIs while leaving every other src form
// (http/https/site-relative) exactly as before: the negative lookahead
// keeps all non-data values, and data: is narrowed to images only.
["src", /^data:image\//i, /^(?!data:)/i],
],
},
};
@@ -105,6 +118,11 @@ const sanitizeSchema = {
function urlTransform(url: string): string {
if (url.startsWith("mention://")) return url;
if (url.startsWith("slash://skill/")) return url;
// Allow inline data:image/* URIs — defaultUrlTransform strips every data: URL
// to '', which would blank the src even after rehype-sanitize keeps it. Kept
// in sync with the image/* narrowing in sanitizeSchema (protocols.src +
// attributes.img) so both gates agree on what a valid inline image is.
if (/^data:image\//i.test(url)) return url;
return defaultUrlTransform(url);
}