html-preview-sandbox 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,121 @@
1
+ # Project Structure
2
+
3
+ This repository is a single-package TypeScript project. The package publishes a Node/default build and a browser build from the same core pipeline.
4
+
5
+ ## Top-Level Layout
6
+
7
+ ```text
8
+ html-preview-sandbox/
9
+ src/ TypeScript source for the package
10
+ dist/ Generated build output, ignored by git
11
+ playground/ Local inspection workbench
12
+ examples/ Minimal host integration examples
13
+ fixtures/ Benign, malicious, and edge HTML inputs
14
+ test/ Node and browser regression tests
15
+ docs/ Architecture, security, and integration notes
16
+ .github/workflows/ CI checks
17
+ ```
18
+
19
+ ## Source Modules
20
+
21
+ ```text
22
+ src/index.ts Node/default package entry
23
+ src/index.browser.ts Browser package entry
24
+ src/types.ts Public API types
25
+ src/decode.ts Input normalization and charset handling
26
+ src/sanitize.ts Node DOMPurify + jsdom sanitizer entry
27
+ src/sanitize.browser.ts Browser DOMPurify sanitizer entry
28
+ src/sanitize-core.ts Shared sanitizer policy and reporting hooks
29
+ src/policy.ts CSP, sandbox, and external URL policies
30
+ src/bridge.ts Injected iframe bridge script
31
+ src/document.ts Node document assembly pipeline
32
+ src/document.browser.ts Browser document assembly pipeline
33
+ src/renderer-core.ts Shared renderer factory (iframe lifecycle, bridge, nav)
34
+ src/renderer.ts Default renderer export (injects Node document pipeline)
35
+ src/renderer.browser.ts Browser renderer export (injects browser document pipeline)
36
+ src/scrollbar.ts Optional scrollbar style injection
37
+ src/errors.ts Error codes and PreviewError
38
+ ```
39
+
40
+ The public package surface is exported through `src/index.ts` and `src/index.browser.ts`. Keep new public APIs in `src/types.ts` and export them from both entrypoints when they apply to both runtimes.
41
+
42
+ ## Runtime Flow
43
+
44
+ ```text
45
+ PreviewInput
46
+ -> normalizeInput()
47
+ -> sanitizeHtml()
48
+ -> buildCsp()
49
+ -> injectCspMeta()
50
+ -> injectBridgeScript()
51
+ -> injectScrollbarStyle()
52
+ -> iframe.srcdoc
53
+ ```
54
+
55
+ Browser hosts usually call `createPreview()`. Node usage should call `createHtmlDocument()` or lower-level helpers.
56
+
57
+ ## Build Outputs
58
+
59
+ `npm run build` runs two steps: `tsup` bundles the ESM JavaScript, then `tsc`
60
+ emits the type declarations (tsup's dts pass is disabled because it injects a
61
+ deprecated `baseUrl` that TypeScript 6 rejects). It generates:
62
+
63
+ ```text
64
+ dist/index.js (tsup, Node/default)
65
+ dist/index.browser.js (tsup, browser)
66
+ dist/*.d.ts (tsc, per-module declarations; index.d.ts is the public entry)
67
+ dist/*.map
68
+ ```
69
+
70
+ Consumers reach the API through the `.` and `./browser` export conditions; the
71
+ per-module `.d.ts` files support the type graph but are not deep-importable by
72
+ package name.
73
+
74
+ Tests and local examples intentionally run against `dist` after build. This keeps development checks close to the published package behavior.
75
+
76
+ ## Test Layers
77
+
78
+ - `test/*.test.js`: Node tests for decoding, CSP, sanitizer behavior, package metadata, and document assembly.
79
+ - `test/browser/*.spec.js`: Playwright tests for browser iframe behavior, bridge forwarding, host navigation handling, external URL policy, and the example/Playground UIs (file upload, Web Component, sanitized-HTML view, shareable URL, drag-and-drop).
80
+ - `fixtures/`: stable inputs for benign, malicious, and edge cases.
81
+
82
+ ## Local Workbench
83
+
84
+ The Playground is a repository tool, not a published runtime API. It runs against `dist/index.browser.js` and exposes:
85
+
86
+ - sample payloads;
87
+ - drag-and-drop of a local HTML file into the editor;
88
+ - CSP preset switching;
89
+ - external protocol controls;
90
+ - host suffix URL filtering;
91
+ - sanitizer removal reports;
92
+ - CSP violation reports;
93
+ - external request reports;
94
+ - current policy summary;
95
+ - a "sanitized HTML" view of the exact pipeline output;
96
+ - a shareable URL that encodes the input + preset.
97
+
98
+ Run it with:
99
+
100
+ ```bash
101
+ npm run build
102
+ npm run serve
103
+ ```
104
+
105
+ Then open `http://localhost:4173/playground/`.
106
+
107
+ ## Publishing Shape
108
+
109
+ The npm package is intentionally small:
110
+
111
+ ```text
112
+ dist/
113
+ docs/
114
+ README.md
115
+ LICENSE
116
+ THREAT_MODEL.md
117
+ SECURITY.md
118
+ CHANGELOG.md
119
+ ```
120
+
121
+ Examples, fixtures, tests, and Playground stay in the repository for development and review, but they are not included in the package tarball.
@@ -0,0 +1,184 @@
1
+ # Roadmap
2
+
3
+ This document tracks what remains before the project is ready for a stronger public v0.1 release.
4
+
5
+ ## Current State
6
+
7
+ Implemented:
8
+
9
+ - single-package TypeScript source;
10
+ - generated ESM Node/default build;
11
+ - generated ESM browser build;
12
+ - generated TypeScript declarations;
13
+ - DOMPurify-backed sanitizer in browser and Node;
14
+ - CSP presets;
15
+ - sandboxed iframe renderer;
16
+ - injected bridge for link, `window.open`, and CSP violation events;
17
+ - external protocol and custom URL policy;
18
+ - high-risk sandbox token filtering;
19
+ - local Playground inspection workbench;
20
+ - Node tests, Playwright tests, CI, audit script, and pack dry-run;
21
+ - Biome linting (gated in CI) and formatter config.
22
+
23
+ ## Housekeeping
24
+
25
+ - **One-time Biome format pass.** The formatter is configured (`biome.json`) and
26
+ available via `npm run format`, but existing files have not been reformatted, to
27
+ keep substantive diffs clean. Apply `biome format --write` in a dedicated
28
+ `chore: apply Biome formatting` commit (no logic changes). Only after that pass,
29
+ (re)add a `format:check` script (`biome format .`) and gate it in CI — it is
30
+ intentionally omitted now so no shipped script fails against the un-formatted tree.
31
+
32
+ ## Priority 0
33
+
34
+ All three P0 items are done. The package is TypeScript-strict, has broad security
35
+ fixture coverage, and documents its browser baseline.
36
+
37
+ 1. **Tighten TypeScript strictness** — Done. `strict: true` in both `tsconfig.json`
38
+ and `tsconfig.check.json`; DOMPurify hook parameters are typed, caught errors are
39
+ normalized to `PreviewErrorShape`, and the suite passes under strict. The `jsdom`
40
+ module shim and the `any`-typed DOMPurify factory remain as small, deliberate
41
+ boundaries; replacing them with precise upstream types is a follow-up nicety, not
42
+ a blocker.
43
+
44
+ 2. **Expand security fixture coverage** — Done. Fixtures + regression tests now cover:
45
+ `base` tag injection, URL-targeting SVG animation, mXSS mutation vectors,
46
+ `formaction` override, nested `iframe`/`srcdoc`, obfuscated (entity/whitespace/case)
47
+ `javascript:` schemes, irregular `srcset` whitespace/descriptors, CSS `url()`
48
+ exfiltration (layered sanitize + CSP assertion), plus GBK/non-UTF-8 decoding, empty,
49
+ and malformed HTML edge cases.
50
+
51
+ 3. **Document browser compatibility** — Done. See [BROWSER_SUPPORT.md](BROWSER_SUPPORT.md):
52
+ baseline (Chrome/Edge 90+, Firefox 90+, Safari 14+, Electron 12+, Node 18+) with the
53
+ binding constraint (`Blob.arrayBuffer`) and the full list of platform features used.
54
+
55
+ ## Priority 1
56
+
57
+ These improve adoption and maintainability.
58
+
59
+ 1. **Integration examples**
60
+
61
+ Keep the package single-package, with repository examples:
62
+
63
+ - Done: string input (`examples/web/`).
64
+ - Done: File/Blob input via `<input type=file>` + drag & drop, with encoding /
65
+ sanitize-report / `OVERSIZED` error display (`examples/file-upload/`, covered by
66
+ Playwright smoke tests). Covers the attachment / upload / netdisk shape.
67
+ - Done: vanilla Web Component wrapper (`examples/web-component/`, smoke-tested).
68
+ - Done: Electron host navigation interception (`examples/electron/`, reference
69
+ code — runs with a separately installed `electron`).
70
+ - Done: Node `createHtmlDocument` (full pipeline, no iframe) for self-managed
71
+ webviews / SSR / CLI (`examples/node-create-document/`).
72
+ - Deferred: React/Vue examples (need a build step; Web Component already covers
73
+ the framework-agnostic entry) and a multiple-previews-per-page snippet. Low
74
+ priority — these are capability showcases, not core adoption entry points.
75
+
76
+ 2. **Improve CI signal**
77
+
78
+ Done: Dependabot (`.github/dependabot.yml`) and CodeQL static analysis
79
+ (`.github/workflows/codeql.yml`). The main CI workflow already runs a
80
+ `pack:dry` job.
81
+
82
+ Still to add:
83
+
84
+ - browser matrix expansion (Firefox/WebKit) once the compatibility target is
85
+ confirmed and those engines are verified to pass.
86
+
87
+ 3. **Package release workflow**
88
+
89
+ Done: tag-triggered publish workflow with npm provenance and a tag/version
90
+ consistency check (`.github/workflows/release.yml`); `CHANGELOG.md` in
91
+ Keep a Changelog format.
92
+
93
+ Still to decide:
94
+
95
+ - whether to adopt changesets/release-please for automated version bumps, or
96
+ keep manual CHANGELOG + tag (manual is fine while single-maintainer).
97
+
98
+ ## Priority 2
99
+
100
+ These are useful but not release blockers.
101
+
102
+ 1. **Playground polish**
103
+
104
+ Done:
105
+
106
+ - drag-and-drop an HTML file into the editor;
107
+ - "sanitized HTML" view — toggle to inspect the exact document the pipeline produced;
108
+ - shareable URL — the input + preset are encoded into the location hash and restored on load.
109
+
110
+ Still potential:
111
+
112
+ - copyable reports;
113
+ - saved sample presets;
114
+ - visual before/after diff for removed tags and attributes.
115
+
116
+ 2. **Policy presets**
117
+
118
+ Consider whether `strict`, `balanced`, and `offline` need aliases or additional presets for:
119
+
120
+ - AI-generated reports;
121
+ - fully offline attachments;
122
+ - internal trusted dashboards;
123
+ - teaching/demo mode.
124
+
125
+ 3. **Performance and size profiling**
126
+
127
+ Add benchmarks for:
128
+
129
+ - large HTML input normalization;
130
+ - sanitizer runtime;
131
+ - iframe render time;
132
+ - bundle size and dependency impact.
133
+
134
+ 4. **`jsdom` dependency footprint**
135
+
136
+ Verified: `jsdom` is **not** referenced in the browser build (`dist/index.browser.js`,
137
+ 0 occurrences), so it never reaches the final app bundle — a bundler resolving the
138
+ `browser` export condition never imports it. The only remaining cost is disk
139
+ footprint in `node_modules` for browser-only consumers who install via npm.
140
+
141
+ This is an install-hygiene improvement, not a runtime one. The candidate fix
142
+ (move `jsdom` to an optional peer dependency + lazily initialize it in the Node
143
+ entrypoint via `createRequire`, keeping the sync `sanitizeHtml` API and throwing a
144
+ clear error when absent) changes install semantics and touches the security-critical
145
+ sanitizer, so it should go through the normal review loop rather than a rushed change.
146
+
147
+ 5. **`EMPTY_AFTER_SANITIZE` behavior needs a proper definition (do not rush)**
148
+
149
+ DOMPurify's `WHOLE_DOCUMENT` mode wraps any input into an `html/head/body` shell,
150
+ so `sanitized.html.trim()` in `document.ts` is never blank and the
151
+ `EMPTY_AFTER_SANITIZE` error never fires. Content that sanitizes to nothing renders
152
+ as a blank preview instead.
153
+
154
+ The naive fix — `if (sanitized.report.strippedAll) throw ...` — is **wrong**, because
155
+ `strippedAll` currently keys off text content and a small tag set, so it would
156
+ misfire on content that is visually/interactively meaningful but text-free
157
+ (e.g. only `<canvas>`, `<svg>`, `<img>`, or `<input>`). Before changing behavior,
158
+ the "empty" concept must be split and each case decided explicitly:
159
+
160
+ - empty input;
161
+ - sanitized to no *visible* content;
162
+ - sanitized to no *interactive* content;
163
+ - only form/canvas/svg/media remain (no text).
164
+
165
+ Then decide per case whether to throw `EMPTY_AFTER_SANITIZE` or simply signal the
166
+ host via `sanitizeReport.strippedAll`. This is a dedicated design PR, not an inline fix.
167
+
168
+ ## Open Questions
169
+
170
+ - Should the package expose a lower-level policy builder for hosts that already own iframe rendering?
171
+ - Should Node sanitization remain a first-class path, or only support tests and document generation?
172
+
173
+ ## Resolved
174
+
175
+ - **Should `strict` allow remote images by default?** No. Presets are layered by
176
+ exfiltration capability: `strict` keeps `img-src`/`media-src` at `blob: data:`
177
+ (an image URL is an attacker-readable exfiltration channel). Remote `https:`
178
+ images/media live in `balanced` only. `offline` has no network at all.
179
+ - **Should the Playground become a hosted demo?** Yes. `.github/workflows/pages.yml`
180
+ deploys it to GitHub Pages on every push to `main` (assembling a site that mirrors
181
+ the Playground's relative imports). Requires enabling Pages (Source = GitHub Actions).
182
+ - **Renderer duplication.** The Node and browser renderers now share
183
+ `renderer-core.ts` via a factory; the entrypoints only inject their
184
+ `createHtmlDocument` implementation.
@@ -0,0 +1,78 @@
1
+ # Sanitizer Decision
2
+
3
+ ## Decision
4
+
5
+ `html-preview-sandbox` uses **DOMPurify** as the sanitizer for v0.1.
6
+
7
+ `sanitize-html` was considered, but is not used in the current implementation.
8
+
9
+ ## Context
10
+
11
+ This project previews untrusted HTML inside a browser-like environment. The sanitizer must therefore match browser parsing behavior as closely as practical, support hostile HTML/SVG inputs, and work as one layer in a larger sandbox pipeline.
12
+
13
+ The pipeline is:
14
+
15
+ ```text
16
+ input -> decode -> sanitize -> CSP policy -> bridge -> sandboxed iframe
17
+ ```
18
+
19
+ The project is not a general CMS content cleaner and not a server-only HTML transformation library.
20
+
21
+ ## Decision Criteria
22
+
23
+ The sanitizer choice was judged against these criteria:
24
+
25
+ - **Browser fidelity:** the preview is eventually rendered by a browser iframe, so browser DOM behavior matters.
26
+ - **XSS focus:** the default threat model is untrusted HTML with script, SVG, URL, parser, and mutation-style attack surfaces.
27
+ - **Client runtime:** the package must run naturally in browser applications without requiring a server.
28
+ - **Node support:** tests and server-side document generation still need a Node path.
29
+ - **Hooks and reports:** the project needs policy hooks and a removal report for the Playground and host callbacks.
30
+ - **Maintenance signal:** security-sensitive dependencies should have clear active maintenance and security posture.
31
+
32
+ ## Why DOMPurify
33
+
34
+ DOMPurify is browser-first and DOM-based. Its public documentation describes it as an XSS sanitizer for HTML, MathML, and SVG, and explains that it uses browser-provided technologies as the basis of the filter. That matches this project's main runtime: previewing HTML that will be parsed and executed by a browser engine.
35
+
36
+ DOMPurify also supports Node when paired with jsdom. That lets the project use the same sanitizer family in both entrypoints:
37
+
38
+ - Browser entrypoint: DOMPurify with the real browser `window`.
39
+ - Node/default entrypoint: DOMPurify with jsdom.
40
+
41
+ This keeps policy behavior easier to reason about than maintaining unrelated sanitizers for browser and Node.
42
+
43
+ ## Why Not sanitize-html for v0.1
44
+
45
+ `sanitize-html` is useful when the primary job is server-side cleanup of user-submitted HTML using explicit tag and attribute allowlists. Its public repository describes that model and notes that it is built on `htmlparser2`.
46
+
47
+ That is not the best fit for this project's first release because:
48
+
49
+ - The main rendering target is a sandboxed browser iframe, not a server-rendered CMS field.
50
+ - Browser DOM parsing behavior is central to the security model.
51
+ - The project needs a browser-native path without making the browser build feel like an adaptation of a server sanitizer.
52
+ - A server-oriented sanitizer route was considered, but this project prioritizes browser-fidelity preview behavior and a fresh, general-purpose design.
53
+
54
+ As of July 5, 2026, the original `apostrophecms/sanitize-html` GitHub repository is archived and points to a monorepo. That does not make the package unusable by itself, but it weakens the maintenance signal for choosing it as the core of a new security-oriented package.
55
+
56
+ ## Consequences
57
+
58
+ Choosing DOMPurify means:
59
+
60
+ - Node usage depends on keeping jsdom current.
61
+ - Sanitization must stay one layer only; CSP, sandboxing, bridge filtering, and host navigation interception remain required.
62
+ - Project hooks must preserve this package's stricter preview policy on top of DOMPurify defaults.
63
+ - Tests must cover browser behavior, not only string-level sanitization.
64
+
65
+ ## Revisit Conditions
66
+
67
+ Revisit the sanitizer choice if:
68
+
69
+ - the package becomes primarily server-side;
70
+ - browser runtime support is no longer required;
71
+ - DOMPurify cannot support required policy hooks;
72
+ - jsdom becomes unsuitable for the Node entrypoint;
73
+ - a standardized browser Sanitizer API becomes mature enough for this project's compatibility target.
74
+
75
+ ## References
76
+
77
+ - [DOMPurify](https://github.com/cure53/DOMPurify)
78
+ - [sanitize-html](https://github.com/apostrophecms/sanitize-html)
@@ -0,0 +1,100 @@
1
+ # Security Model
2
+
3
+ `html-preview-sandbox` uses defense in depth. No single layer is treated as sufficient.
4
+
5
+ ## Layers
6
+
7
+ 1. **Sanitization**
8
+ DOMPurify-backed sanitization removes blocked tags, attributes, protocols, meta refresh, and user-provided CSP.
9
+ The Node entrypoint runs DOMPurify with jsdom; the browser entrypoint runs DOMPurify against the real browser `window`.
10
+
11
+ 2. **CSP**
12
+ A generated meta CSP limits network access, object embedding, form submission, and resource loading.
13
+
14
+ 3. **Sandboxed iframe**
15
+ The preview iframe uses `sandbox` without `allow-same-origin`, creating an opaque origin.
16
+
17
+ 4. **Bridge**
18
+ A small injected script forwards link clicks, `window.open`, and CSP violation reports to the host.
19
+
20
+ 5. **Host adapter**
21
+ Hosts may add stronger capabilities such as Electron navigation interception.
22
+
23
+ ## Default Sandbox
24
+
25
+ ```text
26
+ allow-scripts allow-forms allow-popups allow-popups-to-escape-sandbox allow-modals
27
+ ```
28
+
29
+ `allow-same-origin` and `allow-downloads` are intentionally omitted.
30
+
31
+ If callers provide custom sandbox tokens, the renderer filters high-risk tokens by default:
32
+
33
+ ```text
34
+ allow-same-origin allow-top-navigation allow-top-navigation-by-user-activation allow-downloads
35
+ ```
36
+
37
+ These tokens are preserved only when `allowUnsafeSandboxTokens` is explicitly enabled.
38
+
39
+ ## CSP Presets
40
+
41
+ The presets are layered by **exfiltration capability (egress)**, not by which resources they load (ingress). The core threat is not whether untrusted HTML can load a resource, but whether it can send data to a location the attacker can read.
42
+
43
+ - `offline`: no network at all. Inline scripts/styles and local `data:`/`blob:` resources only. Nothing enters or leaves.
44
+ - `strict`: default. Blocks attacker-readable exfiltration channels — `connect-src 'none'`, `form-action 'none'`, and no wildcard `img-src`/`media-src` (`blob: data:` only). It permits inline script, `unsafe-eval`, and fixed static CDN/font hosts, because in the presence of already-permitted inline scripts these add no attacker capability and provide no attacker-readable exfiltration sink. This is not zero network: requests to whitelisted hosts still leave the machine, leaving narrow low-bandwidth side channels (see `THREAT_MODEL.md` → Residual Risk).
45
+ - `balanced`: opens `https:` images/media and `connect-src https:`, which is a general-purpose exfiltration surface. For semi-trusted content only.
46
+
47
+ ## Default External Protocols
48
+
49
+ ```text
50
+ http: https: mailto: tel:
51
+ ```
52
+
53
+ Other protocols are blocked before reaching `onOpenExternal`.
54
+
55
+ Hosts can narrow this list with `externalProtocols`. They can also provide `allowExternalUrl(url, context)` for domain-level or product-level decisions. The custom callback is evaluated only after the protocol allowlist passes, and it must return `true` to allow the URL.
56
+
57
+ ## Important Limits
58
+
59
+ - Scripts are allowed by design, because the project exists to preserve useful HTML interactivity.
60
+ - `unsafe-inline` is allowed in CSP presets because the injected bridge and many self-contained reports rely on inline scripts.
61
+ - `unsafe-eval` is allowed in `strict` and `balanced` (not `offline`). Since inline script is already permitted, `unsafe-eval` adds no attacker capability; blocking it would only break chart libraries and similar generated reports without a security gain.
62
+ - `connect-src` is `none` in `strict` and `offline`, blocking the primary data-exfiltration channel despite script execution. `strict` additionally keeps `img-src`/`media-src` at `blob: data:` (no wildcard host) and `form-action 'none'` so there is no attacker-readable exfiltration sink.
63
+ - `allowExternalUrl` is fail-closed when provided: thrown errors and non-`true` results block the URL.
64
+ - Pure Web hosts cannot fully stop `window.location` navigation; stronger host adapters are required for that layer.
65
+
66
+ ## Known Trade-offs and Hardening Notes
67
+
68
+ These are deliberate design choices, not defects. They are low-risk given the
69
+ surrounding layers, but are documented so the trade-off is explicit and revisitable.
70
+
71
+ - **`frame-src blob:`** — allows script inside the sandbox to create `blob:` sub-iframes.
72
+ Low risk: `<iframe>` markup is stripped by the sanitizer, and any script-created
73
+ nested frame inherits the sandbox flags (no `allow-same-origin`), so it stays opaque
74
+ and cannot reach the host. Could be tightened to `frame-src 'none'` if no dependency
75
+ needs blob frames.
76
+ - **`allow-popups-to-escape-sandbox`** — in the default sandbox. In practice `window.open`
77
+ is overridden by the bridge and routed to `onOpenExternal`, so a real escaping popup is
78
+ hard to trigger from content. Kept as defense-in-depth belt so an approved "open in new
79
+ tab" reaches the host decision rather than opening a sandboxed blank page.
80
+ - **Bridge `postMessage` uses `targetOrigin: '*'`** — the payload is a URL or a CSP
81
+ violation report (not secret), and the host receiver verifies `event.source ===
82
+ iframe.contentWindow` before acting. Acceptable given the sandboxed opaque origin.
83
+
84
+ ## Why DOMPurify Is Not Enough
85
+
86
+ DOMPurify is a sanitizer. This project uses it as one layer, then adds CSP, sandboxing, link handling, and host integration. Users should not extract the sanitizer configuration and treat it as the whole security story.
87
+
88
+ ## Playground Coverage
89
+
90
+ The Playground is an inspection surface for this model. It renders through the built browser bundle and exposes sanitizer removals, CSP violations, external request forwarding, CSP presets, protocol filtering, and host suffix URL policy in one place. A "sanitized HTML" view also shows the exact document the pipeline produced, so the injected CSP meta, bridge, and stripped content can be inspected directly.
91
+
92
+ ## DOMPurify vs sanitize-html
93
+
94
+ The current implementation uses **DOMPurify**.
95
+
96
+ `sanitize-html` was considered, but the package currently favors DOMPurify because browser-side DOM parsing and mXSS hardening are important for this project. Node usage is supported by running DOMPurify with jsdom.
97
+
98
+ This choice can be revisited if future requirements prioritize a Node-only policy engine, but the v0.1 implementation and tests are DOMPurify-based.
99
+
100
+ See [Sanitizer Decision](SANITIZER_DECISION.md) for the detailed criteria and trade-offs.