fix(sandbox): the sandbox provisioner api exposes endpoints need to check API KEY (#4116)

* fix: V-001 security vulnerability

Automated security fix generated by OrbisAI Security

* fix(sandbox): wire provisioner API key through backend client and config

RemoteSandboxBackend now accepts an api_key parameter and sends it as
X-API-Key on all five provisioner HTTP calls (list, create, destroy,
is_alive, discover). AioSandboxProvider reads provisioner_api_key from
SandboxConfig and forwards it at construction time. SandboxConfig
formally declares the field; config.example.yaml documents it under
Option 4; docker-compose-dev.yaml threads PROVISIONER_API_KEY into both
the provisioner and gateway containers so a single .env entry covers
both sides.

Tests: monkeypatch PROVISIONER_API_KEY and send X-API-Key headers in the
five parametrized threading tests (previously 401-failing); new
test_auth_middleware asserts /health is open, /api/* rejects no-header
and wrong-key with 401, and accepts the correct key.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(provisioner): correct fail-closed auth docs and fix test mock signatures

The first round of PR #4116 fixes left four blocking issues:
- Mock signatures in test_remote_sandbox_backend.py (17) and
  test_aio_sandbox_provider.py (2) didn't accept the new headers= kwarg,
  causing TypeError on every provisioner HTTP call test.
- sandbox_config.py and config.example.yaml described the auth as
  optional ("leave unset to disable") but the middleware is fail-closed:
  an unset PROVISIONER_API_KEY causes 401 on every /api/* request.
- .env.example had no PROVISIONER_API_KEY entry, leaving users with an
  empty value and silent 401s.
- No test covered the PROVISIONER_API_KEY="" fail-closed path.

Fix all four: add headers=None to all mock signatures, correct the
field description and example comment to state that both sides must
have the same key set, add PROVISIONER_API_KEY to .env.example with
generation guidance, add test_auth_middleware_unset_key, and add a
logger.warning on auth rejection for observability.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OrbisAI Security
2026-07-14 07:51:59 +08:00
committed by GitHub
co-authored by Claude Sonnet 4.6
parent e32456d337
commit 8cc4b3abeb
10 changed files with 142 additions and 27 deletions
+5
View File
@@ -60,6 +60,11 @@ INFOQUEST_API_KEY=your-infoquest-api-key
# when you run Gateway workers outside the bundled deploy script.
# DEER_FLOW_INTERNAL_AUTH_TOKEN=your-shared-internal-token
# Provisioner API key for sandbox authentication (required when using provisioner/K8s sandbox mode).
# The same value must be set on the provisioner container and in config.yaml sandbox.provisioner_api_key.
# Generate: openssl rand -hex 32
# PROVISIONER_API_KEY=your-provisioner-api-key
# ── Frontend SSR → Gateway wiring ─────────────────────────────────────────────
# The Next.js server uses these to reach the Gateway during SSR (auth checks,
# /api/* rewrites). They default to localhost values that match `make dev` and
@@ -190,7 +190,8 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
provisioner_url = self._config.get("provisioner_url")
if provisioner_url:
logger.info(f"Using remote sandbox backend with provisioner at {provisioner_url}")
return RemoteSandboxBackend(provisioner_url=provisioner_url)
api_key = self._config.get("provisioner_api_key", "")
return RemoteSandboxBackend(provisioner_url=provisioner_url, api_key=api_key)
logger.info("Using local container sandbox backend")
return LocalContainerBackend(
@@ -221,6 +222,7 @@ class AioSandboxProvider(WarmPoolLifecycleMixin[SandboxInfo], SandboxProvider):
"environment": self._resolve_env_vars(sandbox_config.environment or {}),
# provisioner URL for dynamic pod management (e.g. http://provisioner:8002)
"provisioner_url": getattr(sandbox_config, "provisioner_url", None) or "",
"provisioner_api_key": getattr(sandbox_config, "provisioner_api_key", None) or "",
}
@staticmethod
@@ -41,21 +41,28 @@ class RemoteSandboxBackend(SandboxBackend):
sandbox:
use: deerflow.community.aio_sandbox:AioSandboxProvider
provisioner_url: http://provisioner:8002
provisioner_api_key: $PROVISIONER_API_KEY
"""
def __init__(self, provisioner_url: str):
"""Initialize with the provisioner service URL.
def __init__(self, provisioner_url: str, api_key: str = ""):
"""Initialize with the provisioner service URL and optional API key.
Args:
provisioner_url: URL of the provisioner service
(e.g., ``http://provisioner:8002``).
api_key: Value sent as ``X-API-Key`` header on every request.
Leave empty to send no authentication header.
"""
self._provisioner_url = provisioner_url.rstrip("/")
self._api_key = api_key
@property
def provisioner_url(self) -> str:
return self._provisioner_url
def _auth_headers(self) -> dict[str, str]:
return {"X-API-Key": self._api_key} if self._api_key else {}
# ── SandboxBackend interface ──────────────────────────────────────────
def create(
@@ -106,7 +113,7 @@ class RemoteSandboxBackend(SandboxBackend):
def _provisioner_list(self) -> list[SandboxInfo]:
"""GET /api/sandboxes → list all running sandboxes."""
try:
resp = requests.get(f"{self._provisioner_url}/api/sandboxes", timeout=10)
resp = requests.get(f"{self._provisioner_url}/api/sandboxes", headers=self._auth_headers(), timeout=10)
resp.raise_for_status()
data = resp.json()
if not isinstance(data, dict):
@@ -156,6 +163,7 @@ class RemoteSandboxBackend(SandboxBackend):
"user_id": effective_user_id,
"include_legacy_skills": include_legacy_skills,
},
headers=self._auth_headers(),
timeout=30,
)
resp.raise_for_status()
@@ -174,6 +182,7 @@ class RemoteSandboxBackend(SandboxBackend):
try:
resp = requests.delete(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=15,
)
if resp.ok:
@@ -188,6 +197,7 @@ class RemoteSandboxBackend(SandboxBackend):
try:
resp = requests.get(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=10,
)
except requests.RequestException as exc:
@@ -206,6 +216,7 @@ class RemoteSandboxBackend(SandboxBackend):
try:
resp = requests.get(
f"{self._provisioner_url}/api/sandboxes/{sandbox_id}",
headers=self._auth_headers(),
timeout=10,
)
if resp.status_code == 404:
@@ -111,4 +111,14 @@ class SandboxConfig(BaseModel):
),
)
provisioner_api_key: str | None = Field(
default=None,
description=(
"API key sent as X-API-Key header to the provisioner service. "
"Must match PROVISIONER_API_KEY on the provisioner container. "
"Both sides must be set to the same value; "
"the provisioner rejects all /api/* requests when the key is unset or mismatched."
),
)
model_config = ConfigDict(extra="allow")
+2 -2
View File
@@ -368,7 +368,7 @@ def test_remote_backend_create_forwards_effective_user_id(monkeypatch):
def json(self):
return {"sandbox_url": "http://sandbox.local"}
def _post(url, json, timeout): # noqa: A002 - mirrors requests.post kwarg
def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg
posted.update({"url": url, "json": json, "timeout": timeout})
return _Response()
@@ -402,7 +402,7 @@ def test_remote_backend_create_prefers_explicit_user_id(monkeypatch):
def json(self):
return {"sandbox_url": "http://sandbox.local"}
def _post(url, json, timeout): # noqa: A002 - mirrors requests.post kwarg
def _post(url, json, timeout, headers=None): # noqa: A002 - mirrors requests.post kwarg
posted.update({"url": url, "json": json, "timeout": timeout})
return _Response()
@@ -144,14 +144,16 @@ async def test_sandbox_business_routes_run_k8s_client_off_event_loop_thread(
ready_after_service_reads={"sandbox-new": 3},
)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret")
with _detect_provisioner_blocking_io(provisioner_module):
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
headers = {"X-API-Key": "test-secret"}
if json_body is None:
response = await client.request(method, path)
response = await client.request(method, path, headers=headers)
else:
response = await client.request(method, path, json=json_body)
response = await client.request(method, path, json=json_body, headers=headers)
assert response.status_code == 200
assert fake_core_v1.thread_ids
@@ -250,3 +252,50 @@ def test_sandbox_service_supports_cluster_ip_with_dns_url(provisioner_module) ->
assert service.spec.ports[0].port == 8080
assert service.spec.ports[0].target_port == 8080
assert provisioner_module._sandbox_url("abc123") == ("http://sandbox-abc123-svc.mdv-sit.svc.cluster.local:8080")
@pytest.mark.asyncio
async def test_auth_middleware(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
"""Verify the X-API-Key middleware: /health is open; /api/* requires a correct key."""
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "test-secret")
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# /health is always open — no key needed
r = await client.get("/health")
assert r.status_code == 200
# /api/* with no header → 401
r = await client.get("/api/sandboxes")
assert r.status_code == 401
# /api/* with wrong key → 401
r = await client.get("/api/sandboxes", headers={"X-API-Key": "wrong-key"})
assert r.status_code == 401
# /api/* with correct key → not 401 (auth passed; handler runs with the K8s mock)
r = await client.get("/api/sandboxes", headers={"X-API-Key": "test-secret"})
assert r.status_code != 401
@pytest.mark.asyncio
async def test_auth_middleware_unset_key(monkeypatch: pytest.MonkeyPatch, provisioner_module) -> None:
"""When PROVISIONER_API_KEY is unset/empty, all /api/* routes return 401."""
monkeypatch.setattr(provisioner_module, "PROVISIONER_API_KEY", "")
fake_core_v1 = _RecordingCoreV1(event_loop_thread_id=-1)
monkeypatch.setattr(provisioner_module, "core_v1", fake_core_v1)
transport = httpx.ASGITransport(app=provisioner_module.app)
async with httpx.AsyncClient(transport=transport, base_url="http://testserver") as client:
# /health is always open even when key is unset
r = await client.get("/health")
assert r.status_code == 200
# /api/* is always 401 when key is unset — even with a header
r = await client.get("/api/sandboxes")
assert r.status_code == 401
r = await client.get("/api/sandboxes", headers={"X-API-Key": "anything"})
assert r.status_code == 401
+33 -18
View File
@@ -49,9 +49,10 @@ def test_list_running_delegates_to_provisioner_list(monkeypatch):
def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
assert url == "http://provisioner:8002/api/sandboxes"
assert timeout == 10
assert headers == {}
return _StubResponse(
payload={
"sandboxes": [
@@ -70,10 +71,24 @@ def test_provisioner_list_returns_sandbox_infos_and_filters_invalid_entries(monk
assert infos[0].sandbox_url == "http://k3s:31001"
def test_provisioner_list_sends_auth_header_when_api_key_set(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002", api_key="secret")
captured: list[dict] = []
def mock_get(url: str, timeout: int, headers=None):
captured.append({"headers": headers})
return _StubResponse(payload={"sandboxes": []})
monkeypatch.setattr(requests, "get", mock_get)
backend._provisioner_list()
assert captured[0]["headers"] == {"X-API-Key": "secret"}
def test_provisioner_list_returns_empty_on_request_exception(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
raise requests.RequestException("network down")
monkeypatch.setattr(requests, "get", mock_get)
@@ -84,7 +99,7 @@ def test_provisioner_list_returns_empty_on_request_exception(monkeypatch):
def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(payload=[{"sandbox_id": "abc", "sandbox_url": "http://k3s:31001"}])
monkeypatch.setattr(requests, "get", mock_get)
@@ -95,7 +110,7 @@ def test_provisioner_list_returns_empty_when_payload_is_not_dict(monkeypatch):
def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(payload={"sandboxes": {"sandbox_id": "abc"}})
monkeypatch.setattr(requests, "get", mock_get)
@@ -106,7 +121,7 @@ def test_provisioner_list_returns_empty_when_sandboxes_is_not_list(monkeypatch):
def test_provisioner_list_skips_non_dict_sandbox_entries(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(
payload={
"sandboxes": [
@@ -172,7 +187,7 @@ def test_provisioner_create_returns_sandbox_info(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: True)
def mock_post(url: str, json: dict, timeout: int):
def mock_post(url: str, json: dict, timeout: int, headers=None):
assert url == "http://provisioner:8002/api/sandboxes"
assert json == {
"sandbox_id": "abc123",
@@ -194,7 +209,7 @@ def test_provisioner_create_accepts_anonymous_thread_id(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
def mock_post(url: str, json: dict, timeout: int):
def mock_post(url: str, json: dict, timeout: int, headers=None):
assert url == "http://provisioner:8002/api/sandboxes"
assert json == {
"sandbox_id": "anon123",
@@ -216,7 +231,7 @@ def test_provisioner_create_raises_runtime_error_on_request_exception(monkeypatc
backend = RemoteSandboxBackend("http://provisioner:8002")
monkeypatch.setattr(remote_backend_mod, "user_should_see_legacy_skills", lambda user_id: False)
def mock_post(url: str, json: dict, timeout: int):
def mock_post(url: str, json: dict, timeout: int, headers=None):
raise requests.RequestException("boom")
monkeypatch.setattr(requests, "post", mock_post)
@@ -241,7 +256,7 @@ def test_destroy_delegates_to_provisioner_destroy(monkeypatch):
def test_provisioner_destroy_calls_delete(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_delete(url: str, timeout: int):
def mock_delete(url: str, timeout: int, headers=None):
assert url == "http://provisioner:8002/api/sandboxes/abc123"
assert timeout == 15
return _StubResponse(status_code=200)
@@ -254,7 +269,7 @@ def test_provisioner_destroy_calls_delete(monkeypatch):
def test_provisioner_destroy_swallows_request_exception(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_delete(url: str, timeout: int):
def mock_delete(url: str, timeout: int, headers=None):
raise requests.RequestException("network down")
monkeypatch.setattr(requests, "delete", mock_delete)
@@ -278,13 +293,13 @@ def test_is_alive_delegates_to_provisioner_is_alive(monkeypatch):
def test_provisioner_is_alive_true_only_when_status_running(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get_running(url: str, timeout: int):
def mock_get_running(url: str, timeout: int, headers=None):
return _StubResponse(payload={"status": "Running"})
monkeypatch.setattr(requests, "get", mock_get_running)
assert backend._provisioner_is_alive("abc123") is True
def mock_get_pending(url: str, timeout: int):
def mock_get_pending(url: str, timeout: int, headers=None):
return _StubResponse(payload={"status": "Pending"})
monkeypatch.setattr(requests, "get", mock_get_pending)
@@ -294,7 +309,7 @@ def test_provisioner_is_alive_true_only_when_status_running(monkeypatch):
def test_provisioner_is_alive_returns_false_on_404(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(status_code=404)
monkeypatch.setattr(requests, "get", mock_get)
@@ -304,7 +319,7 @@ def test_provisioner_is_alive_returns_false_on_404(monkeypatch):
def test_provisioner_is_alive_raises_on_request_exception(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
raise requests.RequestException("boom")
monkeypatch.setattr(requests, "get", mock_get)
@@ -315,7 +330,7 @@ def test_provisioner_is_alive_raises_on_request_exception(monkeypatch):
def test_provisioner_is_alive_raises_on_server_error(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
response = _StubResponse(status_code=503)
response.text = "unavailable"
return response
@@ -342,7 +357,7 @@ def test_discover_delegates_to_provisioner_discover(monkeypatch):
def test_provisioner_discover_returns_none_on_404(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(status_code=404)
monkeypatch.setattr(requests, "get", mock_get)
@@ -353,7 +368,7 @@ def test_provisioner_discover_returns_none_on_404(monkeypatch):
def test_provisioner_discover_returns_info_on_success(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
return _StubResponse(payload={"sandbox_id": "abc123", "sandbox_url": "http://k3s:31001"})
monkeypatch.setattr(requests, "get", mock_get)
@@ -367,7 +382,7 @@ def test_provisioner_discover_returns_info_on_success(monkeypatch):
def test_provisioner_discover_returns_none_on_request_exception(monkeypatch):
backend = RemoteSandboxBackend("http://provisioner:8002")
def mock_get(url: str, timeout: int):
def mock_get(url: str, timeout: int, headers=None):
raise requests.RequestException("boom")
monkeypatch.setattr(requests, "get", mock_get)
+5
View File
@@ -1167,6 +1167,11 @@ sandbox:
# sandbox:
# use: deerflow.community.aio_sandbox:AioSandboxProvider
# provisioner_url: http://provisioner:8002
# # API key for provisioner authentication. Must match PROVISIONER_API_KEY
# # set on the provisioner container. Both sides must have the same value set;
# # the provisioner rejects all /api/* requests when PROVISIONER_API_KEY is unset.
# # Generate a strong key: openssl rand -hex 32
# # provisioner_api_key: $PROVISIONER_API_KEY
# # Note: provisioner-created Pods use the provisioner's SANDBOX_IMAGE
# # environment variable, not sandbox.image from this config file.
+6
View File
@@ -64,6 +64,9 @@ services:
# Override K8S API server URL since kubeconfig uses 127.0.0.1
# which is unreachable from inside the container
- K8S_API_SERVER=https://host.docker.internal:26443
# Optional: set PROVISIONER_API_KEY in .env to enable provisioner auth.
# The same value must be set on the gateway side via config.yaml sandbox.provisioner_api_key.
- PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-}
env_file:
- ../.env
extra_hosts:
@@ -191,6 +194,9 @@ services:
- DEER_FLOW_HOST_BASE_DIR=${DEER_FLOW_ROOT}/backend/.deer-flow
- DEER_FLOW_HOST_SKILLS_PATH=${DEER_FLOW_ROOT}/skills
- DEER_FLOW_SANDBOX_HOST=host.docker.internal
# Pass PROVISIONER_API_KEY into the gateway container so config.yaml can reference it
# as sandbox.provisioner_api_key: $PROVISIONER_API_KEY
- PROVISIONER_API_KEY=${PROVISIONER_API_KEY:-}
# Proxy values (HTTP_PROXY/HTTPS_PROXY/ALL_PROXY) are inherited from ../.env via env_file.
# Only NO_PROXY is declared here so internal service hostnames are always exempt from the proxy.
- NO_PROXY=${NO_PROXY:-}${NO_PROXY:+,}localhost,127.0.0.1,::1,gateway,frontend,nginx,provisioner,host.docker.internal
+13 -1
View File
@@ -32,11 +32,12 @@ from __future__ import annotations
import logging
import os
import re
import secrets
import time
from contextlib import asynccontextmanager
import urllib3
from fastapi import FastAPI, HTTPException
from fastapi import FastAPI, HTTPException, Request, Response
from kubernetes import client as k8s_client
from kubernetes import config as k8s_config
from kubernetes.client.rest import ApiException
@@ -81,6 +82,7 @@ DEFAULT_USER_ID = "default"
# Path to the kubeconfig *inside* the provisioner container.
# Typically the host's ~/.kube/config is mounted here.
KUBECONFIG_PATH = os.environ.get("KUBECONFIG_PATH", "/root/.kube/config")
PROVISIONER_API_KEY = os.environ.get("PROVISIONER_API_KEY", "")
# The hostname / IP that the backend uses to reach NodePort services. On Docker
# Desktop for macOS this is ``host.docker.internal``; on Linux it may be the
@@ -204,6 +206,16 @@ async def lifespan(_app: FastAPI):
app = FastAPI(title="DeerFlow Sandbox Provisioner", lifespan=lifespan)
@app.middleware("http")
async def verify_api_key(request: Request, call_next):
if request.url.path.startswith("/api/"):
key = request.headers.get("X-API-Key", "")
if not PROVISIONER_API_KEY or not secrets.compare_digest(key, PROVISIONER_API_KEY):
logger.warning("provisioner auth rejected: %s %s", request.method, request.url.path)
return Response(status_code=401, content="Unauthorized")
return await call_next(request)
# ── Request / Response models ───────────────────────────────────────────