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,204 @@
1
+ # html-preview-sandbox 使用文档(中文)
2
+
3
+ > 安全地渲染不可信 HTML,同时保留它的交互能力。
4
+ > English docs: see [README.md](../README.md) and [docs/INTEGRATION.md](INTEGRATION.md).
5
+
6
+ ## 这是什么
7
+
8
+ `html-preview-sandbox` 是一条**不可信 HTML 的安全预览管线**。当你的应用需要展示来路不明的 HTML——AI 生成的报告、聊天/邮件附件、用户上传的文件——直接用 `innerHTML` 或裸 `<iframe>` 都有风险,而只做清洗又会剥掉脚本、丢失交互。本库把五层防御打包成开箱即用的方案:
9
+
10
+ ```
11
+ 输入 → 解码 → 清洗 → CSP 策略 → 桥接 → 沙箱 iframe
12
+ ```
13
+
14
+ 图表、动画和表单控件可以保留交互,但表单提交外传会被默认策略拦截;内容运行在不共享宿主来源的沙箱里,默认策略会阻断主要的攻击者可读外传通道,并把外链和可观测导航交给宿主决策。
15
+
16
+ **它不做什么**:不是浏览器(不浏览 URL)、不是附件系统(下载/缓存/权限是你的事)、不是 DOMPurify 替代品(清洗只是其中一层)。
17
+
18
+ ## 安装
19
+
20
+ ```bash
21
+ npm install html-preview-sandbox
22
+ ```
23
+
24
+ - 仅提供 ESM 构建,要求 Node 18+
25
+ - 浏览器基线:Chrome/Edge 90+、Firefox 90+、Safari 14+、Electron 12+(详见 [BROWSER_SUPPORT.md](BROWSER_SUPPORT.md))
26
+
27
+ ## 快速上手
28
+
29
+ ```js
30
+ import { createPreview } from 'html-preview-sandbox';
31
+
32
+ const preview = createPreview(document.querySelector('#preview'), {
33
+ csp: 'strict',
34
+ // 外链回调:不提供则外链被丢弃(fail-closed)。这里用系统方式打开。
35
+ onOpenExternal(url) {
36
+ window.open(url, '_blank', 'noopener,noreferrer');
37
+ },
38
+ });
39
+
40
+ // 输入可以是 HTML 字符串,也可以是 File / Blob / ArrayBuffer / Uint8Array
41
+ await preview.render(htmlStringOrFile);
42
+ ```
43
+
44
+ 容器 `#preview` 需要有确定的尺寸(库会在里面塞一个撑满的 iframe)。
45
+
46
+ ## 核心 API
47
+
48
+ ### `createPreview(container, options)` → `PreviewHandle`
49
+
50
+ 在 `container`(一个 `HTMLElement`)里创建并管理沙箱 iframe。返回句柄:
51
+
52
+ | 方法 | 说明 |
53
+ |------|------|
54
+ | `render(input)` | 渲染输入,返回 `{ html, encoding, sanitizeReport }` |
55
+ | `updateOptions(patch)` | 局部更新选项;CSP/清洗类变更需重新 `render` 生效 |
56
+ | `notifyNavigationAttempt(url)` | 宿主(如 Electron 主进程)观察到 iframe 自我导航时调用,库会重挂载上次可信内容并走外链决策 |
57
+ | `destroy()` | 销毁 iframe、解绑监听 |
58
+ | `iframe` | 当前 iframe 元素(只读) |
59
+
60
+ ### 低层纯函数(服务端 / 自定义渲染)
61
+
62
+ ```js
63
+ import { sanitizeHtml, buildCsp, createHtmlDocument } from 'html-preview-sandbox';
64
+
65
+ sanitizeHtml(htmlString, options); // 只清洗,返回 { html, report }
66
+ buildCsp('strict'); // 返回 CSP 字符串
67
+ await createHtmlDocument(input, options); // 走完整管线但不建 iframe,返回最终 HTML 字符串
68
+ ```
69
+
70
+ `createHtmlDocument` 适合"要管线不要 iframe"的场景——比如你自己管理 webview,只想拿到处理好的安全 HTML。
71
+
72
+ ## 选项 `PreviewOptions`
73
+
74
+ | 选项 | 类型 | 默认 | 说明 |
75
+ |------|------|------|------|
76
+ | `csp` | `'strict'\|'balanced'\|'offline'\|CspPolicy` | `'strict'` | CSP 预设或自定义策略 |
77
+ | `sanitize` | `SanitizeOptions` | — | 清洗白名单覆盖(见下) |
78
+ | `maxBytes` | `number` | 100 MB | 超限触发 `onError`(code `OVERSIZED`) |
79
+ | `sandboxTokens` | `string[]` | 见下 | 覆盖 iframe sandbox 属性(逃生舱) |
80
+ | `allowUnsafeSandboxTokens` | `boolean` | `false` | 放行高危 token(`allow-same-origin` 等) |
81
+ | `externalProtocols` | `string[]` | `http/https/mailto/tel` | 外链协议白名单 |
82
+ | `allowExternalUrl` | `(url, ctx) => boolean` | — | 逐 URL 的自定义放行决策 |
83
+ | `injectScrollbarStyle` | `boolean` | `true` | 注入兜底滚动条样式 |
84
+ | `logger` | `Console` 子集 | — | 注入日志器 |
85
+
86
+ ### 事件回调
87
+
88
+ | 回调 | 触发时机 |
89
+ |------|----------|
90
+ | `onOpenExternal(url, ctx)` | 用户点外链 / `window.open` 时。**不提供则外链被丢弃** |
91
+ | `onCspViolation(report)` | 沙箱内发生 CSP 违例(可用于观测被拦了什么) |
92
+ | `onSanitize(report)` | 清洗完成,报告删了哪些标签/属性/协议 |
93
+ | `onNavigationAttempt(url, ctx)` | 宿主导航拦截触发(需宿主适配层) |
94
+ | `onError(err)` | 出错:`OVERSIZED` / `DECODE_FAILED` / `EMPTY_AFTER_SANITIZE` / `RENDER_FAILED` |
95
+
96
+ ## CSP 三预设(按"数据外传能力"分层)
97
+
98
+ 关键理念:预设不是按"能加载哪些资源"分,而是按"**不可信 HTML 能不能把数据发到攻击者可读的位置**"分。
99
+
100
+ | 预设 | 语义 | 适用 |
101
+ |------|------|------|
102
+ | `offline` | **零网络**,不进不出 | 合规 / 离线 / 隐私敏感 |
103
+ | `strict`(默认) | **阻断攻击者可读的外传通道**(`connect-src 'none'`、`form-action 'none'`、img/media 无通配主机);放行 inline 脚本、`unsafe-eval`、固定 CDN/字体主机 | 内容不可信(IM、邮件、AI 报告) |
104
+ | `balanced` | 开放 `https:` 图片/媒体 + `connect-src https:`,**存在通用外传面** | 内容半可信、需要远程资源或 fetch |
105
+
106
+ > ⚠️ 注意:`strict` **不是"零网络"**——白名单主机的请求仍会离开本机,残余低带宽侧信道见 [THREAT_MODEL.md](../THREAT_MODEL.md)。真正零网络请用 `offline`。
107
+
108
+ **自定义 CSP**:
109
+
110
+ ```js
111
+ createPreview(el, {
112
+ csp: {
113
+ preset: 'strict',
114
+ imgHosts: ['https://cdn.example.com'], // 追加图片主机(不打开通配)
115
+ scriptHosts: ['https://my.cdn.com'], // 追加脚本白名单
116
+ // directives: { 'connect-src': 'https://api.example.com' } // 逃生舱:直接覆盖,风险自负
117
+ },
118
+ });
119
+ ```
120
+
121
+ ## 自定义清洗 `SanitizeOptions`
122
+
123
+ ```js
124
+ createPreview(el, {
125
+ sanitize: {
126
+ allowScripts: true, // 默认 true;false 则退化为"只清洗不跑脚本"
127
+ allowInlineEvents: true, // 默认 true,仅放行用户主动触发型(onclick 等),自动触发型(onload)始终剥除
128
+ extraTags: ['my-widget'], // 追加标签白名单
129
+ extraAttributes: { 'my-widget': ['data-config'] },
130
+ extraSchemes: ['app'], // 追加协议白名单
131
+ dropTags: ['canvas'], // 从默认白名单移除
132
+ },
133
+ });
134
+ ```
135
+
136
+ ## 场景示例
137
+
138
+ ### 预览用户上传的 HTML 文件
139
+
140
+ ```js
141
+ fileInput.addEventListener('change', async (e) => {
142
+ const file = e.target.files[0];
143
+ await preview.render(file); // 直接传 File,库内部解码
144
+ });
145
+ ```
146
+
147
+ ### 预览 AI 生成的报告(需要 CDN 图表库)
148
+
149
+ ```js
150
+ // strict 默认已放行主流 CDN(cdnjs/jsdelivr/tailwind/jquery),多数报告可直接跑。
151
+ const preview = createPreview(el, { csp: 'strict', onOpenExternal });
152
+
153
+ // 仅当报告需要「远程图片/媒体」或「fetch 外部 API」时,才切 balanced
154
+ // (balanced 开放了 img-src https: 和 connect-src https:,存在通用外传面)。
155
+ // const preview = createPreview(el, { csp: 'balanced', onOpenExternal });
156
+ ```
157
+
158
+ ### 观测安全在工作(拦截可视化)
159
+
160
+ ```js
161
+ createPreview(el, {
162
+ onSanitize: (r) => console.log('清洗删除:', r.removedTags, r.removedSchemes),
163
+ onCspViolation: (r) => console.log('CSP 拦截:', r.effectiveDirective, r.blockedURI),
164
+ onOpenExternal: (url) => console.log('外链请求:', url),
165
+ });
166
+ ```
167
+
168
+ ### Electron:补齐自我导航防御
169
+
170
+ 纯 Web 拦不住 iframe 里的 `window.location = ...`(`Location` 是 `[Unforgeable]`)。Electron 主进程能观察到所有 frame 导航,可补齐这一层:
171
+
172
+ ```js
173
+ // 主进程:监听导航 → IPC 通知渲染层
174
+ win.webContents.on('will-frame-navigate', (details) => {
175
+ if (!details.isMainFrame) win.webContents.send('nav-attempt', details.url);
176
+ });
177
+ // 渲染层:
178
+ window.previewHost.onNavigationAttempt((url) => preview.notifyNavigationAttempt(url));
179
+ ```
180
+
181
+ 完整可运行示例见 [`examples/electron/`](../examples/electron/)。
182
+
183
+ ## 框架封装
184
+
185
+ - **Web Component**:`examples/web-component/` 里有一个 `<safe-html-preview>` 自定义元素,可直接照抄
186
+ - **React / Vue**:v0.1 暂未内置封装,可基于 `createPreview` 自行包一层(在 `useEffect`/`onMounted` 里 create、卸载时 `destroy`)
187
+
188
+ ## 安全边界(务必了解)
189
+
190
+ **防**:窃取宿主数据、危险协议、自动跳转、外链逃逸、常见 XSS 向量、默认 `strict` 下的主要攻击者可读外传通道。
191
+
192
+ **不防**:沙箱内的死循环/CPU 占用、钓鱼式内容、用户主动往表单填敏感信息、浏览器/运行时自身漏洞、未知的清洗器绕过。
193
+
194
+ 安全来自**五层纵深**,不承诺任何单层无懈可击。详见 [THREAT_MODEL.md](../THREAT_MODEL.md) 与 [SECURITY_MODEL.md](SECURITY_MODEL.md)。发现漏洞请走 [SECURITY.md](../SECURITY.md) 的私密披露渠道,不要开公开 issue。
195
+
196
+ ## 在线体验
197
+
198
+ 本地跑 Playground(粘贴 HTML → 看安全预览 + 实时拦截可视化):
199
+
200
+ ```bash
201
+ npm run build
202
+ npm run serve
203
+ # 打开 http://localhost:4173/playground/
204
+ ```
package/package.json ADDED
@@ -0,0 +1,86 @@
1
+ {
2
+ "name": "html-preview-sandbox",
3
+ "version": "0.1.0",
4
+ "description": "Preview untrusted HTML safely with DOMPurify, CSP, and sandboxed iframes.",
5
+ "type": "module",
6
+ "license": "MIT",
7
+ "author": "html-preview-sandbox contributors",
8
+ "homepage": "https://github.com/preview-sandbox/html-preview-sandbox#readme",
9
+ "repository": {
10
+ "type": "git",
11
+ "url": "git+https://github.com/preview-sandbox/html-preview-sandbox.git"
12
+ },
13
+ "bugs": {
14
+ "url": "https://github.com/preview-sandbox/html-preview-sandbox/issues"
15
+ },
16
+ "sideEffects": false,
17
+ "main": "./dist/index.js",
18
+ "module": "./dist/index.js",
19
+ "types": "./dist/index.d.ts",
20
+ "exports": {
21
+ ".": {
22
+ "types": "./dist/index.d.ts",
23
+ "browser": "./dist/index.browser.js",
24
+ "import": "./dist/index.js",
25
+ "default": "./dist/index.js"
26
+ },
27
+ "./browser": {
28
+ "types": "./dist/index.d.ts",
29
+ "import": "./dist/index.browser.js",
30
+ "default": "./dist/index.browser.js"
31
+ }
32
+ },
33
+ "files": [
34
+ "dist",
35
+ "docs",
36
+ "README.md",
37
+ "LICENSE",
38
+ "THREAT_MODEL.md",
39
+ "SECURITY.md",
40
+ "CHANGELOG.md"
41
+ ],
42
+ "scripts": {
43
+ "build": "tsup && tsc -p tsconfig.json",
44
+ "test": "npm run build && node --test test/*.test.js",
45
+ "test:browser": "npm run build && playwright test",
46
+ "pack:dry": "npm pack --dry-run",
47
+ "audit": "npm_config_cache=.npm-cache npm_config_logs_dir=.npm-cache/_logs npm audit --omit=optional",
48
+ "lint": "biome lint .",
49
+ "format": "biome format --write .",
50
+ "check:types": "tsc -p tsconfig.check.json",
51
+ "check": "npm run check:types && npm run lint && npm test",
52
+ "serve": "node scripts/serve.mjs"
53
+ },
54
+ "keywords": [
55
+ "html",
56
+ "preview",
57
+ "html-preview",
58
+ "safe-html",
59
+ "secure-preview",
60
+ "sandbox",
61
+ "sandboxed-iframe",
62
+ "iframe",
63
+ "csp",
64
+ "dompurify",
65
+ "security",
66
+ "untrusted-html",
67
+ "ai-generated-html"
68
+ ],
69
+ "engines": {
70
+ "node": ">=18"
71
+ },
72
+ "dependencies": {
73
+ "dompurify": "^3.4.11",
74
+ "jsdom": "^29.1.1"
75
+ },
76
+ "devDependencies": {
77
+ "@biomejs/biome": "^2.5.2",
78
+ "@playwright/test": "^1.61.1",
79
+ "electron": "^41.7.1",
80
+ "tsup": "^8.5.1",
81
+ "typescript": "^6.0.3"
82
+ },
83
+ "overrides": {
84
+ "esbuild": "^0.28.1"
85
+ }
86
+ }