# 审计 — 远程浏览器（Playwright Worker）

**Changelog**: `docs/changelog/2026-06-12-remote-browser-worker-deploy.md`  
**设计 SSOT**: `docs/design/specs/2026-06-11-server-browser-chromium-design.md`  
**范围**: 2026-06-12 新增/恢复的浏览器 Worker 全链路（未含 `web/app` 构建产物）

## 范围（文件清单）

| 文件 | 职责 |
|------|------|
| `browser-worker/Dockerfile` | Worker 镜像 |
| `browser-worker/docker-compose.yml` | Worker 编排 |
| `browser-worker/docker-entrypoint.sh` | headed + Xvfb 入口 |
| `browser-worker/requirements.txt` | Worker 依赖 |
| `browser-worker/config.py` | Worker 环境配置 |
| `browser-worker/main.py` | Worker HTTP/WS API |
| `browser-worker/session_manager.py` | Playwright + screencast |
| `browser-worker/browser_stealth.py` | 反自动化 / 验证码辅助 |
| `browser-worker/browser_url_safe.py` | SSRF（Worker 侧） |
| `server/main.py` | 注册 browser 路由 |
| `server/config.py` | `BROWSER_*` 与 TLS verify |
| `server/api/settings.py` | 设置项默认值（push_complete_sound） |
| `server/api/browser_session.py` | 桥接 REST + `/ws/browser` |
| `server/api/browser.py` | UI 状态 `/api/browser/state` |
| `server/infrastructure/browser/__init__.py` | 包入口 |
| `server/infrastructure/browser/worker_client.py` | 连 Worker（可配置 TLS verify） |
| `server/infrastructure/browser/session_registry.py` | 内存会话归属 |
| `server/utils/browser_url_safe.py` | SSRF（桥接层） |
| `server/utils/browser_ui_state.py` | 状态解析 |
| `frontend/src/App.vue` | 顶栏 + 固定 GlobalBrowserPanel |
| `frontend/src/router/index.ts` | `/browser` 路由 |
| `frontend/src/pages/ServersPage.vue` | 服务器页打开浏览器 |
| `frontend/src/pages/BrowserPage.vue` | 独立浏览器页（兼容） |
| `frontend/src/components/GlobalBrowserPanel.vue` | 固定顶栏下整窗 |
| `frontend/src/composables/useGlobalBrowser.ts` | 全局标签/状态 |
| `frontend/src/composables/useRemoteBrowserSession.ts` | canvas + WS |
| `tests/test_browser_url_safe.py` | SSRF 单测 |
| `tests/test_browser_ui_state.py` | UI 状态单测 |
| `tests/test_settings_sound_defaults.py` | 设置默认音单测 |

## API / WS 入口表

| 方法 | 路径 | 鉴权 |
|------|------|------|
| GET | `/api/browser/status` | JWT `get_current_admin` |
| POST | `/api/browser/sessions` | JWT |
| DELETE | `/api/browser/sessions/{id}` | JWT + 会话归属 |
| GET/PUT | `/api/browser/state` | JWT + `admin_ui_preferences` |
| WS | `/ws/browser/{session_id}?token=` | JWT + 会话归属 |
| GET | Worker `/health` | **无** |
| POST/DELETE | Worker `/v1/sessions*` | `X-Nexus-Worker-Key` |
| WS | Worker `/v1/sessions/{id}/stream` | Worker Key（Header） |

## Step 3 规则扫描

| ID | 规则 | 结论 |
|----|------|------|
| H1 | SSRF / 私网访问 | **RISK** — 见 F1、F2 |
| H2 | 鉴权 / IDOR | PASS — WS 校验 admin_id |
| H3 | 密钥不落库/不入 git | PASS — `.env` / 持久卷 |
| H4 | 无静默吞错 | **RISK** — 见 F4 |
| H5 | Worker 暴露面 | **RISK** — 见 F3 |
| H6 | 多 worker 一致性 | **RISK** — 见 F5 |
| H7 | UI 状态 URL 白名单 | PASS — `browser_ui_state` |
| H8 | 设计符合度（redirect） | **GAP** — 见 F1 |

## Closure（全表）

| ID | 判定 | 严重度 | 位置 | 说明 |
|----|------|--------|------|------|
| H1 | FINDING | **P1** | `browser-worker/session_manager.py` NAVIGATE 后 | **F1** 页内点击/重定向未逐跳 SSRF 校验；设计文档要求 redirect 后重验 |
| H1 | FINDING | **P1** | Worker 部署 ufw | **F3** 装机时额外 `ufw allow 8443/tcp`（全网），削弱「仅主站 IP」隔离 |
| H2 | SAFE | — | `browser_session.py:125-127` | `session_id` 必须属于当前 `admin.id` |
| H2 | SAFE | — | `browser_session.py:95-104` | 桥接层 NAVIGATE 二次校验 |
| H3 | SAFE | — | `worker_client.py`, Worker `.env` | `WORKER_SECRET` 仅环境变量 |
| H4 | FINDING | P2 | `worker_client.py:67-68` | **F4** `delete_worker_session` 网络失败仅 warning，可能残留 Worker 会话 |
| H5 | FINDING | P2 | `browser-worker/main.py:45-51` | `/health` 无认证；若 8443 对公网可见会泄露会话数 |
| H6 | FINDING | P2 | `session_registry.py` | **F5** 内存注册表不跨 uvicorn worker；多进程下 WS 可能 4403 |
| H7 | SAFE | — | `browser_ui_state.py` | http(s) only，无凭据 URL |
| H8 | SAFE | — | screencast 热修 | `cdp.on("Page.screencastFrame")` 已替代错误 API |
| — | SAFE | — | `browser_session.py:61-64` | 创建前清理陈旧会话，避免 409 |
| — | SAFE | — | 前端 | 不再 iframe；canvas 远程渲染 |
| — | SAFE | — | CUD 审计 | 首版未记 session 审计，与设计「与 RDP 一致」一致 |

### F1 — 重定向 / 页内导航 SSRF 缺口（P1）

`validate_navigation_url` 仅在显式 `NAVIGATE` 消息时调用。用户在远程 Chromium 内**点击链接**或 **302 跳转**到 `http://10.x` / metadata 时，Playwright 会照常请求，**不经过**校验。

**建议**：`page.on("framenavigated")` / `response` 监听，每跳对 `page.url` 调用 `validate_navigation_url`；失败则 `page.close()` 或 `about:blank` 并 WS `ERROR`。

### F3 — Worker 防火墙过宽（P1）

部署日志显示除 `from 20.24.218.235` 外还有 `ufw allow 8443/tcp`（Anywhere）。攻击者若猜到 `WORKER_SECRET`（或暴力）可直连 Worker。

**建议**：删除全网 8443 规则，仅保留主站源 IP；`/health` 改内网或加 Key。

### F4 — Worker 删除失败（P2）

`delete_worker_session` 异常时静默；依赖 Worker 空闲超时清理。

### F5 — 多 uvicorn worker（P2）

`session_registry` 进程内字典；生产若 `--workers>1` 可能 POST 在 worker A、WS 落到 worker B。当前 Docker 单进程可接受；扩 worker 时需 Redis 注册表。

## 外部输入 → Sink

| 输入 | Sink | 缓解 |
|------|------|------|
| `NAVIGATE.url` | Playwright `goto` | 双端 `validate_navigation_url` |
| 页内链接点击 | Playwright 导航 | **未缓解（F1）** |
| WS `MOUSE`/`KEY` | Chromium | 仅已认证管理员 |
| `browser/state` tabs URL | MySQL JSON | `_is_safe_url` 过滤 |
| Worker Key | Worker API | 常量时间比较（`!=`） |

## DoD

| 项 | 状态 |
|----|------|
| `pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py` | 7 passed（生产机）；本地沙箱 DNS 失败属环境限制 |
| `npm run type-check` | 已通过（实现日） |
| 生产 `GET /api/browser/status` | `enabled:true`, `worker_ok:true` |
| 生产 screencast 冒烟 | `SCREENCAST_OK` ~4KB JPEG |
| changelog | `docs/changelog/2026-06-12-remote-browser-worker-deploy.md` |

## 总结

| 级别 | 数量 | 处置建议 |
|------|------|----------|
| P0 | 0 | — |
| P1 | 2 | F1 redirect/页内 SSRF；F3 收紧 ufw |
| P2 | 3 | F4/F5 可排期；/health 加固 |

**合并结论**：功能链路可用，鉴权与显式 NAVIGATE SSRF 达标；**未达设计文档「每跳 redirect 校验」**，且 Worker **8443 应对公网收口**。建议先修 F3（运维），再实现 F1（代码）后复审计。

## 验证命令

```bash
venv/bin/pytest tests/test_browser_url_safe.py tests/test_browser_ui_state.py -q
cd frontend && npm run type-check
# 生产
curl -H "Authorization: Bearer $TOKEN" https://api.synaglobal.vip/api/browser/status
```
