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
163 lines
6.2 KiB
Python
163 lines
6.2 KiB
Python
# Copyright 2025 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.
|
|
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from opensandbox_server.config import AppConfig, IngressConfig, RuntimeConfig, ServerConfig
|
|
from opensandbox_server.middleware.auth import AuthMiddleware
|
|
|
|
|
|
def _app_config_with_api_key() -> AppConfig:
|
|
return AppConfig(
|
|
server=ServerConfig(api_key="secret-key"),
|
|
runtime=RuntimeConfig(type="docker", execd_image="opensandbox/execd:latest"),
|
|
ingress=IngressConfig(mode="direct"),
|
|
)
|
|
|
|
|
|
def _build_test_app():
|
|
app = FastAPI()
|
|
config = _app_config_with_api_key()
|
|
app.add_middleware(AuthMiddleware, config=config)
|
|
|
|
@app.get("/secured")
|
|
def secured_endpoint():
|
|
return {"ok": True}
|
|
|
|
return app
|
|
|
|
|
|
def test_auth_middleware_rejects_missing_key():
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
response = client.get("/secured")
|
|
assert response.status_code == 401
|
|
assert response.json()["code"] == "MISSING_API_KEY"
|
|
|
|
|
|
def test_auth_middleware_accepts_valid_key():
|
|
app = _build_test_app()
|
|
client = TestClient(app)
|
|
response = client.get("/secured", headers={"OPEN-SANDBOX-API-KEY": "secret-key"})
|
|
assert response.status_code == 200
|
|
assert response.json() == {"ok": True}
|
|
|
|
|
|
def test_auth_middleware_skips_validation_for_proxy_to_sandbox():
|
|
"""Proxy-to-sandbox paths must not require API key; server only forwards to sandbox."""
|
|
app = _build_test_app()
|
|
|
|
@app.get("/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}")
|
|
def proxy_echo(sandbox_id: str, port: int, full_path: str):
|
|
return {"proxied": True, "sandbox_id": sandbox_id, "port": port, "path": full_path}
|
|
|
|
client = TestClient(app)
|
|
# No OPEN-SANDBOX-API-KEY header; should still succeed for proxy path
|
|
response = client.get("/sandboxes/abc-123/proxy/8080/foo/bar")
|
|
assert response.status_code == 200
|
|
assert response.json()["proxied"] is True
|
|
assert response.json()["sandbox_id"] == "abc-123"
|
|
assert response.json()["port"] == 8080
|
|
assert response.json()["path"] == "foo/bar"
|
|
|
|
|
|
def test_auth_middleware_v1_proxy_path_exempt():
|
|
"""V1 prefix proxy path is also exempt."""
|
|
app = _build_test_app()
|
|
|
|
@app.get("/v1/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}")
|
|
def proxy_echo(sandbox_id: str, port: int, full_path: str):
|
|
return {"proxied": True}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/v1/sandboxes/sid/proxy/443/")
|
|
assert response.status_code == 200
|
|
assert response.json()["proxied"] is True
|
|
|
|
|
|
def test_auth_middleware_requires_key_for_non_proxy_paths_containing_proxy_and_sandboxes():
|
|
"""Paths that contain both 'proxy' and 'sandboxes' but not in proxy-route shape still require auth."""
|
|
app = _build_test_app()
|
|
|
|
@app.get("/proxy/sandboxes/anything")
|
|
def fake_proxy():
|
|
return {"reached": True}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/proxy/sandboxes/anything")
|
|
assert response.status_code == 401
|
|
assert response.json()["code"] == "MISSING_API_KEY"
|
|
|
|
|
|
def test_auth_middleware_requires_key_for_malformed_proxy_port():
|
|
"""Malformed port (non-numeric) must get 401, not 422; limits unauthenticated surface."""
|
|
app = _build_test_app()
|
|
|
|
@app.get("/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}")
|
|
def proxy_echo(sandbox_id: str, port: int, full_path: str):
|
|
return {"proxied": True}
|
|
|
|
client = TestClient(app)
|
|
response = client.get("/sandboxes/s1/proxy/not-a-port/x")
|
|
assert response.status_code == 401
|
|
assert response.json()["code"] == "MISSING_API_KEY"
|
|
|
|
|
|
def test_auth_middleware_proxy_requires_auth_in_multi_tenant_mode():
|
|
"""In multi-tenant mode, proxy paths must require auth to establish tenant context."""
|
|
from unittest.mock import MagicMock
|
|
|
|
from opensandbox_server.tenants.models import TenantEntry
|
|
|
|
app = FastAPI()
|
|
config = _app_config_with_api_key()
|
|
mock_provider = MagicMock()
|
|
tenant = TenantEntry(name="tenant-a", namespace="ns-a", api_keys=["key-a"])
|
|
mock_provider.lookup.return_value = tenant
|
|
app.add_middleware(AuthMiddleware, config=config, tenant_provider=mock_provider)
|
|
|
|
@app.get("/sandboxes/{sandbox_id}/proxy/{port}/{full_path:path}")
|
|
def proxy_echo(sandbox_id: str, port: int, full_path: str):
|
|
return {"proxied": True}
|
|
|
|
client = TestClient(app)
|
|
|
|
response = client.get("/sandboxes/abc/proxy/8080/foo")
|
|
assert response.status_code == 401, "proxy must require auth in multi-tenant mode"
|
|
|
|
response = client.get(
|
|
"/sandboxes/abc/proxy/8080/foo",
|
|
headers={"OPEN-SANDBOX-API-KEY": "key-a"},
|
|
)
|
|
assert response.status_code == 200
|
|
|
|
|
|
def test_auth_middleware_is_proxy_path_rejects_traversal():
|
|
"""Paths containing '..' are never considered proxy (no auth bypass)."""
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/abc/proxy/8080/../other") is False
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/../admin/proxy/8080") is False
|
|
|
|
|
|
def test_auth_middleware_is_proxy_path_accepts_valid_shapes():
|
|
"""Only exact proxy route shape (including numeric port) is accepted."""
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/id/proxy/8080") is True
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/id/proxy/8080/") is True
|
|
assert AuthMiddleware._is_proxy_path("/v1/sandboxes/id/proxy/443/path") is True
|
|
assert AuthMiddleware._is_proxy_path("/proxy/sandboxes/x") is False
|
|
assert AuthMiddleware._is_proxy_path("/foo/sandboxes/id/proxy/8080") is False
|
|
# Non-numeric port must not skip auth (malformed path → 401, not 422)
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/s1/proxy/not-a-port/x") is False
|
|
assert AuthMiddleware._is_proxy_path("/sandboxes/s1/proxy/8080x/") is False
|