# ADR-002: WebSocket Token 鉴权(走 Sec-WebSocket-Protocol) **状态**: ✅ 已采纳 **日期**: 2026-06-14 **决策者**: 宋献 + Claude 评审 **关联**: [[风险跟踪表]] 第十节 / 评审报告 `workbuddy-2026-06-14-P0安全.md` --- ## 1. 背景 WebSocket 鉴权原方案:`ws://server/ws/?token=` —— **token 在 URL 里**: - ❌ 被 nginx access_log 记录 - ❌ 被 CDN / 反代记录 - ❌ 被浏览器历史记录 **P0 漏洞**(H-11 风险项),已修复。 ## 2. 评估方案 | 方案 | 浏览器支持 | token 泄露 | 实施难度 | 结论 | |---|---|---|---|---| | **A. Authorization: Bearer header** | ❌ 浏览器 WS API 不支持自定义 header | ✅ 不泄 | 中 | ❌ 否决(浏览器限制) | | **B. Sec-WebSocket-Protocol: bearer.** | ✅ 现代浏览器都支持 | ✅ 不在 URL | 低 | ✅ **采纳** | | **C. 第一条消息传 token** | ✅ 全支持 | ⚠️ 需先开 WS 接受任意连接(无法鉴权) | 低 | ❌ 否决 | | **D. Cookie 自动带** | ✅ 全支持 | ⚠️ CSRF 风险 | 中 | ❌ 否决 | ## 3. 决策 **采纳 B 方案**: `Sec-WebSocket-Protocol: bearer.` 服务端协商 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 不留痕 - ⚠️ 旧客户端需更新(发版通知)