mirror of
https://github.com/bytedance/deer-flow.git
synced 2026-07-20 09:45:47 +00:00
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:
co-authored by
Claude Sonnet 4.6
parent
e32456d337
commit
8cc4b3abeb
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 ───────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user