From 4f5bf2e6e9e30fc9cf5fce532f608cad98ff9044 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Mon, 11 May 2026 18:39:10 +0900 Subject: [PATCH 1/2] Python: DevUI: tighten default access controls and CORS posture Adjusts the default configuration of the DevUI server so the out-of-the-box posture matches what most callers expect when running locally. Adds explicit opt-outs for callers who need the previous behavior. - DevServer gains auth_enabled and auth_token constructor params; auth is on by default. Auto-generates and logs a token when none provided. - CORS default is an empty allowlist on every host. Callers wanting cross-origin pass cors_origins explicitly. - Streaming /v1/responses no longer sets Access-Control-Allow-Origin directly; CORSMiddleware owns all CORS decisions. - Loopback binds enforce a Host-header allowlist. - /meta moved out of the auth bypass list (was alongside /health and /). - serve() default flipped to auth_enabled=True; passes auth args through to DevServer instead of using env-var indirection. - CLI: --auth opt-in replaced with --no-auth opt-out; --auth-token preserved. - Tests cover the eight behaviors above in test_server.py. --- .../devui/agent_framework_devui/__init__.py | 55 ++---- .../devui/agent_framework_devui/_cli.py | 8 +- .../devui/agent_framework_devui/_server.py | 99 ++++++++-- .../packages/devui/tests/devui/test_server.py | 186 +++++++++++++++++- .../tests/devui/test_ui_memory_regression.py | 2 +- 5 files changed, 280 insertions(+), 70 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/__init__.py b/python/packages/devui/agent_framework_devui/__init__.py index 6af274743a..b647c60fed 100644 --- a/python/packages/devui/agent_framework_devui/__init__.py +++ b/python/packages/devui/agent_framework_devui/__init__.py @@ -96,7 +96,7 @@ def serve( ui_enabled: bool = True, instrumentation_enabled: bool = False, mode: str = "developer", - auth_enabled: bool = False, + auth_enabled: bool = True, auth_token: str | None = None, ) -> None: """Launch Agent Framework DevUI with simple API. @@ -126,52 +126,29 @@ def serve( if not isinstance(port, int) or not (1 <= port <= 65535): raise ValueError(f"Invalid port: {port}. Must be integer between 1 and 65535") - # Security check: Warn if network-exposed without authentication + # Security check: warn loudly when network-exposed without authentication. if host not in ("127.0.0.1", "localhost") and not auth_enabled: - logger.warning("⚠️ WARNING: Exposing DevUI to network without authentication!") - logger.warning("⚠️ This is INSECURE - anyone on your network can access your agents") - logger.warning("💡 For network exposure, add --auth flag: devui --host 0.0.0.0 --auth") + logger.warning("WARNING: Exposing DevUI to the network with --no-auth.") + logger.warning("Anyone on your network can read agent metadata and trigger requests.") + logger.warning("Drop --no-auth and DevUI will require Bearer tokens.") - # Handle authentication configuration - if auth_enabled: + # Refuse to auto-generate a token for network-exposed binds. Auto-generated tokens + # are fine for localhost convenience; for anything else, require an explicit token. + if auth_enabled and not auth_token: import os - import secrets - # Check if token is in environment variable first - if not auth_token: - auth_token = os.environ.get("DEVUI_AUTH_TOKEN") - - # Auto-generate token if STILL not provided - if not auth_token: - # Check if we're in a production-like environment + env_token = os.environ.get("DEVUI_AUTH_TOKEN") + if not env_token: is_production = ( - host not in ("127.0.0.1", "localhost") # Exposed to network - or os.environ.get("CI") == "true" # Running in CI - or os.environ.get("KUBERNETES_SERVICE_HOST") # Running in k8s + host not in ("127.0.0.1", "localhost") + or os.environ.get("CI") == "true" + or os.environ.get("KUBERNETES_SERVICE_HOST") ) - if is_production: - # REFUSE to start without explicit token - logger.error("❌ Authentication enabled but no token provided") - logger.error("❌ Auto-generated tokens are NOT secure for network-exposed deployments") - logger.error("💡 Set token: export DEVUI_AUTH_TOKEN=") - logger.error("💡 Or pass: serve(entities=[...], auth_token='your-token')") + logger.error("Authentication required but no token provided.") + logger.error("Set DEVUI_AUTH_TOKEN env var or pass auth_token='...' to serve().") raise ValueError("DEVUI_AUTH_TOKEN required when host is not localhost") - # Development mode: auto-generate and show - auth_token = secrets.token_urlsafe(32) - logger.info("🔒 Authentication enabled with auto-generated token") - logger.info("\n" + "=" * 70) - logger.info("🔑 DEV TOKEN (localhost only, shown once):") - logger.info(f" {auth_token}") - logger.info("=" * 70 + "\n") - else: - logger.info("🔒 Authentication enabled with provided token") - - # Set environment variable for server to use - os.environ["AUTH_REQUIRED"] = "true" - os.environ["DEVUI_AUTH_TOKEN"] = auth_token - # Enable instrumentation if requested if instrumentation_enabled: from agent_framework.observability import enable_instrumentation @@ -187,6 +164,8 @@ def serve( cors_origins=cors_origins, ui_enabled=ui_enabled, mode=mode, + auth_enabled=auth_enabled, + auth_token=auth_token, ) # Register in-memory entities if provided diff --git a/python/packages/devui/agent_framework_devui/_cli.py b/python/packages/devui/agent_framework_devui/_cli.py index 261cfe4331..e5e64b6fd4 100644 --- a/python/packages/devui/agent_framework_devui/_cli.py +++ b/python/packages/devui/agent_framework_devui/_cli.py @@ -79,15 +79,15 @@ def create_cli_parser() -> argparse.ArgumentParser: ) parser.add_argument( - "--auth", + "--no-auth", action="store_true", - help="Enable authentication via Bearer token (required for deployed environments)", + help="Disable Bearer token authentication. DevUI is auth-enabled by default; use this to opt out.", ) parser.add_argument( "--auth-token", type=str, - help="Custom authentication token (auto-generated if not provided with --auth)", + help="Custom Bearer token. Auto-generated and logged at startup when omitted.", ) parser.add_argument("--version", action="version", version=f"Agent Framework DevUI {get_version()}") @@ -184,7 +184,7 @@ def main() -> None: ui_enabled=ui_enabled, instrumentation_enabled=args.instrumentation, mode=mode, - auth_enabled=args.auth, + auth_enabled=not args.no_auth, auth_token=args.auth_token, # Pass through explicit token only ) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index ff26937843..bf716aa5ba 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -75,6 +75,8 @@ def __init__( cors_origins: list[str] | None = None, ui_enabled: bool = True, mode: str = "developer", + auth_enabled: bool = True, + auth_token: str | None = None, ) -> None: """Initialize the development server. @@ -85,20 +87,26 @@ def __init__( cors_origins: List of allowed CORS origins ui_enabled: Whether to enable the UI mode: Server mode - 'developer' (full access, verbose errors) or 'user' (restricted APIs, generic errors) + auth_enabled: Whether to require Bearer token auth on /v1/* endpoints. Defaults to True. + auth_token: Bearer token. If None and auth_enabled, falls back to the DEVUI_AUTH_TOKEN + environment variable, then to an auto-generated token (logged at startup). """ self.entities_dir = entities_dir self.port = port self.host = host - # Smart CORS defaults: permissive for localhost, restrictive for network-exposed deployments + # CORS default is same-origin only (empty allowlist) on every host. The + # previous wildcard-on-localhost default let any webpage the developer + # visited read DevUI's responses cross-origin. Callers who need a real + # cross-origin dev frontend pass an explicit allowlist. if cors_origins is None: - # Localhost development: allow cross-origin for dev tools (e.g., frontend dev server) - # Network-exposed: empty list (same-origin only, no CORS) - cors_origins = ["*"] if host in ("127.0.0.1", "localhost") else [] + cors_origins = [] self.cors_origins = cors_origins self.ui_enabled = ui_enabled self.mode = mode + self.auth_enabled = auth_enabled + self.auth_token = self._resolve_auth_token(auth_enabled, auth_token) self.executor: AgentFrameworkExecutor | None = None self.openai_executor: OpenAIExecutor | None = None self.deployment_manager = DeploymentManager() @@ -110,6 +118,37 @@ def set_pending_entities(self, entities: list[Any]) -> None: """Set in-memory entities to register on startup.""" self._pending_entities = entities + _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "[::1]", "::1"}) + + def _loopback_allowed_hosts(self) -> frozenset[str] | None: + """Return the Host-header allowlist when bound to a loopback interface, else None. + + Returning None disables Host-header enforcement (e.g. for 0.0.0.0 / public binds, + where the operator is intentionally exposing the service). + """ + host = self.host.lower() + if host not in self._LOOPBACK_HOSTS: + return None + return self._LOOPBACK_HOSTS + + @staticmethod + def _resolve_auth_token(auth_enabled: bool, auth_token: str | None) -> str | None: + """Resolve the active Bearer token. Returns None when auth is disabled.""" + if not auth_enabled: + return None + if auth_token: + return auth_token + env_token = os.getenv("DEVUI_AUTH_TOKEN") + if env_token: + return env_token + generated = secrets.token_urlsafe(32) + logger.info("=" * 70) + logger.info("DevUI authentication enabled with auto-generated token:") + logger.info(f" {generated}") + logger.info("Pass it as: Authorization: Bearer ") + logger.info("=" * 70) + return generated + def _is_dev_mode(self) -> bool: """Check if running in developer mode. @@ -336,6 +375,35 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: lifespan=lifespan, ) + # Host-header allowlist for loopback binds. Defends against DNS rebinding: + # an attacker page resolves their domain to 127.0.0.1 in the victim's browser + # to reach DevUI without tripping CORS. Reject by Host header before any handler. + allowed_hosts = self._loopback_allowed_hosts() + if allowed_hosts is not None: + expected_hosts = allowed_hosts + + @app.middleware("http") + async def host_header_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any: + host_header = request.headers.get("host", "") + hostname = host_header.split(":", 1)[0].lower() + if hostname and hostname not in expected_hosts: + return JSONResponse( + status_code=400, + content={ + "error": { + "message": ( + f"Invalid Host header '{host_header}'. DevUI is bound to a " + "loopback interface and only accepts requests addressed to it." + ), + "type": "invalid_host", + "code": "host_not_allowed", + } + }, + ) + return await call_next(request) + + _ = host_header_middleware + # Add CORS middleware # Note: allow_credentials cannot be True when allow_origins is ["*"] # For localhost dev with wildcard origins, credentials are disabled @@ -350,29 +418,24 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: allow_headers=["*"], ) - # Add authentication middleware using decorator pattern - # Auth is enabled by presence of DEVUI_AUTH_TOKEN - auth_token = os.getenv("DEVUI_AUTH_TOKEN", "") - auth_required = bool(auth_token) - - if auth_required: + # Bearer-token authentication. Enabled by default; opt out via + # DevServer(auth_enabled=False) for embedded/test scenarios. + if self.auth_enabled and self.auth_token: + expected_token = self.auth_token logger.info("Authentication middleware enabled") @app.middleware("http") async def auth_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any: """Validate Bearer token authentication. - Skips authentication for health, meta, static UI endpoints, and OPTIONS requests. + Skips authentication for health, the UI shell, static assets, and OPTIONS preflight. """ - # Skip auth for OPTIONS (CORS preflight) requests if request.method == "OPTIONS": return await call_next(request) - # Skip auth for health checks, meta endpoint, and static files - if request.url.path in ["/health", "/meta", "/"] or request.url.path.startswith("/assets"): + if request.url.path in ["/health", "/"] or request.url.path.startswith("/assets"): return await call_next(request) - # Check Authorization header auth_header = request.headers.get("Authorization") if not auth_header or not auth_header.startswith("Bearer "): return JSONResponse( @@ -388,9 +451,8 @@ async def auth_middleware(request: Request, call_next: Callable[[Request], Await }, ) - # Extract and validate token token = auth_header.replace("Bearer ", "", 1).strip() - if not secrets.compare_digest(token, auth_token): + if not secrets.compare_digest(token, expected_token): return JSONResponse( status_code=401, content={ @@ -402,7 +464,6 @@ async def auth_middleware(request: Request, call_next: Callable[[Request], Await }, ) - # Token valid, proceed return await call_next(request) _ = auth_middleware @@ -750,7 +811,6 @@ async def create_response(request: AgentFrameworkRequest, raw_request: Request) headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", }, ) return await openai_executor.execute_sync(request) @@ -794,7 +854,6 @@ async def create_response(request: AgentFrameworkRequest, raw_request: Request) headers={ "Cache-Control": "no-cache", "Connection": "keep-alive", - "Access-Control-Allow-Origin": "*", "X-Response-ID": response_id, # Include ID for debugging/tracking }, ) diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 3f1945be3a..9c96cda8a8 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -3,11 +3,15 @@ """Focused tests for server functionality.""" import asyncio +import inspect import tempfile from pathlib import Path import pytest +from conftest import MockAgent +from fastapi.testclient import TestClient +import agent_framework_devui from agent_framework_devui import DevServer from agent_framework_devui._utils import extract_executor_message_types, select_primary_input_type from agent_framework_devui.models._openai_custom import AgentFrameworkRequest @@ -99,11 +103,11 @@ async def test_server_execution_streaming(test_entities_dir): def test_configuration(): """Test basic configuration.""" - server = DevServer(entities_dir="test", port=9000, host="localhost") + server = DevServer(entities_dir="test", port=9000, host="localhost", auth_enabled=False) assert server.port == 9000 assert server.host == "localhost" assert server.entities_dir == "test" - assert server.cors_origins == ["*"] + assert server.cors_origins == [] assert server.ui_enabled @@ -252,15 +256,18 @@ async def test_api_restrictions_in_user_mode(): """Test that developer APIs are restricted in user mode.""" from fastapi.testclient import TestClient - # Create servers with different modes - dev_server = DevServer(mode="developer") - user_server = DevServer(mode="user") + # Create servers with different modes. auth_enabled=False isolates this test + # to mode behavior — auth has its own dedicated suite. + dev_server = DevServer(mode="developer", auth_enabled=False) + user_server = DevServer(mode="user", auth_enabled=False) dev_app = dev_server.create_app() user_app = user_server.create_app() - dev_client = TestClient(dev_app) - user_client = TestClient(user_app) + # base_url sets the Host header to a loopback alias so the host-header + # allowlist (DNS-rebinding guard) accepts the request. + dev_client = TestClient(dev_app, base_url="http://127.0.0.1") + user_client = TestClient(user_app, base_url="http://127.0.0.1") # Test 1: Health endpoint should work in both modes assert dev_client.get("/health").status_code == 200 @@ -403,3 +410,168 @@ async def test_checkpoint_api_endpoints(test_entities_dir): # Test delete non-existent checkpoint deleted = await storage.delete("nonexistent") assert deleted is False + + +# ============================================================================= +# Security posture: default CORS, auth, host-header, and streaming headers. +# ============================================================================= + + +def _server_with_mock_agent(**kwargs) -> DevServer: + """Build a DevServer with one in-memory mock agent registered.""" + server = DevServer(**kwargs) + server.set_pending_entities([MockAgent(id="mock", name="Mock", response_text="hi")]) + return server + + +def test_streaming_response_does_not_hardcode_acao_header(): + """A streaming /v1/responses must not set Access-Control-Allow-Origin itself. + + The endpoint previously hardcoded `Access-Control-Allow-Origin: *` on the + StreamingResponse, bypassing CORSMiddleware. With no Origin header on the + request, CORSMiddleware never adds ACAO — so any ACAO we see proves the + streaming handler is still setting it. + """ + server = _server_with_mock_agent() + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + response = client.post( + "/v1/responses", + json={"metadata": {"entity_id": "mock"}, "input": "hello", "stream": True}, + ) + + assert "access-control-allow-origin" not in {k.lower() for k in response.headers}, ( + "Streaming response sets ACAO directly, bypassing CORSMiddleware" + ) + + +def test_cors_default_does_not_allow_arbitrary_origin_even_on_localhost(): + """Default CORS must not echo Access-Control-Allow-Origin to arbitrary origins. + + Previous default was `["*"]` on localhost binds, which let any webpage the + developer visited read DevUI's responses. Default is now `[]` — opt in by + passing `cors_origins=[...]` explicitly. + """ + server = _server_with_mock_agent(host="127.0.0.1") + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + preflight = client.options( + "/v1/entities", + headers={ + "Origin": "https://evil.example", + "Access-Control-Request-Method": "GET", + }, + ) + assert preflight.headers.get("access-control-allow-origin") not in ("*", "https://evil.example") + + actual = client.get("/v1/entities", headers={"Origin": "https://evil.example"}) + assert actual.headers.get("access-control-allow-origin") not in ("*", "https://evil.example") + + +def test_devserver_requires_auth_by_default(monkeypatch): + """A bare DevServer() must reject unauthenticated /v1/* requests. + + Previously auth was opt-in via DEVUI_AUTH_TOKEN env var. The drive-by class + of attacks relies on the localhost API being unauthenticated by default. + """ + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer() + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + response = client.get("/v1/entities") + + assert response.status_code == 401 + + +def test_devserver_auth_can_be_explicitly_disabled(monkeypatch): + """Callers can opt out of auth with auth_enabled=False (escape hatch for tests / trusted hosts).""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = _server_with_mock_agent(auth_enabled=False) + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + response = client.get("/v1/entities") + + assert response.status_code == 200 + + +def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): + """When auth is on, supplying the configured Bearer token grants access.""" + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(auth_token="s3cret") + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + response = client.get("/v1/entities", headers={"Authorization": "Bearer s3cret"}) + + assert response.status_code == 200 + + +def test_meta_endpoint_requires_auth(monkeypatch): + """/meta exposes capability flags (deployment, instrumentation, version) — gate it behind auth. + + Previously /meta was in the auth-bypass list alongside /health and /. That let an unauthenticated + cross-origin caller fingerprint the deployment to decide whether to bother attacking. + """ + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(auth_token="s3cret") + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + unauth = client.get("/meta") + assert unauth.status_code == 401 + + ok = client.get("/meta", headers={"Authorization": "Bearer s3cret"}) + assert ok.status_code == 200 + + +def test_loopback_bind_rejects_non_allowlisted_host_header(monkeypatch): + """A loopback-bound server must reject requests with a non-loopback Host header. + + DNS-rebinding mitigation: an attacker page resolves `evil.example` to 127.0.0.1 + in the victim's browser; the browser sends `Host: evil.example` to DevUI. + CORSMiddleware blocks reading the response, but side-effect endpoints (deployments, + cancel) still execute. Reject by Host header before any handler runs. + """ + monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) + + server = DevServer(host="127.0.0.1", auth_enabled=False) + app = server.get_app() + + with TestClient(app, base_url="http://127.0.0.1") as client: + rebound = client.get("/health", headers={"Host": "evil.example"}) + assert rebound.status_code == 400 + + ok = client.get("/health", headers={"Host": "127.0.0.1"}) + assert ok.status_code == 200 + + ok_localhost = client.get("/health", headers={"Host": "localhost:8080"}) + assert ok_localhost.status_code == 200 + + +def test_serve_defaults_to_auth_enabled(): + """`serve()`'s public signature must default to auth_enabled=True.""" + sig = inspect.signature(agent_framework_devui.serve) + assert sig.parameters["auth_enabled"].default is True, ( + "serve() must default to auth_enabled=True so `devui ./agents` is secure out of the box" + ) + + +def test_cli_enables_auth_by_default_and_supports_no_auth_optout(): + """`devui ./agents` must produce auth-enabled config; `--no-auth` is the explicit escape hatch.""" + from agent_framework_devui._cli import create_cli_parser + + parser = create_cli_parser() + + default_args = parser.parse_args([]) + assert default_args.no_auth is False, "Default CLI invocation should leave auth on" + + optout_args = parser.parse_args(["--no-auth"]) + assert optout_args.no_auth is True diff --git a/python/packages/devui/tests/devui/test_ui_memory_regression.py b/python/packages/devui/tests/devui/test_ui_memory_regression.py index b042764f6c..cc3ec9056d 100644 --- a/python/packages/devui/tests/devui/test_ui_memory_regression.py +++ b/python/packages/devui/tests/devui/test_ui_memory_regression.py @@ -582,7 +582,7 @@ def test_sample_peak_renderer_rss_mb_uses_browser_process_tree( def memory_regression_server() -> Generator[tuple[str, str]]: """Start DevUI with a synthetic streaming agent and yield the base URL plus entity ID.""" - server = DevServer(host="127.0.0.1", port=0) + server = DevServer(host="127.0.0.1", port=0, auth_enabled=False) server.register_entities([ MemoryStressAgent( id="memory-stream-agent", From ed28365198059fd25508c20b1e84534c59779ab0 Mon Sep 17 00:00:00 2001 From: Evan Mattson Date: Tue, 12 May 2026 06:50:25 +0900 Subject: [PATCH 2/2] Python: DevUI: address PR review comments - /meta now derives auth_required from self.auth_enabled instead of reading DEVUI_AUTH_TOKEN, so the auto-generated and explicit auth_token paths report correctly. - Reorder middleware so the loopback Host-header allowlist is registered last; Starlette wraps later-added middleware around earlier-added ones, so the host check now runs outermost (before CORS/auth) as intended. - Rework comments to describe the behavior rather than threat scenarios. - Streaming-headers and CORS tests now construct the server with an explicit auth_token and send a Bearer header, so the assertions actually exercise the streaming/CORS path instead of short-circuiting in the auth middleware. --- .../devui/agent_framework_devui/_server.py | 66 ++++++++++--------- .../packages/devui/tests/devui/test_server.py | 29 ++++---- 2 files changed, 51 insertions(+), 44 deletions(-) diff --git a/python/packages/devui/agent_framework_devui/_server.py b/python/packages/devui/agent_framework_devui/_server.py index bf716aa5ba..416821f40e 100644 --- a/python/packages/devui/agent_framework_devui/_server.py +++ b/python/packages/devui/agent_framework_devui/_server.py @@ -375,34 +375,10 @@ async def lifespan(app: FastAPI) -> AsyncGenerator[None]: lifespan=lifespan, ) - # Host-header allowlist for loopback binds. Defends against DNS rebinding: - # an attacker page resolves their domain to 127.0.0.1 in the victim's browser - # to reach DevUI without tripping CORS. Reject by Host header before any handler. - allowed_hosts = self._loopback_allowed_hosts() - if allowed_hosts is not None: - expected_hosts = allowed_hosts - - @app.middleware("http") - async def host_header_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any: - host_header = request.headers.get("host", "") - hostname = host_header.split(":", 1)[0].lower() - if hostname and hostname not in expected_hosts: - return JSONResponse( - status_code=400, - content={ - "error": { - "message": ( - f"Invalid Host header '{host_header}'. DevUI is bound to a " - "loopback interface and only accepts requests addressed to it." - ), - "type": "invalid_host", - "code": "host_not_allowed", - } - }, - ) - return await call_next(request) - - _ = host_header_middleware + # Middleware registration order matters: Starlette wraps later-added + # middleware around earlier-added ones, so the LAST registered runs + # outermost (sees the request first). We want Host-header enforcement + # to run before CORS/auth, so it is registered last below. # Add CORS middleware # Note: allow_credentials cannot be True when allow_origins is ["*"] @@ -468,6 +444,36 @@ async def auth_middleware(request: Request, call_next: Callable[[Request], Await _ = auth_middleware + # Host-header allowlist for loopback binds: on a loopback interface, only + # accept requests whose Host header names a loopback address. Registered LAST + # so it runs outermost, rejecting non-loopback Host values before CORS/auth + # (and before CORS can short-circuit a preflight on a rebound request). + allowed_hosts = self._loopback_allowed_hosts() + if allowed_hosts is not None: + expected_hosts = allowed_hosts + + @app.middleware("http") + async def host_header_middleware(request: Request, call_next: Callable[[Request], Awaitable[Any]]) -> Any: + host_header = request.headers.get("host", "") + hostname = host_header.split(":", 1)[0].lower() + if hostname and hostname not in expected_hosts: + return JSONResponse( + status_code=400, + content={ + "error": { + "message": ( + f"Invalid Host header '{host_header}'. DevUI is bound to a " + "loopback interface and only accepts requests addressed to it." + ), + "type": "invalid_host", + "code": "host_not_allowed", + } + }, + ) + return await call_next(request) + + _ = host_header_middleware + self._register_routes(app) self._mount_ui(app) @@ -488,8 +494,6 @@ async def health_check() -> dict[str, Any]: @app.get("/meta", response_model=MetaResponse) async def get_meta() -> MetaResponse: """Get server metadata and configuration.""" - import os - # Ensure executors are initialized to check capabilities openai_executor = await self._ensure_openai_executor() @@ -503,7 +507,7 @@ async def get_meta() -> MetaResponse: "openai_proxy": openai_executor.is_configured, "deployment": True, # Deployment feature is available }, - auth_required=bool(os.getenv("DEVUI_AUTH_TOKEN")), + auth_required=self.auth_enabled, ) @app.get("/v1/entities", response_model=DiscoveryResponse) diff --git a/python/packages/devui/tests/devui/test_server.py b/python/packages/devui/tests/devui/test_server.py index 9c96cda8a8..bcb21f4eee 100644 --- a/python/packages/devui/tests/devui/test_server.py +++ b/python/packages/devui/tests/devui/test_server.py @@ -264,8 +264,8 @@ async def test_api_restrictions_in_user_mode(): dev_app = dev_server.create_app() user_app = user_server.create_app() - # base_url sets the Host header to a loopback alias so the host-header - # allowlist (DNS-rebinding guard) accepts the request. + # base_url sets the Host header to a loopback alias so the loopback + # host-header allowlist accepts the request. dev_client = TestClient(dev_app, base_url="http://127.0.0.1") user_client = TestClient(user_app, base_url="http://127.0.0.1") @@ -432,13 +432,14 @@ def test_streaming_response_does_not_hardcode_acao_header(): request, CORSMiddleware never adds ACAO — so any ACAO we see proves the streaming handler is still setting it. """ - server = _server_with_mock_agent() + server = _server_with_mock_agent(auth_token="s3cret") app = server.get_app() with TestClient(app, base_url="http://127.0.0.1") as client: response = client.post( "/v1/responses", json={"metadata": {"entity_id": "mock"}, "input": "hello", "stream": True}, + headers={"Authorization": "Bearer s3cret"}, ) assert "access-control-allow-origin" not in {k.lower() for k in response.headers}, ( @@ -453,7 +454,7 @@ def test_cors_default_does_not_allow_arbitrary_origin_even_on_localhost(): developer visited read DevUI's responses. Default is now `[]` — opt in by passing `cors_origins=[...]` explicitly. """ - server = _server_with_mock_agent(host="127.0.0.1") + server = _server_with_mock_agent(host="127.0.0.1", auth_token="s3cret") app = server.get_app() with TestClient(app, base_url="http://127.0.0.1") as client: @@ -466,15 +467,18 @@ def test_cors_default_does_not_allow_arbitrary_origin_even_on_localhost(): ) assert preflight.headers.get("access-control-allow-origin") not in ("*", "https://evil.example") - actual = client.get("/v1/entities", headers={"Origin": "https://evil.example"}) + actual = client.get( + "/v1/entities", + headers={"Origin": "https://evil.example", "Authorization": "Bearer s3cret"}, + ) assert actual.headers.get("access-control-allow-origin") not in ("*", "https://evil.example") def test_devserver_requires_auth_by_default(monkeypatch): """A bare DevServer() must reject unauthenticated /v1/* requests. - Previously auth was opt-in via DEVUI_AUTH_TOKEN env var. The drive-by class - of attacks relies on the localhost API being unauthenticated by default. + Previously auth was opt-in via DEVUI_AUTH_TOKEN env var; the new default is + auth-on so a bare `devui ./agents` invocation does not expose an open API. """ monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) @@ -516,8 +520,8 @@ def test_devserver_accepts_request_with_valid_bearer_token(monkeypatch): def test_meta_endpoint_requires_auth(monkeypatch): """/meta exposes capability flags (deployment, instrumentation, version) — gate it behind auth. - Previously /meta was in the auth-bypass list alongside /health and /. That let an unauthenticated - cross-origin caller fingerprint the deployment to decide whether to bother attacking. + Previously /meta was in the auth-bypass list alongside /health and /, so any + unauthenticated caller could read the deployment's capability flags. """ monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False) @@ -535,10 +539,9 @@ def test_meta_endpoint_requires_auth(monkeypatch): def test_loopback_bind_rejects_non_allowlisted_host_header(monkeypatch): """A loopback-bound server must reject requests with a non-loopback Host header. - DNS-rebinding mitigation: an attacker page resolves `evil.example` to 127.0.0.1 - in the victim's browser; the browser sends `Host: evil.example` to DevUI. - CORSMiddleware blocks reading the response, but side-effect endpoints (deployments, - cancel) still execute. Reject by Host header before any handler runs. + On a loopback bind, only Host values that name a loopback address are valid; + anything else (e.g. an external hostname that happens to resolve to 127.0.0.1) + is rejected before any handler runs. """ monkeypatch.delenv("DEVUI_AUTH_TOKEN", raising=False)