docs(webui): design for workspace render-token html preview fix

This commit is contained in:
m4
2026-08-08 13:36:05 +08:00
parent 0e9334d4cd
commit 4b3f4f101e
@@ -0,0 +1,98 @@
# Workspace HTML 渲染 Token(render token)— 设计
日期:2026-08-08
## 背景
HTML Preview(`?render=1` + sandbox iframe)上线后预览显示不正常。已在运行中的 dev server 上实证三层失败:
| 层 | 机制 | 实测结果 |
|---|---|---|
| L0 相对资源解析 | 文件路径在 query(`/api/workspace/file?path=dir/report.html`),页面里 `./style.css` 解析为 `/api/workspace/style.css` | 404 无此路由 |
| L1 isCrossOrigin | sandbox iframe(无 allow-same-origin)是 opaque origin,子请求必带 `Sec-Fetch-Site: cross-site` | 403(`workspace.ts` `isCrossOrigin`) |
| L2 会话 cookie | SameSite=Lax cookie 在跨站子请求中不发送 | 401(proxy) |
主文档能加载(iframe 导航是同源请求、带 cookie),但页面内所有相对 CSS/JS/图片全部失败。
**已排除的替代方案:**
- 仅把 URL 目录化(`.../<threadId>/dir/report.html` 不加凭证):只解 L0;要解 L1/L2 必须白名单 + 跳过 isCrossOrigin,等于任何拿到 threadId 的人可未登录读工作区,不可接受。
- iframe 加 `allow-same-origin`:`allow-scripts + allow-same-origin` 对同源内容等于无沙盒(脚本可读应用 cookie、操作父 DOM),安全性倒退,排除。
## 设计
### 1. 302 重定向铸造 token(`src/app/api/workspace/file/route.ts` 改动)
`GET /api/workspace/file?threadId=..&path=..&render=1` 且扩展名为 html、非 download 时:
- 现有全部检查不变(proxy 会话、isCrossOrigin、workspace 解析、safeResolve)。
- 通过后**不直接流式返回**,而是铸造 render token,返回 `302 Location: /api/workspace/render/<token>/<relpath>`,`Cache-Control: no-store`。
- `<relpath>` 逐段 `encodeURIComponent`(保留 `/` 分层)。
- iframe 跟随重定向后,文档最终 URL 路径段内含 token,相对资源解析自动带上同一前缀——三层问题一次全解。
- `download=1` 优先于 render(不跳转,行为不变);非 html 的 render=1 忽略(行为不变)。
- 客户端(`WorkspaceFileDialog.tsx` iframe src)**零改动**。
### 2. Render token 模块(新建 `src/lib/server/renderToken.ts`)
- `createRenderToken(threadId: string, ttlSeconds = 600): string`
- payload = `base64url(JSON({ t: threadId, e: expEpochSeconds }))`
- token = `payload + "." + base64url(HMAC-SHA256(payload, secret))`
- `verifyRenderToken(token: string): { threadId: string } | null`
- 验签(`timingSafeEqual`,长度预检)→ 解析 payload → 检查 `e > now`;任一步失败返回 null。
- 签名密钥:`authSecret()`;认证关闭时为进程期随机 secret(首次使用时 `crypto.randomBytes(32)` 生成并缓存)。重启使未过期 token 失效——可接受(TTL 仅 10 分钟)。
- 10 分钟 TTL:子资源在导航后立即加载;过期后动态加载的资源失败,重新打开预览即可。
### 3. Token 渲染路由(新建 `src/app/api/workspace/render/[token]/[...path]/route.ts`)
`GET /api/workspace/render/<token>/<relpath...>`:
- **无会话检查、无 isCrossOrigin**——token 本身就是凭证。
- `verifyRenderToken` 失败 → 403 JSON。
- 用 token 中的 threadId 走服务端 registry 解析 workspace:将 `resolveConversationWorkspace(request)` 重构出 `resolveWorkspaceForThread(threadId)`,原函数改为从 query 取 threadId 后调用它(行为不变)。
- `safeResolve(filesDir, relpath)` containment 不变;必须存在的普通文件。
- 响应头:
- 头部逻辑复用 `fileResponseHeaders(ext, fileName, { render: ext === "html" })`——token 路由内 html 一律按 render 模式(`text/html` + CSP `sandbox allow-scripts`),使多页报告(html 相对链接到 html)可用;css/js/图片用已修正的 MIME + CSP `sandbox` + `nosniff`。
- 固定头:`Referrer-Policy: no-referrer`(防止 token 经 Referer 泄漏给页面引用的外部 CDN)、`Cache-Control: no-store`、`X-Content-Type-Options: nosniff`。
- 流式返回 + `Content-Length`,与 file 路由一致。
### 4. proxy 白名单(`src/proxy.ts`)
`isPublicPath` 增加前缀匹配 `pathname.startsWith("/api/workspace/render/")`。token 路由自行验签,安全不由 proxy 承担。
### 5. 重构点汇总
- `conversationWorkspace.ts`:抽出 `resolveWorkspaceForThread(threadId: string)`(原函数主体),`resolveConversationWorkspace(request)` 变为薄封装。
- file 路由 GET 的 render 分支:由"流式返回"改为"302 + token"。
## 安全边界
- token 绑定 threadId、HMAC 签名、10 分钟过期;验签用常量时间比较。
- `safeResolve` 防路径穿越;符号链接、隐藏目录检查不变。
- `Referrer-Policy: no-referrer` 加在 token 路由所有响应上(关键是 html 文档——它决定其子请求的 Referer 行为)。
- iframe 侧 `sandbox="allow-scripts"` 与服务端 CSP 双重限制不变。
- token 出现在 iframe URL 中、对用户可见——是用户自己的工作区文件,与现有预览信任级别一致。
- 认证关闭部署:proxy 全放行,但 token 路由仍要求有效 token(随机 secret),isCrossOrigin 豁免仅限该路由。
## 错误处理
- token 无效/过期 → 403 JSON;重新打开预览即获得新 token。
- 文件不存在/非普通文件/穿越 → 400/404 JSON,与 file 路由一致。
- workspace 不可用(thread 不存在、scope 删除等)→ 沿用 `ConversationWorkspaceError` 状态码。
## 已知限制
- html 中**绝对路径**资源(`/foo.css`、CDN 外链以外的站内绝对引用)无法映射到 token 路由——保持现状,提示用相对路径。
- token 过期后页面的懒加载资源失败;重新打开预览解决。
## 测试
- `src/lib/server/renderToken.test.ts`:roundtrip、篡改签名失败、过期失败、畸形 token 失败。
- token 路由测试(参照现有路由测试模式,`vi.mock("server-only")`):
- 有效 token + html → 200 `text/html`、CSP `sandbox allow-scripts`、`Referrer-Policy: no-referrer`
- 有效 token + css → `text/css`;+ js → `text/javascript`
- 无效/过期 token → 403
- `..` 穿越 → 400
- file 路由测试扩展:
- `render=1` + html → 302,Location 匹配 `/api/workspace/render/<token>/<relpath>` 且 token 可验签
- `render=1&download=1` → 不跳转(attachment 原行为)
- 非 render 行为不变
- 手动验证:打开含相对 CSS/JS/图片与 plotly 的 HTML → 样式/交互正常;多页 html 相对跳转可用;10 分钟后子资源 403,重新打开恢复。