- fs-tree: symlink 目标 stat 从串行改为 32 并发工作池(readdir 流关闭后统一 探测),symlink 密集/网络目标目录不再累加延迟;顺序与分类语义不变 - html-route: 解码不再重建反斜杠 UNC,统一重建为平台中性的 //server/share/... 前斜杠形态(win32/POSIX 均正确 resolve),从而删除 windowsPathStyle 平台信号 与 client isWindowsStylePath 助手;同时解决无 cwd 时正斜杠 UNC 的往返盲点 - 测试/文档同步;移除未使用的 isPosix
14 KiB
14 KiB
内置 HTML 预览器 + 内嵌网页浏览器(沙箱化)设计
日期:2026-08-11
状态:已批准,已实施
作者:opencode + 用户
当前版本:v0.4.3(main 35cde82)
目标版本:v0.5.0
1. 目标
在 dsh-better-sidebar 内按既有 builtins 模式新增两个内置能力:
html文件预览器:.html/.htm文件在侧边栏编辑器中默认以真实渲染预览(相对资源可加载),可切换代码编辑(CodeMirror HTML 高亮 + 保存)。browser浏览器 tab:+菜单新增「浏览器」页面,地址栏输入 URL 在沙箱 iframe 中浏览,支持后退/前进/刷新,URL 与标题按 tab 持久化。
安全问题为第一优先级(用户显式要求"注意安全问题"):所有渲染走不透明源沙箱 iframe,host 侧新增 /sidebar/html 路由带 CSP + 信任围栏 + cwd 边界。另按用户追加要求提供关闭沙箱的子设置(默认关闭该设置;开启需明确勾选,设置项与界面都带安全警告文案)。
2. 复用门(dsh-reuse-first)
NO EXISTING CAPABILITY:已搜索 DSH checkout(docs/*-catalog、apps/web、packages 源码——sidecar/iframe/webview/html preview/CSP 均无等价物;WorkspaceBrowser 只是目录树)与 dsh-external org hub 目录(browser/html/webview/iframe/sidecar/viewer/preview 均 0 命中)。全新实现,落点为本仓库 builtins(用户已确认)。
3. 威胁模型与安全基线
| 威胁 | 对策 |
|---|---|
恶意 HTML/网页读取 GUI 数据(localStorage、/sidebar/api、同源 cookie) |
iframe 一律 sandbox 且不含 allow-same-origin → 文档源为不透明源(null)。禁止 srcdoc(srcdoc 在无沙箱时继承父源),一律 route-src |
| 预览/浏览内容劫持 GUI 页面(top 导航) | 不含 allow-top-navigation / allow-top-navigation-by-user-activation |
| 表单 CSRF 打到本地服务 | html 预览器 iframe 不含 allow-forms;浏览器 tab 含 allow-forms(登录流必需,残余风险见 §9) |
| 浏览器 tab 访问内部地址(GUI 自身、localhost、127.0.0.0/8) | 地址栏导航前客户端阻断(browser.ts 纯函数);javascript:/data:/file: 等非 http(s) 一律拒绝 |
预览页脚本请求 /sidebar/api |
三层:不透明源 fetch 被 CORS 拒读;信任围栏拒 sec-fetch-site: cross-site;Origin: null 无法伪造同源 |
| 敏感信息泄露给被浏览站点 | iframe referrerPolicy="no-referrer";权限策略 allow=""(拒绝 camera/mic/geolocation 等一切策略特性) |
直接顶层打开 /sidebar/html/* URL(弹窗等) |
路由响应带 Content-Security-Policy: sandbox ...; object-src 'none',顶层同样被沙箱化 |
| 路径逃逸 | 路由复用 requireAbsolute + isWithin(cwd, path) + mediaLimit 上限 + 信任围栏 |
| 关闭沙箱(用户主动选择) | 每功能独立子设置(htmlViewerNoSandbox / browserNoSandbox,默认 false);设置文案 + 渲染时持久警告条双警告 |
4. 变更清单
4.1 host:/sidebar/html 路由(src/html-route.ts 新 + src/index.ts)
URL 采用路径编码(无 query)——WHATSG URL 解析对纯路径相对引用会丢弃 base 的 query,query 式 URL 下 ./style.css 会丢失 session 作用域:
/sidebar/html/<sessionId>/<绝对路径逐段 encodeURIComponent>
例:/sidebar/html/S/Users/me/proj/index.html
./style.css → /sidebar/html/S/Users/me/proj/style.css ✓
Windows: C:\Users\me\a.html → /sidebar/html/S/C%3A/Users/me/a.html
src/html-route.ts(无依赖纯模块,client 可导入不触发纯度门):encodeHtmlUrl(sessionId, path)、decodeHtmlUrl(pathname)(404 错前缀;400 空路径/双斜杠/畸形编码/缺 sessionId 或文件路径/空段)。src/index.ts:注册/sidebar/htmlprefix 路由(fence → GET 校验 → decode →requireAbsolute→isWithin(cwd)→ stat isFile +mediaLimit→ 读文件),响应头:content-type(新增.html/.htm→text/html到 MEDIA_TYPES)、cache-control: no-cache、X-Content-Type-Options: nosniff、Referrer-Policy: no-referrer、Content-Security-Policy: sandbox allow-scripts allow-popups allow-downloads allow-modals; object-src 'none'。- cwd 语义与媒体路由一致:session store 权威;会话附着前的首请求回退
process.cwd(),通常被isWithin拒绝(可接受)。
4.2 client:html 预览器(TextEditor.tsx 扩展 + builtins/viewers.tsx)
- descriptor:
{ id:'html', exts:['html','htm'], priority:0, fetchStrategy:'fsRead', settings.toggles:[htmlViewerNoSandbox] }。 - TextEditor:
markdown || html都显示预览/编辑切换;html 预览分支渲染:<iframe src={htmlUrl(scope,path)} sandbox="allow-scripts allow-popups allow-downloads allow-modals" referrerPolicy="no-referrer" allow="" />- 导出
HTML_IFRAME_SANDBOX常量(测试钉死);沙箱关闭时sandbox属性整体移除 + 渲染sandboxWarning警告条。 - 预览显示已保存文件(route-src),不反映未保存草稿(有意取舍,文档化)。
- 导出
4.3 client:browser tab(BrowserView.tsx 新 + browser.ts 新 + builtins/tabs.tsx + state.ts)
- state.ts:
SidebarState.nextBrowser(默认 1;sanitize 对缺失/畸形值回退 1,旧持久化布局继续加载);新 reducerpatchTab(state, tabId, {title?, path?})。 - tabs.tsx:
{ id:'browser', order:50, settings.toggles:[browserNoSandbox], createTab: 铸造 browser:<n> + nextBrowser 递增 }。 - browser.ts(无依赖纯模块):
isLoopbackHostname(localhost/::1/0.0.0.0/127/8);normalizeBrowserUrl(input, selfOrigin):- 无 scheme → 补
https://;://完整 URL 走解析后协议兜底;已知危险 scheme 前缀(javascript/data/file/about/...)直接拒绝;host:port形态(如example.com:8080)不被误判为 scheme。 - 拒绝顺序:协议 → 自身 origin 放行(GUI 自身可在侧边栏打开,沙箱不透明源兜底;须先于 loopback 判定,因其主机通常就是 loopback)→ loopback 阻止 → ok。
- 无 scheme → 补
- BrowserView:地址栏(后退/前进/刷新/输入/前往)+ iframe + 状态行;URL 经
patchTab持久化到tab.path/tab.title(hostname);历史栈仅记录地址栏导航(iframe 内部跳转跨域不可见,已知限制);无 URL 时显示起始页(不自动加载任何站点)。- iframe:
sandbox="allow-scripts allow-forms allow-popups allow-downloads allow-modals allow-popups-to-escape-sandbox"(导出BROWSER_IFRAME_SANDBOX;无 allow-same-origin / allow-top-navigation;allow-popups-to-escape-sandbox 供 OAuth 弹窗);沙箱关闭时移除属性 + 警告条。 - 常驻底部小字沙箱说明(
browserHint)。
- iframe:
4.4 设置(prefs-shared.ts + config.ts + SideCardSection 声明式渲染)
SidebarPrefs新增htmlViewerNoSandbox/browserNoSandbox(boolean,默认 false);SIDEBAR_PREFS_DEFAULTS与PrefsSchema同步;parsePrefs按字段校验回退。- 声明式设置:html viewer 行与 browser tab 行各自
settings.toggles,设置页自动渲染(父级关闭时隐藏),文案含「(不安全)」+ 后果说明。
4.5 其他
api.ts:htmlUrl(scope, path)=encodeHtmlUrl(sessionId, path)。icons.tsx:IconHtmlOutline16(文件 +‹/›)、IconGlobeOutline16(地球)。locales.ts:zh/en 双份新键(viewerHtml/browser/浏览器栏/阻止原因/沙箱提示/设置文案)。sidebar.module.css:sandboxWarning、editorHtml、.browser*系列。
5. 测试
| 文件 | 覆盖 |
|---|---|
| tests/html-route.spec.ts(新) | 编码/解码往返(POSIX/Windows/特殊字符)、相对解析保持同路由同 session、404/400 拒绝、遍历段留给 isWithin |
| tests/browser.spec.ts(新) | 补 https、host:port 不误判、危险 scheme/loopback 拒绝、自身 origin 放行、invalid |
| tests/sandbox-views.spec.tsx(新) | iframe sandbox 常量钉死(无 allow-same-origin/top-navigation)、route-src 非 srcdoc、referrer/allow 属性、沙箱关闭 → 无 sandbox 属性 + 警告条、markdown 分支回归、浏览器起始页不自动加载 |
| tests/builtins.spec.ts | 7 tab / 9 viewer 清单、html 扩展名匹配、browser createTab 铸造与 nextBrowser、两个 toggles 声明 |
| tests/unit.spec.ts | nextBrowser sanitize(缺失/畸形回退 1)、patchTab 更新/无操作、parsePrefs 新字段默认与校验 |
| tests/smoke.spec.ts | fenced routes 清单 + /sidebar/html |
6. 版本与文档
- 版本 0.4.3 → 0.5.0(package.json + dsh.plugin.json 同步,manifest-consistency 守卫)。
- README/AGENTS 内置清单更新为 7 tab + 9 viewer。
7. 验收标准
pnpm typecheck && pnpm build && pnpm test全绿(245 用例)。- 手动:打开
.html默认预览渲染(相对资源可加载),可切编辑/保存;+菜单浏览器 tab 多开、地址栏浏览、后退/前进/刷新、刷新页面恢复 URL 与标题。 - 安全:预览/浏览 iframe 无 allow-same-origin/top-navigation;
javascript:/data:/file:/localhost/127.x 均被阻止并提示(自身 origin 允许);/sidebar/html越界 403、超限 400、跨站 403;预览页 fetch/sidebar/api读不到(CORS),预览器表单被沙箱禁(无 allow-forms)。 - 关闭沙箱开关:设置页勾选后界面出现持久警告条,iframe 无 sandbox 属性。
8. 实施偏差记录
- 设计初期考虑过 srcdoc +
<base>注入以实现"预览反映未保存草稿",因 srcdoc 无沙箱时继承父源的致命风险与 base 注入的脆弱性,改采 route-src(预览 = 已保存文件);相对资源经路径编码 URL 天然解析,无需内容改写。 normalizeBrowserUrl的 scheme 判别:朴素正则会把example.com:8080误判为 scheme,最终采用「://完整 URL + 危险 scheme 黑名单 + 解析后协议兜底」三段式。nextBrowser在 sanitize 中为宽松字段(缺失/畸形回退 1),避免旧持久化布局整体失效(与严格校验的nextTerminal不同)。- UNC 路径编码标记(PR #134 整合 #79/#115 时):
/sidebar/html对\\server\share\...///server/share/...采用 sessionId 后的//标记,且解码统一重建为前斜杠//server/share/...而非反斜杠——node:path在 win32/POSIX 上分别解析为\\server\share\...//server/share/...,因此无需任何平台信号;原「400 双斜杠」约束改为「首个空段仅作 UNC 标记,其余空段仍 400」。
8.1 追加需求(2026-08-11,v0.5.0 内)
- 设置页滚动:
SideCardSection.module.css的.section填满设置壳 options 区并内部滚动(height:100% + min-height:0 + overflow-y:auto),7 tab + 9 viewer 卡片再多也不会被裁切。 - 沙箱状态实时显示 + 临时解锁:新增
SandboxStatusBar(HTML 预览与浏览器 tab 共用)——沙箱开启显示绿色状态 + 「临时解锁(不安全)」按钮;关闭(全局设置或本页临时解锁)显示红色警示(state-error-primary色系 + 危险文案)与「恢复沙箱」按钮(仅临时态)。临时解锁是组件内 state,不写回全局设置,随页面/标签卸载失效;iframe 随沙箱翻转重挂载。 - 聊天/界面外链在侧边栏打开:新设置
browserInterceptLinks(默认开,受浏览器 tab 启用开关 gate)——link-intercept.ts在 document capture 阶段拦截 http(s) 外部链接点击(同源/非 http(s)/修饰键点击一律放行),经openTab({type:'browser', url, title: hostname})(openTab seed 新增url字段:落地后 patchTab 预填 path)在侧边栏打开。浏览器 tab 地址栏新增「在浏览器中打开」按钮(window.open(url,'_blank','noopener'),用户手势触发,等价真实浏览器新标签)。
8.2 追加需求(2026-08-11 二轮,v0.5.0 内)
- HTML「默认非安全」二级设置:
htmlViewerDefaultUnsafe(默认 false)——打开 HTML 文件时预览初始处于非沙箱状态(localUnlock 初始值取自该 pref),状态行仍可一键「恢复沙箱」;与htmlViewerNoSandbox(全局强制关闭,不可界面恢复)语义区分。 - 沙箱状态行单行化并取代底部说明:浏览器 tab 底部常驻
browserHint条移除,说明并入SandboxStatusBar开启态文案(「沙箱模式:已启用 · 页面无法访问界面数据与本地文件,登录态与第三方 Cookie 可能不可用」),状态行nowrap + ellipsis + title恒为一行;关闭态同样单行红色警示。 - 「在浏览器中打开」图标放大:
IconRightUpOutline14→IconRightUpOutline16(15px 渲染)。
8.3 追加需求(2026-08-11 三轮,v0.5.0 内)
- 图标改 15px:
<IconRightUpOutline16 size={15} />。 - 嵌入拒绝面板:站点通过
X-Frame-Options/ CSPframe-ancestors禁止嵌入时,浏览器 tab 不再显示空白拒绝框,而是显示BrowserEmbedBlocked面板——「{host} 拒绝了嵌入请求」+ 原因说明 + 「在浏览器中打开」(window.open)+ 「仍然加载」两个按钮。检测:新增 host 路由browser.probe(HEAD→GET 兜底,8s 超时,仅 http(s) 非 loopback,返回目标响应头的x-frame-options/frame-ancestors,受信任围栏保护)+ 客户端embeddabilityOf判定(XFO DENY/SAMEORIGIN 或 frame-ancestors 不含*→ blocked;探测失败 → unknown 保持普通 iframe)。探测在每次导航(含恢复的 path)时经useEffect(url)触发;「仍然加载」以forceEmbed保留普通 iframe。
9. 已知限制与残余风险
- 浏览器沙箱无登录态/第三方 Cookie 受限(SameSite=Lax 在跨站 iframe 不发送),部分站点登录需走弹窗(allow-popups-to-escape-sandbox 已开)。
- iframe 内部链接跳转不进后退栈(跨域不可见);被
X-Frame-Options/frame-ancestors拒绝的站点显示空白(站点策略,无法绕过)。 - 用户主动浏览的恶意站点仍可在沙箱内发起盲 GET(无法读取响应)到本地服务;站点重定向链可把 iframe 带到 localhost(内容仍沙箱化、受围栏保护)。
- 关闭沙箱后预览/浏览内容获得 GUI 同源权限(可读会话文件/本地存储/调内部接口)——仅建议对完全可信内容开启,设置与界面双重警告。