e0e362d700
SDK Tests / SDK CI (push) Blocked by required conditions
SDK Tests / changes (push) Successful in 2m29s
Real E2E Tests / changes (push) Successful in 2m29s
SDK Tests / Python SDK Tests (sandbox) (push) Waiting to run
SDK Tests / CLI Quality (push) Waiting to run
SDK Tests / CLI Tests (push) Waiting to run
SDK Tests / Python SDK Quality (code-interpreter) (push) Waiting to run
SDK Tests / Python SDK Quality (sandbox) (push) Waiting to run
SDK Tests / Python SDK Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / JavaScript SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Kotlin SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (code-interpreter) (push) Waiting to run
SDK Tests / C# SDK Quality And Tests (sandbox) (push) Waiting to run
SDK Tests / Go SDK Quality And Tests (push) Waiting to run
Deploy Docs Pages / build (push) Has been cancelled
Deploy Docs Pages / deploy (push) Has been cancelled
Real E2E Tests / JavaScript E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Python E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Java E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / C# E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Go E2E (docker bridge) (push) Has been cancelled
Real E2E Tests / Real E2E CI (push) Has been cancelled
791 lines
27 KiB
Python
791 lines
27 KiB
Python
# Copyright 2026 Alibaba Group Holding Ltd.
|
|
#
|
|
# Licensed under the Apache License, Version 2.0 (the "License");
|
|
# you may not use this file except in compliance with the License.
|
|
# You may obtain a copy of the License at
|
|
#
|
|
# http://www.apache.org/licenses/LICENSE-2.0
|
|
#
|
|
# Unless required by applicable law or agreed to in writing, software
|
|
# distributed under the License is distributed on an "AS IS" BASIS,
|
|
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
# See the License for the specific language governing permissions and
|
|
# limitations under the License.
|
|
|
|
import asyncio
|
|
import gzip
|
|
from typing import Any, cast
|
|
|
|
import httpx
|
|
from fastapi.testclient import TestClient
|
|
from websockets.typing import Origin
|
|
|
|
import opensandbox_server.api.proxy as proxy_api
|
|
from opensandbox_server.api import lifecycle
|
|
from opensandbox_server.api.schema import Endpoint
|
|
from opensandbox_server.middleware.auth import SANDBOX_API_KEY_HEADER
|
|
from opensandbox_server.services.constants import OPEN_SANDBOX_EGRESS_AUTH_HEADER, OPEN_SANDBOX_INGRESS_HEADER
|
|
from opensandbox_server.services.constants import OPEN_SANDBOX_SECURE_ACCESS_HEADER
|
|
|
|
|
|
class _FakeStreamingResponse:
|
|
def __init__(
|
|
self,
|
|
status_code: int = 200,
|
|
headers: dict | None = None,
|
|
chunks: list[bytes] | None = None,
|
|
raw_chunks: list[bytes] | None = None,
|
|
):
|
|
self.status_code = status_code
|
|
self.headers = httpx.Headers(headers or {})
|
|
self._chunks = chunks or []
|
|
self._raw_chunks = raw_chunks if raw_chunks is not None else self._chunks
|
|
self.aclose_called = False
|
|
self.aiter_bytes_called = False
|
|
self.aiter_raw_called = False
|
|
|
|
async def aiter_bytes(self):
|
|
self.aiter_bytes_called = True
|
|
for chunk in self._chunks:
|
|
yield chunk
|
|
|
|
async def aiter_raw(self):
|
|
self.aiter_raw_called = True
|
|
for chunk in self._raw_chunks:
|
|
yield chunk
|
|
|
|
async def aclose(self):
|
|
self.aclose_called = True
|
|
|
|
|
|
class _FakeAsyncClient:
|
|
def __init__(self):
|
|
self.built = None
|
|
self.response = _FakeStreamingResponse()
|
|
self.raise_connect_error = False
|
|
self.raise_generic_error = False
|
|
|
|
def build_request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
headers: dict,
|
|
content,
|
|
params: str | None = None,
|
|
):
|
|
self.built = {
|
|
"method": method,
|
|
"url": url,
|
|
"params": params,
|
|
"headers": headers,
|
|
"content": content,
|
|
}
|
|
return self.built
|
|
|
|
async def send(self, req, stream: bool = True):
|
|
if self.raise_connect_error:
|
|
raise httpx.ConnectError("connection refused")
|
|
if self.raise_generic_error:
|
|
raise RuntimeError("unexpected proxy error")
|
|
return self.response
|
|
|
|
|
|
def _set_http_client(client: TestClient, fake_client: _FakeAsyncClient) -> None:
|
|
cast(Any, client.app).state.http_client = fake_client
|
|
|
|
|
|
class _FakeBackendWebSocket:
|
|
def __init__(self, message: str = "backend-ready", subprotocol: str | None = "claw.v1"):
|
|
self.message = message
|
|
self.subprotocol = subprotocol
|
|
self.sent: list[str | bytes] = []
|
|
self.close_calls: list[tuple[int, str]] = []
|
|
self._delivered = False
|
|
|
|
async def send(self, payload: str | bytes) -> None:
|
|
self.sent.append(payload)
|
|
|
|
async def recv(self) -> str:
|
|
if not self._delivered:
|
|
self._delivered = True
|
|
return self.message
|
|
await asyncio.Future()
|
|
raise AssertionError("unreachable")
|
|
|
|
async def close(self, code: int = 1000, reason: str = "") -> None:
|
|
self.close_calls.append((code, reason))
|
|
|
|
|
|
class _FakeWebSocketConnector:
|
|
def __init__(self, backend: _FakeBackendWebSocket):
|
|
self.backend = backend
|
|
self.calls: list[dict] = []
|
|
|
|
def __call__(self, uri: str, **kwargs):
|
|
self.calls.append({"uri": uri, **kwargs})
|
|
backend = self.backend
|
|
|
|
class _ContextManager:
|
|
async def __aenter__(self):
|
|
return backend
|
|
|
|
async def __aexit__(self, exc_type, exc, tb):
|
|
return False
|
|
|
|
return _ContextManager()
|
|
|
|
|
|
def test_proxy_forwards_filtered_headers_and_query(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=201,
|
|
headers={"x-backend": "yes"},
|
|
chunks=[b"proxy-ok"],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
headers = {
|
|
**auth_headers,
|
|
"Authorization": "Bearer top-secret",
|
|
"Cookie": "sid=secret",
|
|
"Connection": "keep-alive, X-Hop-Temp",
|
|
"Upgrade": "h2c",
|
|
"Trailer": "X-Checksum",
|
|
"X-Hop-Temp": "drop-me",
|
|
"X-Trace": "trace-1",
|
|
"Forwarded": "for=attacker;proto=https",
|
|
"X-Forwarded-For": "203.0.113.99",
|
|
"X-Forwarded-Host": "attacker.example",
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Real-Ip": "203.0.113.99",
|
|
}
|
|
|
|
response = client.post(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/api/run",
|
|
params={"q": "search"},
|
|
headers=headers,
|
|
content=b'{"hello":"world"}',
|
|
)
|
|
|
|
assert response.status_code == 201
|
|
assert response.content == b"proxy-ok"
|
|
assert response.headers.get("x-backend") == "yes"
|
|
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["method"] == "POST"
|
|
assert fake_client.built["url"] == "http://10.57.1.91:40109/api/run"
|
|
assert fake_client.built["params"] == "q=search"
|
|
forwarded_headers = fake_client.built["headers"]
|
|
lowered_headers = {k.lower(): v for k, v in forwarded_headers.items()}
|
|
assert "host" not in lowered_headers
|
|
assert "connection" not in lowered_headers
|
|
assert "upgrade" not in lowered_headers
|
|
assert "trailer" not in lowered_headers
|
|
assert "authorization" not in lowered_headers
|
|
assert "cookie" not in lowered_headers
|
|
assert SANDBOX_API_KEY_HEADER.lower() not in lowered_headers
|
|
assert "x-hop-temp" not in lowered_headers
|
|
assert lowered_headers.get("x-trace") == "trace-1"
|
|
assert "forwarded" not in lowered_headers
|
|
assert "x-real-ip" not in lowered_headers
|
|
assert lowered_headers.get("x-forwarded-proto") == "http"
|
|
assert lowered_headers.get("x-forwarded-host") != "attacker.example"
|
|
assert lowered_headers.get("x-forwarded-for") != "203.0.113.99"
|
|
assert fake_client.response.aclose_called is True
|
|
|
|
|
|
def test_proxy_root_path_forwards_endpoint_headers_and_query(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:40109/base",
|
|
headers={OPEN_SANDBOX_INGRESS_HEADER: "sbx-123-44772"},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(chunks=[b"root-ok"])
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772",
|
|
params={"q": "search"},
|
|
headers={**auth_headers, "X-Trace": "trace-root"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.content == b"root-ok"
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["url"] == "http://10.57.1.91:40109/base"
|
|
assert fake_client.built["params"] == "q=search"
|
|
lowered_headers = {
|
|
key.lower(): value for key, value in fake_client.built["headers"].items()
|
|
}
|
|
assert lowered_headers["opensandbox-ingress-to"] == "sbx-123-44772"
|
|
assert lowered_headers["x-trace"] == "trace-root"
|
|
|
|
|
|
def test_proxy_rejects_missing_secure_access_header(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Regression test: requests without the required secure-access token are rejected."""
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:40109/base",
|
|
headers={
|
|
OPEN_SANDBOX_INGRESS_HEADER: "sbx-123-44772",
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER: "secure-token",
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(chunks=[b"root-ok"])
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772",
|
|
params={"q": "search"},
|
|
headers={**auth_headers, "X-Trace": "trace-root"},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert fake_client.built is None # request was never forwarded
|
|
|
|
|
|
def test_proxy_rejects_mismatched_secure_access_header(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Regression test: requests with a wrong secure-access token are rejected."""
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:40109/base",
|
|
headers={
|
|
OPEN_SANDBOX_INGRESS_HEADER: "sbx-123-44772",
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER: "server-side-token",
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(chunks=[b"root-ok"])
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772",
|
|
headers={
|
|
**auth_headers,
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER: "wrong-token",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert fake_client.built is None # request was never forwarded
|
|
|
|
|
|
def test_proxy_allows_valid_secure_access_header(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Valid secure-access token passes; header is stripped from forwarded request."""
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:40109/base",
|
|
headers={
|
|
OPEN_SANDBOX_INGRESS_HEADER: "sbx-123-44772",
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER: "server-side-token",
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(chunks=[b"root-ok"])
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772",
|
|
headers={
|
|
**auth_headers,
|
|
OPEN_SANDBOX_SECURE_ACCESS_HEADER: "server-side-token",
|
|
},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
lowered_headers = {
|
|
key.lower(): value for key, value in fake_client.built["headers"].items()
|
|
}
|
|
# Token is stripped from forwarded headers — sandbox app should not receive it
|
|
assert OPEN_SANDBOX_SECURE_ACCESS_HEADER.lower() not in lowered_headers
|
|
# Other endpoint headers are still forwarded
|
|
assert lowered_headers["opensandbox-ingress-to"] == "sbx-123-44772"
|
|
|
|
|
|
def test_proxy_forwards_get_request_with_query_params(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Test that GET requests with query parameters are forwarded correctly.
|
|
|
|
This test verifies the fix for issue #484 where GET requests with query
|
|
parameters were failing with 400 MISSING_QUERY when using use_server_proxy.
|
|
The query string should be passed via httpx params, not embedded in URL.
|
|
"""
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=200,
|
|
headers={"content-type": "application/json"},
|
|
chunks=[b'[{"name":"file.txt","size":100}]'],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/files/search",
|
|
params={"path": "/workspace"},
|
|
headers=auth_headers,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["method"] == "GET"
|
|
assert fake_client.built["url"] == "http://10.57.1.91:40109/files/search"
|
|
assert fake_client.built["params"] == "path=%2Fworkspace"
|
|
assert fake_client.built["content"] is None
|
|
|
|
|
|
def test_proxy_forwards_delete_request_with_body(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
"""Test that DELETE requests with body payload are forwarded correctly.
|
|
|
|
This verifies that DELETE requests with JSON/body payload are not
|
|
incorrectly stripped when proxying.
|
|
"""
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=200,
|
|
headers={"content-type": "application/json"},
|
|
chunks=[b'{"deleted":true}'],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.request(
|
|
"DELETE",
|
|
"/v1/sandboxes/sbx-123/proxy/44772/resources",
|
|
headers=auth_headers,
|
|
content=b'{"id": "resource-123"}',
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["method"] == "DELETE"
|
|
assert fake_client.built["content"] is not None
|
|
|
|
|
|
def test_proxy_filters_response_hop_by_hop_headers(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=200,
|
|
headers={
|
|
"x-backend": "yes",
|
|
"Connection": "keep-alive, X-Hop-Temp",
|
|
"Keep-Alive": "timeout=5",
|
|
"Trailer": "X-Checksum",
|
|
"X-Hop-Temp": "drop-me",
|
|
},
|
|
chunks=[b"proxy-ok"],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/healthz",
|
|
headers=auth_headers,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.content == b"proxy-ok"
|
|
assert response.headers.get("x-backend") == "yes"
|
|
assert response.headers.get("connection") is None
|
|
assert response.headers.get("keep-alive") is None
|
|
assert response.headers.get("trailer") is None
|
|
assert response.headers.get("x-hop-temp") is None
|
|
|
|
|
|
def test_proxy_streams_raw_body_for_content_encoded_response(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService)
|
|
|
|
decoded_body = b"<html>vnc</html>"
|
|
encoded_body = gzip.compress(decoded_body)
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=200,
|
|
headers={
|
|
"content-type": "text/html",
|
|
"content-encoding": "gzip",
|
|
},
|
|
chunks=[decoded_body],
|
|
raw_chunks=[encoded_body],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/8080/vnc/index.html",
|
|
headers={**auth_headers, "Accept-Encoding": "gzip"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert response.headers.get("content-encoding") == "gzip"
|
|
assert response.content == decoded_body
|
|
assert fake_client.response.aiter_raw_called is True
|
|
assert fake_client.response.aiter_bytes_called is False
|
|
assert fake_client.response.aclose_called is True
|
|
|
|
|
|
def test_proxy_rejects_websocket_upgrade(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
_set_http_client(client, _FakeAsyncClient())
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/ws",
|
|
headers={**auth_headers, "Upgrade": "websocket"},
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["message"] == "Websocket upgrade is not supported yet"
|
|
|
|
|
|
def test_proxy_rejects_websocket_upgrade_for_post_and_mixed_case_header(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
_set_http_client(client, _FakeAsyncClient())
|
|
|
|
response = client.post(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/ws",
|
|
headers={**auth_headers, "Upgrade": "WebSocket"},
|
|
content=b"{}",
|
|
)
|
|
|
|
assert response.status_code == 400
|
|
assert response.json()["message"] == "Websocket upgrade is not supported yet"
|
|
|
|
|
|
def test_proxy_websocket_relays_messages_and_forwards_safe_headers(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert sandbox_id == "sbx-123"
|
|
assert port == 44772
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:40109/proxy/44772",
|
|
headers={
|
|
OPEN_SANDBOX_INGRESS_HEADER: "sbx-123-44772",
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Forwarded-For": "198.51.100.20",
|
|
},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
backend = _FakeBackendWebSocket()
|
|
connector = _FakeWebSocketConnector(backend)
|
|
monkeypatch.setattr(proxy_api.websockets, "connect", connector)
|
|
|
|
with client.websocket_connect(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/ws?token=abc",
|
|
headers={
|
|
**auth_headers,
|
|
"Authorization": "Bearer top-secret",
|
|
"Cookie": "sid=secret",
|
|
"Origin": "https://ui.example.com",
|
|
"X-Trace": "trace-ws",
|
|
"Forwarded": "for=attacker;proto=https",
|
|
"X-Forwarded-For": "203.0.113.99",
|
|
"X-Forwarded-Host": "attacker.example",
|
|
"X-Forwarded-Proto": "https",
|
|
"X-Real-Ip": "203.0.113.99",
|
|
},
|
|
subprotocols=["claw.v1"],
|
|
) as websocket:
|
|
assert websocket.receive_text() == "backend-ready"
|
|
websocket.send_text("client-ready")
|
|
|
|
assert backend.sent == ["client-ready"]
|
|
assert backend.close_calls[0][0] == 1000
|
|
|
|
call = connector.calls[0]
|
|
assert call["uri"] == "ws://10.57.1.91:40109/proxy/44772/ws?token=abc"
|
|
assert call["origin"] == Origin("https://ui.example.com")
|
|
assert call["subprotocols"] == ["claw.v1"]
|
|
lowered_headers = {
|
|
key.lower(): value for key, value in (call["additional_headers"] or {}).items()
|
|
}
|
|
assert "authorization" not in lowered_headers
|
|
assert "cookie" not in lowered_headers
|
|
assert "origin" not in lowered_headers
|
|
assert "forwarded" not in lowered_headers
|
|
assert "x-real-ip" not in lowered_headers
|
|
assert lowered_headers["x-forwarded-proto"] == "http"
|
|
assert lowered_headers["x-forwarded-host"] == "testserver"
|
|
assert lowered_headers["x-forwarded-for"] == "testclient"
|
|
assert lowered_headers["opensandbox-ingress-to"] == "sbx-123-44772"
|
|
assert lowered_headers["x-trace"] == "trace-ws"
|
|
|
|
|
|
def test_proxy_maps_connect_error_to_502(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.raise_connect_error = True
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/healthz",
|
|
headers=auth_headers,
|
|
)
|
|
|
|
assert response.status_code == 502
|
|
assert "Could not connect to the backend sandbox" in response.json()["message"]
|
|
|
|
|
|
def test_proxy_maps_unexpected_error_to_500(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
return Endpoint(endpoint="10.57.1.91:40109")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.raise_generic_error = True
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/44772/healthz",
|
|
headers=auth_headers,
|
|
)
|
|
|
|
assert response.status_code == 500
|
|
assert "An internal error occurred in the proxy" in response.json()["message"]
|
|
|
|
|
|
def test_proxy_forwards_18080_without_server_side_egress_auth_check(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert port == 18080
|
|
assert resolve_internal is True
|
|
return Endpoint(
|
|
endpoint="10.57.1.91:18080",
|
|
headers={OPEN_SANDBOX_EGRESS_AUTH_HEADER: "endpoint-token"},
|
|
)
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=401,
|
|
headers={"content-type": "application/json"},
|
|
chunks=[b'{"code":"UNAUTHORIZED"}'],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/18080/policy",
|
|
headers=auth_headers,
|
|
)
|
|
|
|
assert response.status_code == 401
|
|
assert response.json()["code"] == "UNAUTHORIZED"
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["url"] == "http://10.57.1.91:18080/policy"
|
|
lowered_headers = {k.lower(): v for k, v in fake_client.built["headers"].items()}
|
|
assert OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower() not in lowered_headers
|
|
|
|
|
|
def test_proxy_forwards_egress_auth_header_for_18080(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert port == 18080
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:18080")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=200,
|
|
headers={"content-type": "application/json"},
|
|
chunks=[b'{"status":"ok"}'],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/18080/policy",
|
|
headers={**auth_headers, OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"},
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert fake_client.built is not None
|
|
lowered_headers = {k.lower(): v for k, v in fake_client.built["headers"].items()}
|
|
assert lowered_headers[OPEN_SANDBOX_EGRESS_AUTH_HEADER.lower()] == "egress-token"
|
|
|
|
|
|
def test_proxy_active_credential_vault_returns_sidecar_forbidden(
|
|
client: TestClient,
|
|
auth_headers: dict,
|
|
monkeypatch,
|
|
) -> None:
|
|
class StubService:
|
|
@staticmethod
|
|
def get_endpoint(sandbox_id: str, port: int, resolve_internal: bool = False) -> Endpoint:
|
|
assert port == 18080
|
|
assert resolve_internal is True
|
|
return Endpoint(endpoint="10.57.1.91:18080")
|
|
|
|
monkeypatch.setattr(lifecycle, "sandbox_service", StubService())
|
|
|
|
fake_client = _FakeAsyncClient()
|
|
fake_client.response = _FakeStreamingResponse(
|
|
status_code=403,
|
|
headers={"content-type": "text/plain"},
|
|
chunks=[b"forbidden\n"],
|
|
)
|
|
_set_http_client(client, fake_client)
|
|
|
|
response = client.get(
|
|
"/v1/sandboxes/sbx-123/proxy/18080/credential-vault/_active",
|
|
headers={**auth_headers, OPEN_SANDBOX_EGRESS_AUTH_HEADER: "egress-token"},
|
|
)
|
|
|
|
assert response.status_code == 403
|
|
assert response.content == b"forbidden\n"
|
|
assert fake_client.built is not None
|
|
assert fake_client.built["url"] == "http://10.57.1.91:18080/credential-vault/_active"
|