# ADR-002: WebSocket Token 鉴权(走 Sec-WebSocket-Protocol)

**状态**: ✅ 已采纳
**日期**: 2026-06-14
**决策者**: 宋献 + Claude 评审
**关联**: [[风险跟踪表]] 第十节 / 评审报告 `workbuddy-2026-06-14-P0安全.md`

---

## 1. 背景

WebSocket 鉴权原方案:`ws://server/ws/?token=<JWT>` —— **token 在 URL 里**:
- ❌ 被 nginx access_log 记录
- ❌ 被 CDN / 反代记录
- ❌ 被浏览器历史记录

**P0 漏洞**(H-11 风险项),已修复。

## 2. 评估方案

| 方案 | 浏览器支持 | token 泄露 | 实施难度 | 结论 |
|---|---|---|---|---|
| **A. Authorization: Bearer header** | ❌ 浏览器 WS API 不支持自定义 header | ✅ 不泄 | 中 | ❌ 否决(浏览器限制) |
| **B. Sec-WebSocket-Protocol: bearer.<token>** | ✅ 现代浏览器都支持 | ✅ 不在 URL | 低 | ✅ **采纳** |
| **C. 第一条消息传 token** | ✅ 全支持 | ⚠️ 需先开 WS 接受任意连接(无法鉴权) | 低 | ❌ 否决 |
| **D. Cookie 自动带** | ✅ 全支持 | ⚠️ CSRF 风险 | 中 | ❌ 否决 |

## 3. 决策

**采纳 B 方案**: `Sec-WebSocket-Protocol: bearer.<token>`

服务端协商 subprotocol,客户端用第二个 subprotocol 传 token(浏览器 API `new WebSocket(url, [subprotocols])`)。

## 4. 实现

### 4.1 前端

```ts
// frontend-agent/src/composables/useWebSocket.ts
const ws = new WebSocket(wsUrl, [`bearer.${agentStore.token}`])
```

### 4.2 后端

```python
# backend/app/api/ws.py
subprotocol = request.headers.get("sec-websocket-protocol", "")
if subprotocol.startswith("bearer."):
    token = subprotocol[7:]
else:
    # 降级:Authorization header
    auth = request.headers.get("Authorization", "")
    if auth.startswith("Bearer "):
        token = auth[7:]
    else:
        # 降级:query param(已废,只用于兼容旧前端)
        token = request.query_params.get("token", "")
```

## 5. 降级路径

| 优先级 | 来源 | 用途 |
|---|---|---|
| 1 | Sec-WebSocket-Protocol | 标准(主) |
| 2 | Authorization: Bearer | Postman / 测试工具 |
| 3 | query `?token=` | 已废(留兼容) |

## 6. 风险与缓解

| 风险 | 缓解 |
|---|---|
| 浏览器 API 不支持 subprotocol | 现代浏览器(2020+)都支持,无问题 |
| 旧客户端不更新 | query param 降级仍可用,但提示更新 |
| nginx 仍记录 subprotocol | `location /ws/ { access_log off; }` 配合 |

## 7. 决策影响

- ✅ WS 鉴权修复,token 不再泄
- ✅ nginx access_log 关闭,旧 token 不留痕
- ⚠️ 旧客户端需更新(发版通知)
