# 7-45 ONLYOFFICE live bridge session 隔离风险

## 状态

- 状态：done
- Owner：07-ai / OnlyOffice live bridge / mnote-web
- 发现时间：2026-05-31

## 现象

ONLYOFFICE live bridge session 当前是进程内全局状态。工具调用未显式传 `onlyofficeSessionId` / `bridgeSessionId` 时，会 fallback 到最近活跃 session；同一文档的 bridge session id 又由 `docKey` 派生，重复打开同文档或多用户/多 tab 打开不同 Office 文档时，存在命令落到错误 Office tab 或 token 覆盖的风险。

## 证据

- `rust/crates/mnote-web/src/routes/onlyoffice.rs` 生成 `bridgeSessionId = "mnote-oo-" + fileState.docKey`。
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs` 的 `register_session` 按 `session_id` 覆盖 `token`、`document_id`、`asset_id` 和 `last_seen_millis`。
- `rust/crates/mnote-web/src/routes/onlyoffice_bridge.rs` 的 `current_session_info()` 直接取全局 `last_seen_millis` 最大值。
- `rust/crates/mnote-web/src/hermes_tools/onlyoffice_live.rs` 的 `resolve_session_id()` 在未传 session id 时 fallback 到 `current_session_info()`。

## 影响

- Page AI 或 Hermes tool 可能在多 Office tab 场景写错目标文档。
- 同一文档重复打开可能互相覆盖 bridge token，使旧 tab 的插件长轮询或结果回传失效。
- 多用户共享同一 mnote-web 进程时，全局最近 session 可能跨用户泄漏目标选择。

## 最小复现建议

1. 打开两个不同 Office 文档，或同一文档两个 tab。
2. 不传 `onlyofficeSessionId` 调用 `mnote.onlyoffice.session.current` 与一个写工具，例如 `mnote.onlyoffice.document.insert_text`。
3. 观察返回 session 是否只由最近活跃 tab 决定。
4. 同一文档双 tab 复测 token 覆盖后，旧 tab `commands/next` 或 `results` 是否返回 401 / 无结果。

## 修复建议

- bridge session id 加入 browser tab 级随机后缀，不只使用 `docKey`。
- Page AI run payload 必须携带当前 resource tab 的 explicit `onlyofficeSessionId`；写工具禁止默认 fallback 写入。
- `current_session` 只能作为只读诊断工具，不能作为写工具默认目标。
- session state 至少按 actor/session/document/resource 维度过滤。

## 本轮进展

- 2026-05-31：
  - `onlyoffice.rs` 已把 `bridgeSessionId` 从仅基于 `docKey` 改为 `docKey + bridgeSessionSalt`，避免同文档多 tab 共用同一个 bridge session id。
  - `onlyoffice_live.rs` 已禁止读/写 action fallback 到全局最近 session；读写工具必须显式传 `onlyofficeSessionId` / `bridgeSessionId`。
  - `onlyoffice.rs` 的本地 callback 已绑定 bridge `sessionId` + `token`，未认证本地写回返回 401 并保持原文件不变。
  - 已补 Rust 定点测试覆盖缺 explicit session 的读/写工具拒绝，以及未认证 local callback 拒绝。
  - 已补 `scripts/task515-onlyoffice-live-scope-http-smoke.js` 覆盖 HTTP 层缺 explicit session 返回 400。
  - 已补并通过 `scripts/task516-onlyoffice-bridge-multisession-browser-smoke.js`，在真实浏览器中验证 A/B 两个 bridge session 的 token 校验、command queue 和 result 回收互不串台，错误 token 返回 401。
- 2026-06-01：
  - 已补并通过 `scripts/task518-onlyoffice-real-iframe-session-scope-smoke.js`，在真实 ONLYOFFICE iframe / DocumentServer 下打开同一 docx 两个 tab 和另一个 docx，验证同文档双 tab 共享 `docKey` 但使用不同 `bridgeSessionId`，三个插件 session 均能注册并各自回收 `selection.get` command。
  - `task518` 同时覆盖显式传 B session 但 scope=A 时返回 403，以及 scope=B 授权 dry-run 返回 200，证明真实 iframe session 能被工具层按显式 session 和 resource scope 约束。
  - Page AI open editors snapshot / target package 已开始保留 Office `onlyofficeSessionId` / `bridgeSessionId`，服务端 `agentTargetPackage` 与 `aiAccessScope.allowedResourceIds` 已能保留 Office asset、session 和 `resource:onlyoffice:{documentId}:{assetId}` 候选；新增 Rust 定点测试 `hermes_client_run_body_preserves_onlyoffice_target_scope` 覆盖该合同。
  - ONLYOFFICE iframe 在设置 `__MNOTE_ONLYOFFICE_DEBUG__` 后会向父窗口发送 `mnote:onlyoffice-bridge-ready`，resource tab runtime 收到后事件驱动刷新 open editors snapshot；Page AI 发送前也会拒绝没有 `onlyofficeSessionId` 的 Office target，避免 bridge 未就绪时发起 run。
  - `task518` 已扩展并通过非 dry-run 写入落点验证：通过 `mnote.onlyoffice.document.insert_text` 向 Office B 写入唯一 marker，再通过真实 iframe bridge `document.export` 导出 A/B 内容，断言 B 包含 marker 且 A 不包含；截图保存到 `tmp/task518-onlyoffice-real-iframe-session-scope-smoke/screenshots/office-a-after-write.png` 和 `office-b-after-write.png`。
  - 已补并通过 `scripts/task523-page-ai-onlyoffice-real-target-session-smoke.js`：在真实文档页内打开 Office resource tab，等待 iframe bridge ready 后通过 Page AI target picker 选择 Office target，断言 `editorTarget` 与 `targetPackage` 均冻结真实 `onlyofficeSessionId` / `bridgeSessionId`，并断言无 `consoleErrors` / `networkFailures` / `httpErrors`。

当前 P0 安全阻断已覆盖“工具不能隐式读写全局最近 session”“callback 不能未认证写本地文件”“bridge session/token/queue 在浏览器 HTTP 层隔离”“真实 iframe 插件 session 显式授权边界”“Page AI run scope 不再被服务端压回当前页面 id”“Office bridge 未就绪时 Page AI 发送前拒绝”“真实 iframe 非 dry-run 写入不串台”和“真实 Page AI UI 从 Office iframe 目标取到 live `bridgeSessionId` 并冻结进 run payload”。

## 验收

- [x] Rust 单测覆盖页面生成同 docKey 双 session 随机 session id。
- [x] Rust 单测覆盖读/写工具缺 explicit session id 时拒绝。
- [x] Rust 单测覆盖本地 callback 缺 bridge session/token 时拒绝。
- [x] Browser smoke 覆盖两个 bridge session 的 token、command queue 与 result 隔离。
- [x] Browser smoke 覆盖两个真实 Office iframe tab 下插件注册、sessionId 隔离和 command 回收。
- [x] Rust 单测覆盖 Page AI Office target package / aiAccessScope 保留 session 与 resource scope。
- [x] 前端事件驱动刷新覆盖 ONLYOFFICE bridge ready 消息，发送前拒绝缺 session 的 Office target。
- [x] Browser smoke 覆盖两个真实 Office iframe tab 下非 dry-run 写入目标不串台。
- [x] Browser smoke 覆盖 Page AI target picker 从真实 Office iframe target 读取 live `onlyofficeSessionId` 并写入 run payload。
