/** * COD-56 — markdown HTML sanitizer (mXSS hardening). * * The response viewer / attachment preview render agent- and transcript-derived markdown to * HTML via `marked` (raw-HTML passthrough) and assign the result with innerHTML. The HTML must * be sanitized first. The original sanitizer (`_sanitizeHtml` in app.js) was a hand-rolled * DENYLIST and is mXSS-prone — it never stripped `svg`/`math`/`style`, so foreign-namespace and * CSS vectors survived. * * This suite drives the EXACT shipping artifacts: * - src/web/public/vendor/dompurify.min.js (the vendored sanitizer) * - src/web/public/sanitize-html.js (our allowlist config wired to DOMPurify) * * It runs in the DEFAULT node environment (it deliberately does NOT declare a per-file jsdom * environment) and constructs a jsdom window here, then binds the vendored DOMPurify to it. A * per-file jsdom environment externalizes node:fs/node:path under vite, which made this suite fail * to load when * run in isolation (it only survived the full CI run because an earlier node-env test happened to * pre-cache node:fs). Building the window in-test keeps fs/path native and the suite order-robust. * * It feeds a corpus of mXSS payloads (svg/math/style/namespace-confusion/event-handler) and * asserts the output carries NO script-executing constructs, that the curated allowlist is * actually enforced (non-markdown tags dropped), and that legitimate markdown-rendered HTML * survives unchanged. A faithful re-implementation of the OLD denylist is included and asserted to * LET payloads through — the gap this fix closes. * * No port / server needed. */ import { describe, it, expect, beforeAll } from 'vitest'; import { readFileSync } from 'node:fs'; import { join } from 'node:path'; import { JSDOM } from 'jsdom'; const publicDir = join(process.cwd(), 'src/web/public'); // One jsdom window shared by the shipping sanitizer (bound to its DOMPurify) and the old-denylist // reference impl (which needs a DOM `document`). const dom = new JSDOM('
'); const jsdomWindow = dom.window as unknown as Window & typeof globalThis; const jsdomDocument = jsdomWindow.document; /** Build the SHIPPING sanitizer the way the browser does: vendored DOMPurify (bound to our jsdom * window) + the EXACT CONFIG from sanitize-html.js — so the same allow/forbid lists are exercised * under vitest without a real browser. */ function loadShippingSanitizer(): (html: string) => string { const dompurifySrc = readFileSync(join(publicDir, 'vendor/dompurify.min.js'), 'utf8'); const sanitizeSrc = readFileSync(join(publicDir, 'sanitize-html.js'), 'utf8'); // dompurify.min.js is a UMD — evaluate it as CommonJS to obtain the factory (createDOMPurify), // then bind it to our jsdom window so DOMPurify sanitizes against a real DOM. const dpModule: { exports: unknown } = { exports: {} }; // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func new Function('module', 'exports', dompurifySrc)(dpModule, dpModule.exports); const factory = dpModule.exports as (win: unknown) => { sanitize: (h: string, c?: unknown) => string }; const DOMPurify = factory(jsdomWindow); // sanitize-html.js exposes createMarkdownSanitizer via its CommonJS export. const sanModule: { exports: { createMarkdownSanitizer?: (dp: unknown) => (html: string) => string } } = { exports: {}, }; // eslint-disable-next-line @typescript-eslint/no-implied-eval, no-new-func new Function('module', 'exports', sanitizeSrc)(sanModule, sanModule.exports); const create = sanModule.exports.createMarkdownSanitizer; if (typeof create !== 'function') throw new Error('createMarkdownSanitizer not exported'); const fn = create(DOMPurify); if (typeof fn !== 'function') throw new Error('sanitizeMarkdownHtml not wired'); return fn; } /** Faithful copy of the OLD denylist _sanitizeHtml (app.js pre-COD-56) — used only to prove RED. */ function oldDenylistSanitize(html: string): string { const tpl = jsdomDocument.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(); } 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 = jsdomDocument.createElement('div'); div.appendChild(frag); return div.innerHTML; } // mXSS / XSS payloads. Each must be neutralized by the shipping sanitizer. const PAYLOADS: { name: string; html: string }[] = [ { name: 'img onerror', html: '