FlawOpen

flawopen.com/Teardowns/cve-2026-71416-headroom-websocket-origin-hijacking

● CVE-2026-71416 · CVSS 8.8 · 高危
安全研究 · FlawOpen

CVE-2026-71416:Headroom WebSocket Origin 劫持

Headroom 未验证 WebSocket Origin,访问的网页可通过可达代理转发 LLM 请求。请升级至 0.35.0 或更高版本。

💡 通俗易懂的原理解析 (ELI5)

接待员不查访客证件,就让任何人进入私人服务台。Origin 就像访客证件:代理必须先验证它,才能打开可使用已存 API 密钥的通道。即使访客能到达服务台,门卫也要确认证件上的来源在允许名单中,不能只因对方敲门就放行。

核心概念与专有名词

WebSocket
由 HTTP Upgrade 请求启动的持久双向连接,可在两端持续交换消息。
Origin 请求头
浏览器发送的页面协议、主机和端口,用于标识发起请求的页面。WebSocket 握手时服务器必须执行自己的信任策略;CORS 响应头不能替代此检查。
CSWSH
跨站 WebSocket 劫持:不可信网页向可达服务发起 WebSocket,而该服务未验证连接来源。
环境凭据
进程已可使用的秘密,例如环境变量中的 API 密钥,可为没有自带凭据的请求进行认证。
CWE-1385
WebSocket 缺少 Origin 验证:端点未检查通信来源是否获准。

框架映射

根本原因剖析 (Root Cause)

Headroom 的 /v1/responses WebSocket 处理程序在检查握手中的 Origin 之前就接受了浏览器连接。随后代理可能将自身环境中的 OPENAI_API_KEY 添加到上游请求。浏览器同源策略本身并不会阻止 JavaScript 向另一个可达主机发起 WebSocket;浏览器通常会发送 Origin,服务器必须拒绝不符合策略的来源。因此,利用条件包括受害者浏览器能够访问存在漏洞的 Headroom 代理,以及代理持有有用凭据或集成。公告称被接受的连接可发出任意 LLM 请求。主机命令执行还取决于请求能否使用已配置的命令执行工具;缺少请求头检查本身不会自动导致主机 RCE。CNA 列出 0.35.0 之前版本为受影响版本,而 GitHub 公告包元数据列出 0.23.0。请升级至 0.35.0 或更高版本,并核实已安装版本。

攻击执行流程分解

步骤 1

代理持有上游权限

Headroom 进程可能在 OPENAI_API_KEY 中保存 OpenAI 密钥;浏览器客户端无需发送该密钥。

步骤 2

浏览器访问不可信网页

若浏览器可访问 Headroom WebSocket 端点,网页脚本便可尝试建立连接。浏览器会在握手中包含网页来源。

步骤 3

存在漏洞的处理程序未检查来源便接受连接

受影响处理程序会在未先拒绝不可信 Origin 的情况下执行 websocket.accept()。同源策略不是服务器端 WebSocket 授权检查。

步骤 4

代理转发已认证请求

Headroom 可从环境中补充缺失的上游 Authorization 头。攻击者控制的网页因此可能借助代理权限发起请求;影响取决于工具、凭据、网络可达性和用户交互。

步骤 5

修复在路由前拒绝不允许的来源

上游补丁在接受和转发会话前验证来源。浏览器来源默认限制为 loopback,可配置明确来源,并以 1008 策略违规代码关闭不允许的连接。

源代码对比:漏洞与安全实现

公告指出 handle_openai_responses_ws() 在 websocket.accept() 前缺少验证。上游提交 c632023 添加 _is_allowed_websocket_origin(),并在路由会话前拒绝不允许的来源;它兼容不带 Origin 的原生客户端,默认仅允许浏览器 loopback 来源,并支持通过 HEADROOM_WS_ORIGINS 或 HEADROOM_CORS_ORIGINS 配置来源。此简化代码突出变更的信任边界,未完整列出辅助函数。上游回归测试覆盖 Origin 缺失、loopback、远程、格式错误及显式配置。

源码摘录: headroom/proxy/handlers/openai.py

存在漏洞的实现
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    # 易受攻击的流程:在验证来源之前接受浏览器会话。
    await websocket.accept()
    await self._relay_responses_session(websocket)
加固后的安全修复
async def handle_openai_responses_ws(self, websocket: WebSocket) -> None:
    ws_headers = dict(websocket.headers)
    if not _is_allowed_websocket_origin(ws_headers):
        logger.warning(
            "event=websocket_origin_not_allowed origin=%r",
            _header_get(ws_headers, "origin"),
        )
        await websocket.close(code=1008, reason="origin not allowed")
        return

    # 只有通过策略校验的浏览器会话才能进入上游路由。
    await websocket.accept()
    await self._relay_responses_session(websocket)

工程与系统安全加固清单

参考来源