From 5fbf7aabc8993bc091337e5ae6aed943873e933e Mon Sep 17 00:00:00 2001 From: Issac-Newton <1556820213@qq.com> Date: Tue, 21 Apr 2026 17:43:36 +0800 Subject: [PATCH 1/3] fix(proxy): forward whitelisted headers in WebSocket proxy upstream handshake (#865) WebSocket proxy was losing client Origin, Authorization, Cookie, and tracing headers when initiating the second-hop handshake to downstream services. Add whitelist-based header forwarding via build_upstream_ws_headers() and pass origin=/additional_headers= to websockets.connect(). Co-Authored-By: Claude Opus 4.6 --- .../proxy-enhancements/01_requirement.md | 32 +++- .../proxy-enhancements/03_implementation.md | 104 +++++++++++- rock/sandbox/service/sandbox_proxy_service.py | 32 ++++ .../sandbox/test_websocket_proxy_headers.py | 148 ++++++++++++++++++ 4 files changed, 310 insertions(+), 6 deletions(-) create mode 100644 tests/unit/sandbox/test_websocket_proxy_headers.py diff --git a/docs/_specs/proxy-enhancements/01_requirement.md b/docs/_specs/proxy-enhancements/01_requirement.md index 76fc19bdb7..dafa3d84c0 100644 --- a/docs/_specs/proxy-enhancements/01_requirement.md +++ b/docs/_specs/proxy-enhancements/01_requirement.md @@ -7,10 +7,16 @@ ROCK Admin 目前提供两类代理能力: 1. **WebSocket Proxy** (`/sandboxes/{id}/proxy/ws[/{path}]`):将 WebSocket 连接转发到沙箱内的固定 SERVER 端口(`Port.SERVER = 8080`),不支持用户指定目标端口。 2. **HTTP Proxy** (`/sandboxes/{sandbox_id}/proxy[/{path}]`):将 HTTP 请求转发到沙箱内的固定 SERVER 端口(`Port.SERVER = 8080`),且硬编码为 `method="POST"`,不支持 GET / PUT / DELETE / PATCH 等其他 HTTP 方法,也不支持用户指定目标端口。 -这三个限制阻碍了以下场景: +除了端口和 method 能力不足之外,WebSocket proxy 还有一个上下文丢失问题: +- Admin 在转发 WebSocket 握手到下游服务时,目前只处理 `Sec-WebSocket-Protocol` 子协议,不透传通用请求头 +- 当下游服务依赖 `Origin`、`Authorization`、`Cookie`、`X-Forwarded-*` 等头做来源校验、认证、会话恢复或审计时,二跳握手会丢失这些上下文 +- 典型失败现象是下游日志出现 `origin not allowed`,或者因为缺失 token / cookie 导致握手或后续鉴权失败 + +这些限制阻碍了以下场景: - 沙箱内运行了多个 WebSocket 服务(如 Jupyter Kernel、VS Code Server、自定义推理服务),需要连接到不同端口 - 沙箱内服务使用 RESTful 风格 API,需要 GET 查询、PUT 更新、DELETE 删除 - 沙箱内运行了多个 HTTP 服务,需要访问非 8080 端口 +- 沙箱内 WebSocket 服务依赖浏览器来源校验、认证头、cookie 或链路追踪头,要求代理保留客户端请求上下文 --- @@ -33,12 +39,22 @@ ROCK Admin 目前提供两类代理能力: - 在现有 `/sandboxes/{sandbox_id}/proxy[/{path}]` 路由上,允许通过 query param `port` 指定目标 HTTP 端口 - 当 `port` 未指定时,保持现有行为(使用 `Port.SERVER = 8080`) +4. **WebSocket Proxy 支持白名单透传通用请求头** + - 通过 `/sandboxes/{id}/proxy/{path:path}` 建立 WebSocket 代理时,允许将客户端请求中的通用 header 按白名单转发到下游服务 + - 首批需要覆盖的 header 包括:`Origin`、`Authorization`、`Cookie`、`X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto`、`X-Real-IP`、`X-Request-Id`、`Traceparent`、`Tracestate`、`EagleEye-TraceId`、`EagleEye-RpcId`、`EagleEye-UserData` + - `Origin` 是必须支持的关键头,因为下游服务可能使用来源白名单(例如 `gateway.controlUi.allowedOrigins`)校验 WebSocket 握手 + - `Sec-WebSocket-Protocol` 继续通过 WebSocket 子协议协商传递,不作为普通 header 透传 + - 采用白名单策略,避免将握手专用头和 hop-by-hop 头错误转发给下游 + ### Out(本次不做的) - WebSocket Proxy 的认证/鉴权增强 - HTTP Proxy 的 multipart/form-data 支持(upload 接口已单独处理) - `host_proxy` 的 method 扩展(范围外) - SDK 客户端侧的封装更新 +- WebSocket 代理透传“所有”请求头,或由客户端动态指定任意 header 白名单 +- 自动伪造、补默认值或重写任意 `Origin` +- 透传 WebSocket 握手专用头(如 `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`) --- @@ -53,6 +69,11 @@ ROCK Admin 目前提供两类代理能力: - **AC7**:SSE streaming(`text/event-stream`)在所有 method 下仍然正常工作 - **AC8**:`GET /sandboxes/{sandbox_id}/proxy/api/health?port=9000` 能成功代理到沙箱内 9000 端口的 HTTP 服务 - **AC9**:HTTP proxy 不带 `port` 参数时行为不变(向后兼容) +- **AC10**:当客户端 WebSocket 握手包含 `Origin` 时,代理发起到下游的二跳握手必须携带相同 `Origin`,以满足下游来源校验 +- **AC11**:当客户端握手包含 `Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent` 等白名单头时,代理发起到下游的二跳握手必须一并转发 +- **AC12**:`Sec-WebSocket-Protocol` 必须继续通过现有 `subprotocols` 机制转发和协商,不能降级为普通 header 透传 +- **AC13**:`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions` 等握手专用头不得被转发到下游 +- **AC14**:当客户端未携带任何白名单头时,WebSocket proxy 的默认行为与现状保持一致(向后兼容) --- @@ -62,11 +83,16 @@ ROCK Admin 目前提供两类代理能力: - 不修改 `Port.SERVER` / `Port.PROXY` 枚举定义 - `port_validation` 逻辑复用现有 `validate_port_forward_port`(但 WebSocket proxy 端口校验需新增对 `Port.SERVER = 8080` 的允许,或直接用同一校验函数) - 保持现有 portforward 端点(`/sandboxes/{id}/portforward`)不变 +- WebSocket header 转发采用**白名单**而不是**黑名单**策略,避免误传握手专用头 +- `Origin` 通过 WebSocket 客户端库的显式参数透传,不与普通 `additional_headers` 混用 +- `Sec-WebSocket-Protocol` 继续通过 `subprotocols` 参数处理,不走通用 header 透传逻辑 --- ## Risks & Rollout - **风险**:WebSocket proxy 中用户可以指定任意端口访问沙箱内服务,存在横向访问风险 → 通过 `sandbox_id` 鉴权已覆盖,端口范围校验作为防护层 -- **回滚**:修改仅在 `sandbox_proxy_api.py` 和 `sandbox_proxy_service.py` 内,回滚只需还原这两个文件 -- **上线策略**:无数据库变更,直接部署 +- **风险**:若白名单范围定义过宽,可能把不该下传的敏感或握手专用头带给下游 → 通过固定白名单和单元测试约束范围 +- **风险**:不同下游服务对 `Origin`、`Cookie`、`Authorization` 的要求不同,透传后暴露出原本被代理层掩盖的问题 → 以“尽可能保留客户端上下文、但不伪造默认值”为原则 +- **回滚**:修改集中在 `sandbox_proxy_service.py` 和对应单元测试;如需回滚,可仅还原 WebSocket header 透传逻辑 +- **上线策略**:无数据库变更,直接部署;建议先在依赖 `Origin` 校验的控制台类服务上验证 diff --git a/docs/_specs/proxy-enhancements/03_implementation.md b/docs/_specs/proxy-enhancements/03_implementation.md index c4fce8255c..f7254a6e11 100644 --- a/docs/_specs/proxy-enhancements/03_implementation.md +++ b/docs/_specs/proxy-enhancements/03_implementation.md @@ -7,6 +7,11 @@ admin 与 sandbox 不在同一 K8s 集群,`host_ip` 为宿主机 IP,容器 - **WebSocket proxy 自定义端口**:复用 rocklet 现有的 `/portforward` WebSocket 端点中转(与 `/sandboxes/{id}/portforward` 相同机制) - **HTTP proxy 自定义端口**:需在 rocklet 新增 `/http_proxy` HTTP 端点,admin 转发请求给 rocklet,rocklet 在容器内访问目标服务 +除了端口和 method 能力之外,当前 WebSocket proxy 还存在握手上下文丢失问题: +- `sandbox_proxy_service.websocket_proxy()` 在调用 `websockets.connect(...)` 时,目前只传了 `subprotocols` +- 下游服务收到的是 Admin 重新发起的二跳握手,请求来源会表现为 `Python websockets/...`,而不是客户端原始握手上下文 +- 结果是 `Origin`、`Authorization`、`Cookie`、`X-Forwarded-*` 等头全部丢失,依赖这些头的服务会报 `origin not allowed` 或鉴权失败 + --- ## File Changes @@ -14,8 +19,9 @@ admin 与 sandbox 不在同一 K8s 集群,`host_ip` 为宿主机 IP,容器 | 文件 | 修改类型 | 说明 | |------|------|------| | `rock/rocklet/local_api.py` | **新增** | 新增 `ANY /http_proxy/{path:path}?port={port}` 端点 | -| `rock/sandbox/service/sandbox_proxy_service.py` | 修改 | `http_proxy` 有 `port` 时改走 rocklet `/http_proxy` 中转;WebSocket proxy 有 `port` 时改走 rocklet `/portforward` 中转 | -| `rock/admin/entrypoints/sandbox_proxy_api.py` | 无变更 | 已支持,无需修改 | +| `rock/sandbox/service/sandbox_proxy_service.py` | 修改 | `http_proxy` 有 `port` 时改走 rocklet `/http_proxy` 中转;WebSocket proxy 有 `port` 时改走 rocklet `/portforward` 中转;补充 WebSocket 通用 headers 白名单透传 | +| `rock/admin/entrypoints/sandbox_proxy_api.py` | 无变更 | 路由签名保持不变,无需调整 | +| `tests/unit/sandbox/test_websocket_proxy_subprotocol.py` | 修改 | 增加 `Origin` / `additional_headers` 透传与禁转头测试 | --- @@ -110,6 +116,84 @@ async def http_proxy(self, sandbox_id, target_path, body, headers, method="POST" ... ``` +### 变更 4:WebSocket proxy 通用 headers 白名单透传 + +需要在 `sandbox_proxy_service.websocket_proxy()` 内新增一层 header 提取和过滤逻辑,将客户端握手里的“通用请求头”转为上游二跳握手参数。 + +**设计要点**: + +1. **将 `Origin` 单独处理** + - `websockets.connect()` 在 15.0.1 版本里提供 `origin=` 参数 + - `Origin` 不作为普通 `additional_headers` 重复透传,避免语义混乱和重复 header + +2. **其余通用头走固定白名单** + - 推荐白名单:`Authorization`、`Cookie`、`X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto`、`X-Real-IP`、`X-Request-Id`、`Traceparent`、`Tracestate`、`EagleEye-TraceId`、`EagleEye-RpcId`、`EagleEye-UserData` + - 以固定集合匹配,避免“默认全透传”带来的握手兼容性和安全风险 + +3. **无白名单头时保持兼容** + - 若客户端未携带任何白名单头,则 `origin=None`、`additional_headers=None` + - 这样代理行为与当前实现保持一致,不引入额外副作用 + +**建议实现草图**: + +```python +FORWARDED_WS_HEADER_NAMES = { + "authorization", + "cookie", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "x-request-id", + "traceparent", + "tracestate", + "eagleeye-traceid", + "eagleeye-rpcid", + "eagleeye-userdata", +} + + +def build_upstream_ws_headers(client_websocket): + origin = client_websocket.headers.get("origin") + additional_headers = [] + + for key, value in client_websocket.headers.items(): + lower_key = key.lower() + if lower_key == "origin": + continue + if lower_key in FORWARDED_WS_HEADER_NAMES: + additional_headers.append((key, value)) + + return origin, additional_headers or None +``` + +接入方式: + +```python +origin, additional_headers = build_upstream_ws_headers(client_websocket) + +async with websockets.connect( + target_url, + ping_interval=None, + ping_timeout=None, + origin=origin, + additional_headers=additional_headers, + subprotocols=upstream_subprotocols, +) as target_websocket: + ... +``` + +### 变更 5:测试覆盖扩展 + +现有测试主要覆盖子协议转发,需要补充 header 透传相关单测。 + +**新增测试点**: +- `Origin` 存在时,`websockets.connect()` 收到相同 `origin=` +- `Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent`、`EagleEye-*` 存在时,`websockets.connect()` 收到 `additional_headers` +- `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions` 不得出现在 `additional_headers` +- `Sec-WebSocket-Protocol` 继续通过 `subprotocols=` 转发,不能出现在 `additional_headers` +- 无白名单头时,`origin` / `additional_headers` 为 `None`,保持向后兼容 + --- ## Execution Plan @@ -131,14 +215,25 @@ async def http_proxy(self, sandbox_id, target_path, body, headers, method="POST" - 有 `port` 时,改用 `http://{host_ip}:{rocklet_mapped_port}/http_proxy/{path}?port={port}` - 无 `port` 时保持原逻辑不变 +### Step 4:新增 WebSocket 通用 header 过滤与透传逻辑 +- 文件:`rock/sandbox/service/sandbox_proxy_service.py` +- 新增 helper,负责从 `client_websocket.headers` 中提取 `Origin` 和白名单 `additional_headers` +- 在 `websocket_proxy()` 调用 `websockets.connect()` 时传入 `origin=` 和 `additional_headers=` +- 保持现有 `subprotocols=` 协商逻辑不变 + +### Step 5:补充 WebSocket header 透传测试 +- 文件:`tests/unit/sandbox/test_websocket_proxy_subprotocol.py` +- 新增 `Origin` 透传、通用 header 透传、禁转 header、兼容性测试 + --- ## Rollback & Compatibility - **向后兼容**:`rock_target_port` 未指定时,所有逻辑路径与原实现完全一致 +- **向后兼容**:客户端未携带任何白名单 header 时,WebSocket 二跳握手行为与现状一致 - **回滚**: - rocklet:还原 `local_api.py`,重新发布镜像 - - admin:还原 `sandbox_proxy_service.py` + - admin:还原 `sandbox_proxy_service.py` 和对应单元测试 --- @@ -147,3 +242,6 @@ async def http_proxy(self, sandbox_id, target_path, body, headers, method="POST" - WebSocket proxy 自定义端口时,`path` 参数不生效(rocklet portforward 是纯 TCP 隧道,不感知 HTTP path) - rocklet `/http_proxy` 端点的 `port` 参数需要校验(复用 `validate_port_forward_port`) - rocklet 镜像需要重新发布才能生效 +- WebSocket header 透传必须坚持白名单策略,不能简单复制全部请求头 +- `Origin` 应通过 `websockets.connect(origin=...)` 传入;不要与 `additional_headers` 重复 +- `Sec-WebSocket-Protocol` 必须继续通过 `subprotocols=` 传递,避免和普通 header 透传逻辑冲突 diff --git a/rock/sandbox/service/sandbox_proxy_service.py b/rock/sandbox/service/sandbox_proxy_service.py index b471701a96..8f854508e5 100644 --- a/rock/sandbox/service/sandbox_proxy_service.py +++ b/rock/sandbox/service/sandbox_proxy_service.py @@ -46,6 +46,35 @@ logger = init_logger(__name__) +FORWARDED_WS_HEADER_NAMES = { + "authorization", + "cookie", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "x-request-id", + "traceparent", + "tracestate", + "eagleeye-traceid", + "eagleeye-rpcid", + "eagleeye-userdata", +} + + +def build_upstream_ws_headers(client_websocket): + origin = client_websocket.headers.get("origin") or client_websocket.headers.get("Origin") + additional_headers = [] + + for key, value in client_websocket.headers.items(): + lower_key = key.lower() + if lower_key == "origin": + continue + if lower_key in FORWARDED_WS_HEADER_NAMES: + additional_headers.append((key, value)) + + return origin, additional_headers or None + class SandboxProxyService: _httpx_client = None @@ -208,12 +237,15 @@ async def websocket_proxy( client_subprotocols = getattr(client_websocket, "subprotocols", []) or [] upstream_subprotocols = client_subprotocols if client_subprotocols else ["binary", "base64"] + origin, additional_headers = build_upstream_ws_headers(client_websocket) try: async with websockets.connect( target_url, ping_interval=None, ping_timeout=None, + origin=origin, + additional_headers=additional_headers, subprotocols=upstream_subprotocols, ) as target_websocket: negotiated = getattr(target_websocket, "subprotocol", None) diff --git a/tests/unit/sandbox/test_websocket_proxy_headers.py b/tests/unit/sandbox/test_websocket_proxy_headers.py new file mode 100644 index 0000000000..7cda50e089 --- /dev/null +++ b/tests/unit/sandbox/test_websocket_proxy_headers.py @@ -0,0 +1,148 @@ +"""Tests for WebSocket proxy header forwarding (whitelist-based).""" + +from types import SimpleNamespace + +import websockets + +from rock.sandbox.service.sandbox_proxy_service import ( + SandboxProxyService, + build_upstream_ws_headers, +) + +# ───────────────────────────────────────────────────────────────────────────── +# Unit tests: build_upstream_ws_headers (pure function, no mocks) +# ───────────────────────────────────────────────────────────────────────────── + + +def _ws_with_headers(headers: dict): + """Create a minimal object with a .headers dict, mimicking Starlette WebSocket.""" + return SimpleNamespace(headers=headers) + + +class TestBuildUpstreamWsHeaders: + def test_whitelist_and_origin_forwarded(self): + ws = _ws_with_headers( + { + "Origin": "https://example.com", + "Authorization": "Bearer token123", + "cookie": "session=abc", + "traceparent": "00-trace-span-01", + "EagleEye-TraceId": "eagle-trace-001", + "host": "should-be-excluded", + "sec-websocket-protocol": "binary", + "x-pictor-callid": "pictor-123", + } + ) + origin, additional = build_upstream_ws_headers(ws) + assert origin == "https://example.com" + assert additional is not None + forwarded = dict(additional) + assert forwarded["Authorization"] == "Bearer token123" + assert forwarded["cookie"] == "session=abc" + assert forwarded["traceparent"] == "00-trace-span-01" + assert forwarded["EagleEye-TraceId"] == "eagle-trace-001" + excluded = {"origin", "host", "sec-websocket-protocol", "x-pictor-callid"} + assert excluded.isdisjoint({k.lower() for k, _ in additional}) + + def test_no_whitelist_headers_returns_none(self): + ws = _ws_with_headers({"host": "localhost", "connection": "upgrade", "user-agent": "test"}) + origin, additional = build_upstream_ws_headers(ws) + assert origin is None + assert additional is None + + def test_origin_only_no_additional(self): + ws = _ws_with_headers({"origin": "https://example.com"}) + origin, additional = build_upstream_ws_headers(ws) + assert origin == "https://example.com" + assert additional is None + + +# ───────────────────────────────────────────────────────────────────────────── +# E2E tests: real WebSocket server verifying header arrival +# ───────────────────────────────────────────────────────────────────────────── + + +class FakeClientWebSocket: + """Simulates a Starlette WebSocket for websocket_proxy() input.""" + + def __init__(self, headers: dict, subprotocols: list | None = None): + self.headers = headers + self.subprotocols = subprotocols or [] + self._accepted = False + self._closed = False + + async def accept(self, subprotocol=None): + self._accepted = True + + async def close(self, code=1000, reason=""): + self._closed = True + + async def receive(self): + return {"type": "websocket.disconnect", "code": 1000} + + +class TestWebSocketHeaderForwardingE2E: + """Start a real websockets server and verify headers arrive downstream.""" + + async def _run_proxy_with_server(self, client_headers: dict, subprotocols: list | None = None): + """Helper: start WS server, run websocket_proxy(), return captured request headers.""" + captured_headers = {} + + async def handler(ws): + captured_headers.update(dict(ws.request.headers)) + await ws.close() + + async with websockets.serve(handler, "127.0.0.1", 0) as server: + port = server.sockets[0].getsockname()[1] + target_url = f"ws://127.0.0.1:{port}" + + service = SandboxProxyService.__new__(SandboxProxyService) + + async def noop_update(*a, **kw): + pass + + async def fake_get_url(*a, **kw): + return target_url + + service._update_expire_time = noop_update + service.get_sandbox_websocket_url = fake_get_url + + client_ws = FakeClientWebSocket(client_headers, subprotocols) + await service.websocket_proxy(client_ws, "test-sandbox") + + return captured_headers + + async def test_origin_received_by_downstream(self): + headers = await self._run_proxy_with_server({"origin": "https://my-app.example.com"}) + assert headers.get("origin") == "https://my-app.example.com" + + async def test_whitelist_headers_received_by_downstream(self): + client_headers = { + "authorization": "Bearer secret-token", + "eagleeye-traceid": "eagle-trace-e2e", + "x-request-id": "req-e2e-001", + "traceparent": "00-abcdef-123456-01", + } + headers = await self._run_proxy_with_server(client_headers) + assert headers.get("authorization") == "Bearer secret-token" + assert headers.get("eagleeye-traceid") == "eagle-trace-e2e" + assert headers.get("x-request-id") == "req-e2e-001" + assert headers.get("traceparent") == "00-abcdef-123456-01" + + async def test_forbidden_headers_not_received_by_downstream(self): + client_headers = { + "authorization": "Bearer xxx", + "x-pictor-callid": "should-not-arrive", + "x-rock-sandbox-default-upstream": "should-not-arrive", + "web-server-type": "nginx", + } + headers = await self._run_proxy_with_server(client_headers) + assert headers.get("authorization") == "Bearer xxx" + assert "x-pictor-callid" not in headers + assert "x-rock-sandbox-default-upstream" not in headers + assert "web-server-type" not in headers + + async def test_no_extra_headers_backward_compatible(self): + headers = await self._run_proxy_with_server({}) + assert "authorization" not in headers + assert "origin" not in headers From 4c63f825f140b98e014bcf88787108bcbdf17c5a Mon Sep 17 00:00:00 2001 From: Issac-Newton <1556820213@qq.com> Date: Tue, 21 Apr 2026 17:50:26 +0800 Subject: [PATCH 2/3] refactor: move FORWARDED_WS_HEADER_NAMES and build_upstream_ws_headers to rock/sandbox/utils/proxy.py Co-Authored-By: Claude Opus 4.6 --- rock/sandbox/service/sandbox_proxy_service.py | 30 +------------------ rock/sandbox/utils/proxy.py | 28 +++++++++++++++++ .../sandbox/test_websocket_proxy_headers.py | 6 ++-- 3 files changed, 31 insertions(+), 33 deletions(-) create mode 100644 rock/sandbox/utils/proxy.py diff --git a/rock/sandbox/service/sandbox_proxy_service.py b/rock/sandbox/service/sandbox_proxy_service.py index 8f854508e5..451cebdf96 100644 --- a/rock/sandbox/service/sandbox_proxy_service.py +++ b/rock/sandbox/service/sandbox_proxy_service.py @@ -40,41 +40,13 @@ from rock.common.port_validation import validate_port_forward_port from rock.logger import init_logger from rock.sandbox.sandbox_meta_store import SandboxMetaStore +from rock.sandbox.utils.proxy import build_upstream_ws_headers from rock.sandbox.utils.timeout import SandboxTimeoutHelper from rock.sdk.common.exceptions import BadRequestRockError from rock.utils import EAGLE_EYE_TRACE_ID, trace_id_ctx_var logger = init_logger(__name__) -FORWARDED_WS_HEADER_NAMES = { - "authorization", - "cookie", - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - "x-real-ip", - "x-request-id", - "traceparent", - "tracestate", - "eagleeye-traceid", - "eagleeye-rpcid", - "eagleeye-userdata", -} - - -def build_upstream_ws_headers(client_websocket): - origin = client_websocket.headers.get("origin") or client_websocket.headers.get("Origin") - additional_headers = [] - - for key, value in client_websocket.headers.items(): - lower_key = key.lower() - if lower_key == "origin": - continue - if lower_key in FORWARDED_WS_HEADER_NAMES: - additional_headers.append((key, value)) - - return origin, additional_headers or None - class SandboxProxyService: _httpx_client = None diff --git a/rock/sandbox/utils/proxy.py b/rock/sandbox/utils/proxy.py new file mode 100644 index 0000000000..f2a3b68101 --- /dev/null +++ b/rock/sandbox/utils/proxy.py @@ -0,0 +1,28 @@ +FORWARDED_WS_HEADER_NAMES = { + "authorization", + "cookie", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "x-request-id", + "traceparent", + "tracestate", + "eagleeye-traceid", + "eagleeye-rpcid", + "eagleeye-userdata", +} + + +def build_upstream_ws_headers(client_websocket): + origin = client_websocket.headers.get("origin") or client_websocket.headers.get("Origin") + additional_headers = [] + + for key, value in client_websocket.headers.items(): + lower_key = key.lower() + if lower_key == "origin": + continue + if lower_key in FORWARDED_WS_HEADER_NAMES: + additional_headers.append((key, value)) + + return origin, additional_headers or None diff --git a/tests/unit/sandbox/test_websocket_proxy_headers.py b/tests/unit/sandbox/test_websocket_proxy_headers.py index 7cda50e089..613b7d095a 100644 --- a/tests/unit/sandbox/test_websocket_proxy_headers.py +++ b/tests/unit/sandbox/test_websocket_proxy_headers.py @@ -4,10 +4,8 @@ import websockets -from rock.sandbox.service.sandbox_proxy_service import ( - SandboxProxyService, - build_upstream_ws_headers, -) +from rock.sandbox.service.sandbox_proxy_service import SandboxProxyService +from rock.sandbox.utils.proxy import build_upstream_ws_headers # ───────────────────────────────────────────────────────────────────────────── # Unit tests: build_upstream_ws_headers (pure function, no mocks) From 905310325a08c09c68b94803eec86a20fbbd8499 Mon Sep 17 00:00:00 2001 From: Issac-Newton <1556820213@qq.com> Date: Wed, 22 Apr 2026 11:33:12 +0800 Subject: [PATCH 3/3] refactor(proxy): switch WebSocket header forwarding from whitelist to blacklist Whitelist strategy blocked user-defined custom headers from reaching downstream services. Blacklist strategy forwards all headers by default, only filtering out WebSocket handshake headers and hop-by-hop headers. Co-Authored-By: Claude Opus 4.6 --- .../proxy-enhancements/01_requirement.md | 17 ++-- .../proxy-enhancements/03_implementation.md | 72 +++++++------- rock/sandbox/utils/proxy.py | 33 ++++--- .../sandbox/test_websocket_proxy_headers.py | 95 +++++++++++++++---- 4 files changed, 141 insertions(+), 76 deletions(-) diff --git a/docs/_specs/proxy-enhancements/01_requirement.md b/docs/_specs/proxy-enhancements/01_requirement.md index dafa3d84c0..85653b629a 100644 --- a/docs/_specs/proxy-enhancements/01_requirement.md +++ b/docs/_specs/proxy-enhancements/01_requirement.md @@ -39,12 +39,12 @@ ROCK Admin 目前提供两类代理能力: - 在现有 `/sandboxes/{sandbox_id}/proxy[/{path}]` 路由上,允许通过 query param `port` 指定目标 HTTP 端口 - 当 `port` 未指定时,保持现有行为(使用 `Port.SERVER = 8080`) -4. **WebSocket Proxy 支持白名单透传通用请求头** - - 通过 `/sandboxes/{id}/proxy/{path:path}` 建立 WebSocket 代理时,允许将客户端请求中的通用 header 按白名单转发到下游服务 - - 首批需要覆盖的 header 包括:`Origin`、`Authorization`、`Cookie`、`X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto`、`X-Real-IP`、`X-Request-Id`、`Traceparent`、`Tracestate`、`EagleEye-TraceId`、`EagleEye-RpcId`、`EagleEye-UserData` +4. **WebSocket Proxy 支持黑名单过滤透传通用请求头** + - 通过 `/sandboxes/{id}/proxy/{path:path}` 建立 WebSocket 代理时,默认将客户端请求中的通用 header 转发到下游服务,通过黑名单排除不应转发的头 + - 黑名单排除的头包括:WebSocket 握手专用头(`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions`、`Sec-WebSocket-Protocol`)和 hop-by-hop 头(`Transfer-Encoding`、`TE`、`Trailer`、`Keep-Alive`、`Proxy-Authorization`、`Proxy-Connection`、`Content-Length`) - `Origin` 是必须支持的关键头,因为下游服务可能使用来源白名单(例如 `gateway.controlUi.allowedOrigins`)校验 WebSocket 握手 - `Sec-WebSocket-Protocol` 继续通过 WebSocket 子协议协商传递,不作为普通 header 透传 - - 采用白名单策略,避免将握手专用头和 hop-by-hop 头错误转发给下游 + - 采用黑名单策略,确保用户自定义 header 能被透传到下游服务 ### Out(本次不做的) @@ -52,7 +52,6 @@ ROCK Admin 目前提供两类代理能力: - HTTP Proxy 的 multipart/form-data 支持(upload 接口已单独处理) - `host_proxy` 的 method 扩展(范围外) - SDK 客户端侧的封装更新 -- WebSocket 代理透传“所有”请求头,或由客户端动态指定任意 header 白名单 - 自动伪造、补默认值或重写任意 `Origin` - 透传 WebSocket 握手专用头(如 `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`) @@ -70,9 +69,9 @@ ROCK Admin 目前提供两类代理能力: - **AC8**:`GET /sandboxes/{sandbox_id}/proxy/api/health?port=9000` 能成功代理到沙箱内 9000 端口的 HTTP 服务 - **AC9**:HTTP proxy 不带 `port` 参数时行为不变(向后兼容) - **AC10**:当客户端 WebSocket 握手包含 `Origin` 时,代理发起到下游的二跳握手必须携带相同 `Origin`,以满足下游来源校验 -- **AC11**:当客户端握手包含 `Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent` 等白名单头时,代理发起到下游的二跳握手必须一并转发 +- **AC11**:当客户端握手包含 `Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent` 或任意自定义头时,代理发起到下游的二跳握手必须一并转发 - **AC12**:`Sec-WebSocket-Protocol` 必须继续通过现有 `subprotocols` 机制转发和协商,不能降级为普通 header 透传 -- **AC13**:`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions` 等握手专用头不得被转发到下游 +- **AC13**:黑名单头(`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions`、`Transfer-Encoding`、`TE`、`Trailer`、`Keep-Alive`、`Proxy-Authorization`、`Proxy-Connection`、`Content-Length`)不得被转发到下游 - **AC14**:当客户端未携带任何白名单头时,WebSocket proxy 的默认行为与现状保持一致(向后兼容) --- @@ -83,7 +82,7 @@ ROCK Admin 目前提供两类代理能力: - 不修改 `Port.SERVER` / `Port.PROXY` 枚举定义 - `port_validation` 逻辑复用现有 `validate_port_forward_port`(但 WebSocket proxy 端口校验需新增对 `Port.SERVER = 8080` 的允许,或直接用同一校验函数) - 保持现有 portforward 端点(`/sandboxes/{id}/portforward`)不变 -- WebSocket header 转发采用**白名单**而不是**黑名单**策略,避免误传握手专用头 +- WebSocket header 转发采用**黑名单**策略,排除握手专用头和 hop-by-hop 头,允许用户自定义 header 透传 - `Origin` 通过 WebSocket 客户端库的显式参数透传,不与普通 `additional_headers` 混用 - `Sec-WebSocket-Protocol` 继续通过 `subprotocols` 参数处理,不走通用 header 透传逻辑 @@ -92,7 +91,7 @@ ROCK Admin 目前提供两类代理能力: ## Risks & Rollout - **风险**:WebSocket proxy 中用户可以指定任意端口访问沙箱内服务,存在横向访问风险 → 通过 `sandbox_id` 鉴权已覆盖,端口范围校验作为防护层 -- **风险**:若白名单范围定义过宽,可能把不该下传的敏感或握手专用头带给下游 → 通过固定白名单和单元测试约束范围 +- **风险**:黑名单策略下,未列入黑名单的头会默认转发 → 通过单元测试确保握手专用头和 hop-by-hop 头被正确过滤 - **风险**:不同下游服务对 `Origin`、`Cookie`、`Authorization` 的要求不同,透传后暴露出原本被代理层掩盖的问题 → 以“尽可能保留客户端上下文、但不伪造默认值”为原则 - **回滚**:修改集中在 `sandbox_proxy_service.py` 和对应单元测试;如需回滚,可仅还原 WebSocket header 透传逻辑 - **上线策略**:无数据库变更,直接部署;建议先在依赖 `Origin` 校验的控制台类服务上验证 diff --git a/docs/_specs/proxy-enhancements/03_implementation.md b/docs/_specs/proxy-enhancements/03_implementation.md index f7254a6e11..20dd96d05e 100644 --- a/docs/_specs/proxy-enhancements/03_implementation.md +++ b/docs/_specs/proxy-enhancements/03_implementation.md @@ -116,9 +116,9 @@ async def http_proxy(self, sandbox_id, target_path, body, headers, method="POST" ... ``` -### 变更 4:WebSocket proxy 通用 headers 白名单透传 +### 变更 4:WebSocket proxy 通用 headers 黑名单过滤透传 -需要在 `sandbox_proxy_service.websocket_proxy()` 内新增一层 header 提取和过滤逻辑,将客户端握手里的“通用请求头”转为上游二跳握手参数。 +需要在 `sandbox_proxy_service.websocket_proxy()` 内新增一层 header 提取和过滤逻辑,将客户端握手里的”通用请求头”转为上游二跳握手参数。 **设计要点**: @@ -126,43 +126,46 @@ async def http_proxy(self, sandbox_id, target_path, body, headers, method="POST" - `websockets.connect()` 在 15.0.1 版本里提供 `origin=` 参数 - `Origin` 不作为普通 `additional_headers` 重复透传,避免语义混乱和重复 header -2. **其余通用头走固定白名单** - - 推荐白名单:`Authorization`、`Cookie`、`X-Forwarded-For`、`X-Forwarded-Host`、`X-Forwarded-Proto`、`X-Real-IP`、`X-Request-Id`、`Traceparent`、`Tracestate`、`EagleEye-TraceId`、`EagleEye-RpcId`、`EagleEye-UserData` - - 以固定集合匹配,避免“默认全透传”带来的握手兼容性和安全风险 +2. **其余头走黑名单过滤** + - 黑名单:WebSocket 握手专用头(`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions`、`Sec-WebSocket-Protocol`)和 hop-by-hop 头(`Transfer-Encoding`、`TE`、`Trailer`、`Keep-Alive`、`Proxy-Authorization`、`Proxy-Connection`、`Content-Length`) + - 不在黑名单中的头默认转发,确保用户自定义 header 能到达下游 -3. **无白名单头时保持兼容** - - 若客户端未携带任何白名单头,则 `origin=None`、`additional_headers=None` +3. **无可转发头时保持兼容** + - 若客户端未携带任何非黑名单头,则 `origin=None`、`additional_headers=None` - 这样代理行为与当前实现保持一致,不引入额外副作用 -**建议实现草图**: +**实现代码**(`rock/sandbox/utils/proxy.py`): ```python -FORWARDED_WS_HEADER_NAMES = { - "authorization", - "cookie", - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - "x-real-ip", - "x-request-id", - "traceparent", - "tracestate", - "eagleeye-traceid", - "eagleeye-rpcid", - "eagleeye-userdata", +BLOCKED_WS_HEADER_NAMES = { + “host”, + “connection”, + “upgrade”, + “sec-websocket-key”, + “sec-websocket-version”, + “sec-websocket-extensions”, + “sec-websocket-protocol”, + “transfer-encoding”, + “te”, + “trailer”, + “keep-alive”, + “proxy-authorization”, + “proxy-connection”, + “content-length”, } def build_upstream_ws_headers(client_websocket): - origin = client_websocket.headers.get("origin") + origin = client_websocket.headers.get(“origin”) or client_websocket.headers.get(“Origin”) additional_headers = [] for key, value in client_websocket.headers.items(): lower_key = key.lower() - if lower_key == "origin": + if lower_key == “origin”: continue - if lower_key in FORWARDED_WS_HEADER_NAMES: - additional_headers.append((key, value)) + if lower_key in BLOCKED_WS_HEADER_NAMES: + continue + additional_headers.append((key, value)) return origin, additional_headers or None ``` @@ -189,10 +192,11 @@ async with websockets.connect( **新增测试点**: - `Origin` 存在时,`websockets.connect()` 收到相同 `origin=` -- `Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent`、`EagleEye-*` 存在时,`websockets.connect()` 收到 `additional_headers` -- `Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions` 不得出现在 `additional_headers` +- 已知头(`Authorization`、`Cookie`、`X-Forwarded-*`、`X-Request-Id`、`Traceparent`、`EagleEye-*`)存在时,`websockets.connect()` 收到 `additional_headers` +- 用户自定义头(如 `x-my-custom`)能被正常转发到下游 +- 黑名单头(`Host`、`Connection`、`Upgrade`、`Sec-WebSocket-Key`、`Sec-WebSocket-Version`、`Sec-WebSocket-Extensions` 等)不得出现在 `additional_headers` - `Sec-WebSocket-Protocol` 继续通过 `subprotocols=` 转发,不能出现在 `additional_headers` -- 无白名单头时,`origin` / `additional_headers` 为 `None`,保持向后兼容 +- 无可转发头时,`origin` / `additional_headers` 为 `None`,保持向后兼容 --- @@ -215,15 +219,15 @@ async with websockets.connect( - 有 `port` 时,改用 `http://{host_ip}:{rocklet_mapped_port}/http_proxy/{path}?port={port}` - 无 `port` 时保持原逻辑不变 -### Step 4:新增 WebSocket 通用 header 过滤与透传逻辑 -- 文件:`rock/sandbox/service/sandbox_proxy_service.py` -- 新增 helper,负责从 `client_websocket.headers` 中提取 `Origin` 和白名单 `additional_headers` +### Step 4:新增 WebSocket 通用 header 黑名单过滤与透传逻辑 +- 文件:`rock/sandbox/utils/proxy.py`(独立模块)、`rock/sandbox/service/sandbox_proxy_service.py`(调用方) +- 新增 `build_upstream_ws_headers()` helper,负责从 `client_websocket.headers` 中提取 `Origin` 并通过黑名单过滤 `additional_headers` - 在 `websocket_proxy()` 调用 `websockets.connect()` 时传入 `origin=` 和 `additional_headers=` - 保持现有 `subprotocols=` 协商逻辑不变 ### Step 5:补充 WebSocket header 透传测试 -- 文件:`tests/unit/sandbox/test_websocket_proxy_subprotocol.py` -- 新增 `Origin` 透传、通用 header 透传、禁转 header、兼容性测试 +- 文件:`tests/unit/sandbox/test_websocket_proxy_headers.py` +- 新增 `Origin` 透传、已知 header 透传、自定义 header 透传、黑名单 header 过滤、兼容性测试 --- @@ -242,6 +246,6 @@ async with websockets.connect( - WebSocket proxy 自定义端口时,`path` 参数不生效(rocklet portforward 是纯 TCP 隧道,不感知 HTTP path) - rocklet `/http_proxy` 端点的 `port` 参数需要校验(复用 `validate_port_forward_port`) - rocklet 镜像需要重新发布才能生效 -- WebSocket header 透传必须坚持白名单策略,不能简单复制全部请求头 +- WebSocket header 透传采用黑名单策略,排除握手专用头和 hop-by-hop 头,允许用户自定义 header 透传 - `Origin` 应通过 `websockets.connect(origin=...)` 传入;不要与 `additional_headers` 重复 - `Sec-WebSocket-Protocol` 必须继续通过 `subprotocols=` 传递,避免和普通 header 透传逻辑冲突 diff --git a/rock/sandbox/utils/proxy.py b/rock/sandbox/utils/proxy.py index f2a3b68101..195cb0f83b 100644 --- a/rock/sandbox/utils/proxy.py +++ b/rock/sandbox/utils/proxy.py @@ -1,16 +1,18 @@ -FORWARDED_WS_HEADER_NAMES = { - "authorization", - "cookie", - "x-forwarded-for", - "x-forwarded-host", - "x-forwarded-proto", - "x-real-ip", - "x-request-id", - "traceparent", - "tracestate", - "eagleeye-traceid", - "eagleeye-rpcid", - "eagleeye-userdata", +BLOCKED_WS_HEADER_NAMES = { + "host", + "connection", + "upgrade", + "sec-websocket-key", + "sec-websocket-version", + "sec-websocket-extensions", + "sec-websocket-protocol", + "transfer-encoding", + "te", + "trailer", + "keep-alive", + "proxy-authorization", + "proxy-connection", + "content-length", } @@ -22,7 +24,8 @@ def build_upstream_ws_headers(client_websocket): lower_key = key.lower() if lower_key == "origin": continue - if lower_key in FORWARDED_WS_HEADER_NAMES: - additional_headers.append((key, value)) + if lower_key in BLOCKED_WS_HEADER_NAMES: + continue + additional_headers.append((key, value)) return origin, additional_headers or None diff --git a/tests/unit/sandbox/test_websocket_proxy_headers.py b/tests/unit/sandbox/test_websocket_proxy_headers.py index 613b7d095a..caf8841240 100644 --- a/tests/unit/sandbox/test_websocket_proxy_headers.py +++ b/tests/unit/sandbox/test_websocket_proxy_headers.py @@ -1,11 +1,11 @@ -"""Tests for WebSocket proxy header forwarding (whitelist-based).""" +"""Tests for WebSocket proxy header forwarding (blacklist-based).""" from types import SimpleNamespace import websockets from rock.sandbox.service.sandbox_proxy_service import SandboxProxyService -from rock.sandbox.utils.proxy import build_upstream_ws_headers +from rock.sandbox.utils.proxy import BLOCKED_WS_HEADER_NAMES, build_upstream_ws_headers # ───────────────────────────────────────────────────────────────────────────── # Unit tests: build_upstream_ws_headers (pure function, no mocks) @@ -18,7 +18,7 @@ def _ws_with_headers(headers: dict): class TestBuildUpstreamWsHeaders: - def test_whitelist_and_origin_forwarded(self): + def test_known_headers_forwarded(self): ws = _ws_with_headers( { "Origin": "https://example.com", @@ -26,9 +26,6 @@ def test_whitelist_and_origin_forwarded(self): "cookie": "session=abc", "traceparent": "00-trace-span-01", "EagleEye-TraceId": "eagle-trace-001", - "host": "should-be-excluded", - "sec-websocket-protocol": "binary", - "x-pictor-callid": "pictor-123", } ) origin, additional = build_upstream_ws_headers(ws) @@ -39,11 +36,46 @@ def test_whitelist_and_origin_forwarded(self): assert forwarded["cookie"] == "session=abc" assert forwarded["traceparent"] == "00-trace-span-01" assert forwarded["EagleEye-TraceId"] == "eagle-trace-001" - excluded = {"origin", "host", "sec-websocket-protocol", "x-pictor-callid"} - assert excluded.isdisjoint({k.lower() for k, _ in additional}) - def test_no_whitelist_headers_returns_none(self): - ws = _ws_with_headers({"host": "localhost", "connection": "upgrade", "user-agent": "test"}) + def test_custom_headers_forwarded(self): + ws = _ws_with_headers( + { + "x-my-custom": "custom-value", + "x-pictor-callid": "pictor-123", + "web-server-type": "nginx", + } + ) + origin, additional = build_upstream_ws_headers(ws) + assert origin is None + assert additional is not None + forwarded = dict(additional) + assert forwarded["x-my-custom"] == "custom-value" + assert forwarded["x-pictor-callid"] == "pictor-123" + assert forwarded["web-server-type"] == "nginx" + + def test_blocked_headers_excluded(self): + ws = _ws_with_headers( + { + "Authorization": "Bearer token123", + "host": "should-be-excluded", + "connection": "upgrade", + "upgrade": "websocket", + "sec-websocket-key": "dGhlIHNhbXBsZSBub25jZQ==", + "sec-websocket-version": "13", + "sec-websocket-extensions": "permessage-deflate", + "sec-websocket-protocol": "binary", + "transfer-encoding": "chunked", + "keep-alive": "timeout=5", + } + ) + origin, additional = build_upstream_ws_headers(ws) + assert origin is None + assert additional is not None + forwarded_keys = {k.lower() for k, _ in additional} + assert forwarded_keys == {"authorization"} + + def test_no_forwardable_headers_returns_none(self): + ws = _ws_with_headers({"host": "localhost", "connection": "upgrade"}) origin, additional = build_upstream_ws_headers(ws) assert origin is None assert additional is None @@ -54,6 +86,25 @@ def test_origin_only_no_additional(self): assert origin == "https://example.com" assert additional is None + def test_origin_not_in_additional(self): + ws = _ws_with_headers( + { + "origin": "https://example.com", + "Authorization": "Bearer xxx", + } + ) + origin, additional = build_upstream_ws_headers(ws) + assert origin == "https://example.com" + forwarded_keys = {k.lower() for k, _ in additional} + assert "origin" not in forwarded_keys + + def test_all_blocked_headers_covered(self): + headers = {name: "value" for name in BLOCKED_WS_HEADER_NAMES} + ws = _ws_with_headers(headers) + origin, additional = build_upstream_ws_headers(ws) + assert origin is None + assert additional is None + # ───────────────────────────────────────────────────────────────────────────── # E2E tests: real WebSocket server verifying header arrival @@ -114,7 +165,7 @@ async def test_origin_received_by_downstream(self): headers = await self._run_proxy_with_server({"origin": "https://my-app.example.com"}) assert headers.get("origin") == "https://my-app.example.com" - async def test_whitelist_headers_received_by_downstream(self): + async def test_known_headers_received_by_downstream(self): client_headers = { "authorization": "Bearer secret-token", "eagleeye-traceid": "eagle-trace-e2e", @@ -127,18 +178,26 @@ async def test_whitelist_headers_received_by_downstream(self): assert headers.get("x-request-id") == "req-e2e-001" assert headers.get("traceparent") == "00-abcdef-123456-01" - async def test_forbidden_headers_not_received_by_downstream(self): + async def test_custom_headers_received_by_downstream(self): client_headers = { - "authorization": "Bearer xxx", - "x-pictor-callid": "should-not-arrive", - "x-rock-sandbox-default-upstream": "should-not-arrive", + "x-my-custom-app": "my-value", + "x-pictor-callid": "pictor-123", "web-server-type": "nginx", } headers = await self._run_proxy_with_server(client_headers) + assert headers.get("x-my-custom-app") == "my-value" + assert headers.get("x-pictor-callid") == "pictor-123" + assert headers.get("web-server-type") == "nginx" + + async def test_blocked_headers_not_duplicated_by_proxy(self): + client_headers = { + "authorization": "Bearer xxx", + "host": "evil.example.com", + "connection": "upgrade", + } + headers = await self._run_proxy_with_server(client_headers) assert headers.get("authorization") == "Bearer xxx" - assert "x-pictor-callid" not in headers - assert "x-rock-sandbox-default-upstream" not in headers - assert "web-server-type" not in headers + assert headers.get("host") != "evil.example.com" async def test_no_extra_headers_backward_compatible(self): headers = await self._run_proxy_with_server({})