Skip to content

Commit 0ed8d1c

Browse files
authored
fix(markdown): preserve SVG tags in code blocks (#707)
# Motivation The `Markdown` component converts markdown into HTML. For security reasons, the input is sanitized before transformation. The initial implementation was introduced [here](dfinity/nns-dapp#3399) and acknowledged this edge case. > It's not possible to use the HTML renderer since the SVG contains multiple tags. > One edge case remains unaddressed: if the SVG is inside the `<code>` tag, it will be rendered with &lt; and &gt; instead of "<" and ">." We need to address this use case now, as there is a mismatch between how the proposal is rendered and how it should be rendered: * https://nns.ic0.app/proposal/?u=qoctq-giaaa-aaaaa-aaaea-cai&proposal=138188 * https://dashboard.internetcomputer.org/proposal/138188 # Changes * Do not escape svg's inside code blocks. # Screenshots <img width="1013" height="847" alt="Screenshot 2025-08-27 at 15 42 30" src="https://github.com/user-attachments/assets/0c9ceb4e-1806-491d-bb17-99e68fa9b096" />
1 parent 68d1995 commit 0ed8d1c

File tree

2 files changed

+80
-3
lines changed

2 files changed

+80
-3
lines changed

src/lib/utils/markdown.utils.ts

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,42 @@ export const imageToLinkRenderer = (
4747

4848
const escapeHtml = (html: string): string =>
4949
html.replace(/</g, "&lt;").replace(/>/g, "&gt;");
50-
const escapeSvgs = (html: string): string =>
51-
html.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, escapeHtml);
50+
51+
const escapeSvgs = (html: string): string => {
52+
// Early exit if no SVGs to process
53+
if (!/<svg\b[^>]*>/i.test(html)) {
54+
return html;
55+
}
56+
// Early exit if no code blocks - just escape all SVGs
57+
if (!/```[\s\S]*?```|`[^`\n]+`/g.test(html)) {
58+
return html.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, escapeHtml);
59+
}
60+
61+
// Find all code blocks (both inline and fenced) and their positions
62+
const codeBlocks: Array<{ start: number; end: number }> = [];
63+
64+
// Match fenced code blocks (```...```)
65+
const fencedCodeRegex = /```[\s\S]*?```/g;
66+
let match;
67+
while ((match = fencedCodeRegex.exec(html)) !== null) {
68+
codeBlocks.push({ start: match.index, end: match.index + match[0].length });
69+
}
70+
71+
// Match inline code (`...`)
72+
const inlineCodeRegex = /`[^`\n]+`/g;
73+
while ((match = inlineCodeRegex.exec(html)) !== null) {
74+
codeBlocks.push({ start: match.index, end: match.index + match[0].length });
75+
}
76+
77+
// Helper function to check if a position is inside any code block
78+
const isInsideCodeBlock = (position: number): boolean =>
79+
codeBlocks.some((block) => position >= block.start && position < block.end);
80+
81+
// Replace SVGs that are NOT inside code blocks
82+
return html.replace(/<svg[^>]*>[\s\S]*?<\/svg>/gi, (svgMatch, offset) =>
83+
isInsideCodeBlock(offset) ? svgMatch : escapeHtml(svgMatch),
84+
);
85+
};
5286

5387
/**
5488
* Escape <img> tags or convert them to links
@@ -96,7 +130,6 @@ const proposalSummaryRenderer = (marked: Marked): Renderer => {
96130
export const markdownToHTML = async (text: string): Promise<string> => {
97131
// Replace the SVG elements in the HTML with their escaped versions to improve security.
98132
// It's not possible to do it with html renderer because the svg consists of multiple tags.
99-
// One edge case is not covered: if the svg is inside the <code> tag, it will be rendered as with &lt; & &gt; instead of "<" & ">"
100133
const escapedText = escapeSvgs(text);
101134

102135
// The dynamic import cannot be analyzed by Vite. As it is intended, we use the /* @vite-ignore */ comment inside the import() call to suppress this warning.

src/tests/lib/utils/markdown.utils.spec.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -150,5 +150,49 @@ describe("markdown.utils", () => {
150150
`<a href="image.png" target="_blank" rel="noopener noreferrer" type="image/png">title</a>`,
151151
);
152152
});
153+
154+
it("should escape SVGs in regular markdown text", async () => {
155+
const markdown = `Here's an SVG: <svg onload="alert('xss')"><circle/></svg>`;
156+
157+
const result = await markdownToHTML(markdown);
158+
159+
// SVG should be escaped for security
160+
expect(result).toContain("&lt;svg");
161+
expect(result).toContain("&lt;/svg&gt;");
162+
expect(result).not.toContain("<svg onload=\"alert('xss')\"");
163+
});
164+
165+
it("should escape SVGs in HTML blocks", async () => {
166+
const markdown = `<div>
167+
<svg xmlns="http://www.w3.org/2000/svg" onload="alert('xss')">
168+
<circle cx="50" cy="50" r="40"/>
169+
</svg>
170+
</div>`;
171+
172+
const result = await markdownToHTML(markdown);
173+
174+
// SVG should be escaped even in HTML blocks
175+
expect(result).toContain("&lt;svg");
176+
expect(result).toContain("&lt;circle");
177+
});
178+
179+
it("should preserve SVGs in code blocks as escaped text", async () => {
180+
const markdown = `
181+
\`\`\`xml
182+
<svg onload="alert('safe-in-code')">
183+
<circle cx="50" cy="50" r="40"/>
184+
</svg>
185+
\`\`\``;
186+
187+
const result = await markdownToHTML(markdown);
188+
189+
// SVG in code should be HTML-encoded for display as text
190+
expect(result).toContain("&lt;svg");
191+
expect(result).toContain("&lt;circle");
192+
expect(result).toContain("&lt;/svg&gt;");
193+
194+
// Should not contain executable SVG
195+
expect(result).not.toContain("<svg onload=\"alert('safe-in-code')\"");
196+
});
153197
});
154198
});

0 commit comments

Comments
 (0)