From 4b3f4f101eca66583455bb6d62bf7c77bd85f0dc Mon Sep 17 00:00:00 2001 From: m4 Date: Sat, 8 Aug 2026 13:36:05 +0800 Subject: [PATCH] docs(webui): design for workspace render-token html preview fix --- ...026-08-08-workspace-render-token-design.md | 98 +++++++++++++++++++ 1 file changed, 98 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-08-workspace-render-token-design.md diff --git a/docs/superpowers/specs/2026-08-08-workspace-render-token-design.md b/docs/superpowers/specs/2026-08-08-workspace-render-token-design.md new file mode 100644 index 0000000..f3b1c1c --- /dev/null +++ b/docs/superpowers/specs/2026-08-08-workspace-render-token-design.md @@ -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 目录化(`...//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//`,`Cache-Control: no-store`。 +- `` 逐段 `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//`: + +- **无会话检查、无 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 可验签 + - `render=1&download=1` → 不跳转(attachment 原行为) + - 非 render 行为不变 +- 手动验证:打开含相对 CSS/JS/图片与 plotly 的 HTML → 样式/交互正常;多页 html 相对跳转可用;10 分钟后子资源 403,重新打开恢复。