diff --git a/src/web/public/sanitize-html.js b/src/web/public/sanitize-html.js index 2f32670e..78f7aba7 100644 --- a/src/web/public/sanitize-html.js +++ b/src/web/public/sanitize-html.js @@ -123,8 +123,14 @@ // foreign-namespace roots and style so config drift can't silently re-admit them. FORBID_TAGS: ['style', 'svg', 'math', 'script', 'iframe', 'object', 'embed', 'form'], FORBID_ATTR: ['style'], - // No data: URIs except images; block the rest. SVG/MathML namespaces fully disabled. - USE_PROFILES: { html: true }, + // NOTE: do NOT set USE_PROFILES here. DOMPurify treats USE_PROFILES and + // ALLOWED_TAGS/ALLOWED_ATTR as mutually exclusive — when a profile is set it + // RESETS the allow-lists to the full profile and silently ignores the curated + // lists above, widening the tag set far beyond what markdown emits. Relying on + // the explicit ALLOWED_TAGS/ALLOWED_ATTR keeps the tight allowlist in force; + // FORBID_TAGS/FORBID_ATTR remain as defense-in-depth. DOMPurify still applies + // its default safe-URI handling (blocks javascript:/vbscript:, allows + // http/https/mailto/tel + data: only on image tags). ALLOW_DATA_ATTR: false, ADD_ATTR: [], RETURN_DOM: false, diff --git a/test/markdown-sanitizer.test.ts b/test/markdown-sanitizer.test.ts index 98a517b3..b9213c97 100644 --- a/test/markdown-sanitizer.test.ts +++ b/test/markdown-sanitizer.test.ts @@ -1,4 +1,3 @@ -// @vitest-environment jsdom /** * COD-56 — markdown HTML sanitizer (mXSS hardening). * @@ -8,46 +7,70 @@ * 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 under jsdom: + * 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, and that legitimate - * markdown-rendered HTML survives unchanged. + * 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. * - * RED demonstration: a faithful re-implementation of the OLD denylist sanitizer is included and - * asserted to LET payloads through — the gap this fix closes. - * - * No port / server needed; pure DOM logic in jsdom. + * 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'); -/** Build the SHIPPING sanitizer the way the browser does: vendored DOMPurify + sanitize-html.js, - * both evaluated inside the (jsdom) window so DOMPurify binds to a real DOM. */ +// 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 win = window as unknown as Record; - // Evaluate the vendored UMD in the jsdom global context (mirrors