mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
[BREAKING] Python: DevUI: tighten default access controls and CORS posture (#5740)
* 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. * 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.
This commit is contained in:
committed by
GitHub
Unverified
parent
741259476f
commit
fbccad091b
@@ -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=<your-secure-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
|
||||
|
||||
@@ -79,15 +79,15 @@ Examples:
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
@@ -75,6 +75,8 @@ class DevServer:
|
||||
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 @@ class DevServer:
|
||||
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 @@ class DevServer:
|
||||
"""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 <token>")
|
||||
logger.info("=" * 70)
|
||||
return generated
|
||||
|
||||
def _is_dev_mode(self) -> bool:
|
||||
"""Check if running in developer mode.
|
||||
|
||||
@@ -336,6 +375,11 @@ class DevServer:
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# 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 ["*"]
|
||||
# For localhost dev with wildcard origins, credentials are disabled
|
||||
@@ -350,29 +394,24 @@ class DevServer:
|
||||
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 +427,8 @@ class DevServer:
|
||||
},
|
||||
)
|
||||
|
||||
# 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,11 +440,40 @@ class DevServer:
|
||||
},
|
||||
)
|
||||
|
||||
# Token valid, proceed
|
||||
return await call_next(request)
|
||||
|
||||
_ = 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)
|
||||
|
||||
@@ -427,8 +494,6 @@ class DevServer:
|
||||
@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()
|
||||
|
||||
@@ -442,7 +507,7 @@ class DevServer:
|
||||
"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)
|
||||
@@ -750,7 +815,6 @@ class DevServer:
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
},
|
||||
)
|
||||
return await openai_executor.execute_sync(request)
|
||||
@@ -794,7 +858,6 @@ class DevServer:
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"Access-Control-Allow-Origin": "*",
|
||||
"X-Response-ID": response_id, # Include ID for debugging/tracking
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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 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")
|
||||
|
||||
# Test 1: Health endpoint should work in both modes
|
||||
assert dev_client.get("/health").status_code == 200
|
||||
@@ -403,3 +410,171 @@ 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(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}, (
|
||||
"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", auth_token="s3cret")
|
||||
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", "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 new default is
|
||||
auth-on so a bare `devui ./agents` invocation does not expose an open API.
|
||||
"""
|
||||
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 /, so any
|
||||
unauthenticated caller could read the deployment's capability flags.
|
||||
"""
|
||||
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.
|
||||
|
||||
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)
|
||||
|
||||
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
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user