diff --git a/scripts/build.mjs b/scripts/build.mjs index d18a8d99..47e7eea8 100644 --- a/scripts/build.mjs +++ b/scripts/build.mjs @@ -67,6 +67,7 @@ appendFileSync( // 4. Minify frontend assets run('minify input-cjk.js', 'npx esbuild dist/web/public/input-cjk.js --minify --outfile=dist/web/public/input-cjk.js --allow-overwrite'); +run('minify sanitize-html.js', 'npx esbuild dist/web/public/sanitize-html.js --minify --outfile=dist/web/public/sanitize-html.js --allow-overwrite'); run('minify app.js', 'npx esbuild dist/web/public/app.js --minify --outfile=dist/web/public/app.js --allow-overwrite'); run('minify terminal-ui.js', 'npx esbuild dist/web/public/terminal-ui.js --minify --outfile=dist/web/public/terminal-ui.js --allow-overwrite'); run('minify respawn-ui.js', 'npx esbuild dist/web/public/respawn-ui.js --minify --outfile=dist/web/public/respawn-ui.js --allow-overwrite'); @@ -90,6 +91,7 @@ console.log('\n[build] content-hash cache busting'); 'notification-manager.js', 'keyboard-accessory.js', 'input-cjk.js', + 'sanitize-html.js', 'app.js', 'terminal-ui.js', 'respawn-ui.js', diff --git a/src/web/public/app.js b/src/web/public/app.js index e5843288..cb63d649 100644 --- a/src/web/public/app.js +++ b/src/web/public/app.js @@ -1302,28 +1302,14 @@ class CodemanApp { /** Strip dangerous elements and attributes from HTML (XSS prevention) */ _sanitizeHtml(html) { - const tpl = document.createElement('template'); - tpl.innerHTML = html; - const frag = tpl.content; - for (const el of frag.querySelectorAll('script, iframe, object, embed, form, base, meta, link, style')) { - el.remove(); + if (typeof window !== 'undefined' && typeof window.sanitizeMarkdownHtml === 'function') { + return window.sanitizeMarkdownHtml(html); } - for (const el of frag.querySelectorAll('*')) { - for (const attr of [...el.attributes]) { - const name = attr.name.toLowerCase(); - if (name.startsWith('on')) { - el.removeAttribute(attr.name); - } else if (['href', 'src', 'action', 'xlink:href', 'formaction'].includes(name)) { - const val = attr.value.replace(/\s/g, '').toLowerCase(); - if (val.startsWith('javascript:') || val.startsWith('vbscript:') || val.startsWith('data:text/html')) { - el.removeAttribute(attr.name); - } - } - } - } - const div = document.createElement('div'); - div.appendChild(frag); - return div.innerHTML; + // Fail closed: DOMPurify unavailable — never return un-sanitized HTML. + return String(html == null ? '' : html) + .replace(/&/g, '&') + .replace(//g, '>'); } /** diff --git a/src/web/public/index.html b/src/web/public/index.html index 7f87cc52..47c61a65 100644 --- a/src/web/public/index.html +++ b/src/web/public/index.html @@ -39,6 +39,9 @@ + + @@ -1914,6 +1917,8 @@ + + diff --git a/src/web/public/sanitize-html.js b/src/web/public/sanitize-html.js new file mode 100644 index 00000000..78f7aba7 --- /dev/null +++ b/src/web/public/sanitize-html.js @@ -0,0 +1,165 @@ +/** + * @fileoverview Allowlist-based HTML sanitizer for markdown-rendered, agent/transcript-derived + * content that is subsequently assigned via innerHTML (response viewer, attachment markdown + * preview, message bodies). + * + * Security (COD-56): the previous sanitizer was a hand-rolled DENYLIST — it removed a fixed + * set of tags (script/iframe/object/embed/form/base/meta/link/style), stripped on* attrs and a + * few dangerous URL schemes, then re-serialized. Denylists are mXSS-prone: they did not strip + * `svg`/`math` (which carry their own foreign-namespace parsing rules and can smuggle script via + * namespace confusion), did not strip `style` attributes (CSS `expression()`/`url(javascript:)` + * on legacy engines), and had no positive allowlist, so any tag/attribute not explicitly named + * survived. `marked` runs with raw-HTML passthrough, so crafted HTML echoed by an agent flows + * straight into this function. + * + * This module replaces that with DOMPurify (Cure53), an allowlist sanitizer that is the + * industry standard for mXSS defense. It is configured to allow exactly the tag/attribute set + * that markdown rendering legitimately produces (headings, lists, code, blockquotes, links, + * tables, images with safe src) and to FORBID `style`/`svg`/`math` plus all event handlers and + * dangerous URL schemes. + * + * Cross-environment: in the browser this file runs as a classic ' }, + { name: 'svg/style mXSS', html: '' }, + { + name: 'math/mtext/table namespace confusion', + html: '', + }, + { name: 'style attr expression', html: '