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.
- package/CHANGELOG.md +45 -0
- package/LICENSE +21 -0
- package/README.md +163 -0
- package/SECURITY.md +40 -0
- package/THREAT_MODEL.md +51 -0
- package/dist/bridge.d.ts +3 -0
- package/dist/decode.d.ts +12 -0
- package/dist/document.browser.d.ts +2 -0
- package/dist/document.d.ts +2 -0
- package/dist/errors.d.ts +12 -0
- package/dist/index.browser.d.ts +9 -0
- package/dist/index.browser.js +988 -0
- package/dist/index.browser.js.map +1 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +990 -0
- package/dist/index.js.map +1 -0
- package/dist/policy.d.ts +10 -0
- package/dist/renderer-core.d.ts +4 -0
- package/dist/renderer.browser.d.ts +1 -0
- package/dist/renderer.d.ts +1 -0
- package/dist/sanitize-core.d.ts +5 -0
- package/dist/sanitize.browser.d.ts +4 -0
- package/dist/sanitize.d.ts +4 -0
- package/dist/scrollbar.d.ts +2 -0
- package/dist/types.d.ts +84 -0
- package/docs/BRANCHING.md +94 -0
- package/docs/BROWSER_SUPPORT.md +42 -0
- package/docs/INTEGRATION.md +145 -0
- package/docs/PROJECT_STRUCTURE.md +121 -0
- package/docs/ROADMAP.md +184 -0
- package/docs/SANITIZER_DECISION.md +78 -0
- package/docs/SECURITY_MODEL.md +100 -0
- package/docs//344/275/277/347/224/250/346/226/207/346/241/243.zh-CN.md +204 -0
- package/package.json +86 -0
|
@@ -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.
|
package/docs/ROADMAP.md
ADDED
|
@@ -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.
|