fix(security): avoid SVG title DOM reparsing

Extract strict text-only SVG titles without reparsing model output as DOM, preserving sandboxed SVG rendering and safe accessibility labels.
This commit is contained in:
CI Test
2026-10-05 22:33:25 +01:00
parent 7fc7f7427f
commit 25a32e49a0
3 changed files with 36 additions and 15 deletions
+26 -8
View File
@@ -791,14 +791,32 @@ function renderSvgSandbox(source) {
const height = viewBox ? Number(viewBox[2]) : 9;
const ratio = Number.isFinite(width / height) && width > 0 && height > 0
? Math.max(0.5, Math.min(3, width / height)) : (16 / 9);
// XML parsing extracts text without inserting title markup into an HTML DOM.
let title = 'Visual explanation';
if (typeof DOMParser !== 'undefined') {
const svg = new DOMParser().parseFromString(cleaned, 'image/svg+xml');
if (!svg.querySelector('parsererror')) {
title = svg.querySelector('svg title')?.textContent?.trim() || title;
}
}
// Extract only a strict text-only SVG title. Do not reparse model output as DOM.
// Nested or malformed title markup falls back to the generic accessible label.
const decodeSvgTitleEntities = value => String(value || '').replace(
/&(?:#([0-9]+)|#x([0-9a-f]+)|(amp|lt|gt|quot|apos));/gi,
(entity, decimal, hex, named) => {
if (named) {
return { amp: '&', lt: '<', gt: '>', quot: '"', apos: "'" }[named.toLowerCase()];
}
const codePoint = Number.parseInt(decimal || hex, decimal ? 10 : 16);
if (
!Number.isInteger(codePoint)
|| codePoint < 0
|| codePoint > 0x10ffff
|| (codePoint >= 0xd800 && codePoint <= 0xdfff)
) {
return '\uFFFD';
}
return String.fromCodePoint(codePoint);
},
);
const titleMatch = /<title(?:\s[^<>]*)?>([^<>]*)<\/title\s*>/i.exec(cleaned);
const extractedTitle = titleMatch
? decodeSvgTitleEntities(titleMatch[1]).trim()
: '';
const title = extractedTitle || 'Visual explanation';
const csp = "default-src 'none'; img-src 'none'; media-src 'none'; font-src 'none'; style-src 'unsafe-inline'";
const srcdoc = `<!doctype html><html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="${csp}"><style>:root{${svgThemeCss()}}html,body{margin:0;min-height:100%;background:var(--bg);color:var(--fg);overflow:hidden}body{display:grid;place-items:center}svg{display:block;width:100%;height:100%;max-width:100%;background:var(--bg);color:var(--fg)}</style></head><body>${cleaned}</body></html>`;
return `<figure class="chat-svg-visual"><iframe class="chat-svg-preview" sandbox="" referrerpolicy="no-referrer" loading="lazy" title="${escapeHtml(title)}" style="aspect-ratio:${ratio}" srcdoc="${escapeHtml(srcdoc)}"></iframe></figure>`;
+4 -3
View File
@@ -61,11 +61,12 @@ const { extractThemeBootstrap } = require('./helpers/theme_bootstrap.cjs');
const valid = addMessage('user', 'In the document, edit this specific text (lines 1–2):\n```\nselected\n```\n\nInstruction: **Keep bold** and `code`');
const titles = [
{ source: '<svg xmlns="http://www.w3.org/2000/svg"><title>Valid &amp; safe</title></svg>', title: 'Valid & safe' },
{ source: '<svg><title>Nested <b>bold</b> &amp; text</title></svg>', title: 'Nested bold & text' },
{ source: '<svg><title>Numeric &#60;safe&#62; &#x26; sound</title></svg>', title: 'Numeric <safe> & sound' },
{ source: '<svg><title>Nested <b>bold</b> &amp; text</title></svg>', title: 'Visual explanation' },
{ source: '<svg><title>\" onload=\"parent.executed++ &lt;script&gt;</title></svg>', title: '\" onload=\"parent.executed++ <script>' },
{ source: '<svg><title>Malformed <b>nested</title ></svg>', title: 'Visual explanation' },
{ source: '<svg><title><script>parent.executed++</script><b onload="parent.executed++">nested</b></title></svg>', title: 'parent.executed++nested' },
{ source: '<svg><title><![CDATA["><img src=x onerror="parent.executed++">]]></title></svg>', title: '"><img src=x onerror="parent.executed++">' },
{ source: '<svg><title><script>parent.executed++</script><b onload="parent.executed++">nested</b></title></svg>', title: 'Visual explanation' },
{ source: '<svg><title><![CDATA["><img src=x onerror="parent.executed++">]]></title></svg>', title: 'Visual explanation' },
{ source: '<svg><title></title><img src=x onerror="parent.executed++"></svg>', title: 'Visual explanation' },
{ source: '<svg><title> </title></svg>', title: 'Visual explanation' },
{ source: '<svg><text>No title</text></svg>', title: 'Visual explanation' },
+6 -4
View File
@@ -7,15 +7,17 @@ from tests.helpers.document_source import document_source
_REPO = Path(__file__).resolve().parent.parent
def test_svg_title_extraction_uses_xml_text_without_html_assignment():
def test_svg_title_extraction_never_reparses_model_output_as_dom():
src = (_REPO / "static" / "js" / "markdown.js").read_text(encoding="utf-8")
render = src.split("function renderSvgSandbox(source)", 1)[1].split(
"function replaceRawSvgBlocks", 1
)[0]
assert "innerHTML" not in render
assert "parseFromString(cleaned, 'image/svg+xml')" in render
assert "textContent" in render
assert "parsererror" in render
assert "DOMParser" not in render
assert "parseFromString" not in render
assert "decodeSvgTitleEntities" in render
assert "([^<>]*)" in render
assert "Visual explanation" in render
def test_markdown_raw_html_sanitizer_checks_url_attr_edge_cases():