diff --git a/src/lh_harness/webapi/server.py b/src/lh_harness/webapi/server.py index ed3a0e84..fc97b641 100644 --- a/src/lh_harness/webapi/server.py +++ b/src/lh_harness/webapi/server.py @@ -43,6 +43,7 @@ } _MAX_ARTIFACT_BYTES = 8 * 1024 * 1024 +_MAX_CONTROL_BODY_BYTES = 1 * 1024 * 1024 # Agent-produced artifacts are untrusted. Only a small, explicit raster # allow-list is rendered in the dashboard origin; everything else is a @@ -104,6 +105,37 @@ def _is_loopback_host(host: str) -> bool: return False +def _request_hostname(host_header: str) -> str: + """Return the hostname from a Host header, ignoring an optional port.""" + + value = str(host_header or "").strip() + if not value: + return "" + if value.startswith("["): + end = value.find("]") + if end == -1: + return "" + return value[1:end] + if value.count(":") == 1: + return value.rsplit(":", 1)[0] + return value + + +def _host_header_allowed(host_header: str, bind_host: str) -> bool: + hostname = _request_hostname(host_header) + if not hostname: + return False + if _is_loopback_host(hostname): + return True + configured = str(bind_host or "").strip().lower().strip("[]") + return bool(configured) and hostname.lower() == configured + + +def _is_json_content_type(value: str | None) -> bool: + media = (value or "").split(";", 1)[0].strip().lower() + return media == "application/json" + + def _configured_token(explicit: str | None) -> str | None: value = explicit if explicit is not None else os.environ.get("LH_HARNESS_WEB_TOKEN") value = str(value or "").strip() @@ -550,6 +582,7 @@ def create_app( supervisor: RunSupervisor | None = None, auth_token: str | None = None, allowed_origins: set[str] | list[str] | tuple[str, ...] | None = None, + bind_host: str = "127.0.0.1", ) -> FastAPI: """Create an API app over a live shared state or a historical runs root.""" @@ -570,6 +603,7 @@ def create_app( app.state.registry = registry app.state.auth_token = token app.state.allowed_origins = origins + app.state.bind_host = bind_host if supervisor is not None: async def _shutdown_owned_workers() -> None: await asyncio.to_thread(supervisor.shutdown) @@ -582,6 +616,29 @@ async def _security_headers_and_auth(request: Request, call_next): # shell, but every API route (including legacy compatibility routes and # artifact reads) shares one authentication boundary when a token is # configured. + if _is_loopback_host(bind_host) and not _host_header_allowed( + request.headers.get("host", ""), bind_host + ): + return JSONResponse({"detail": "host is not allowed"}, status_code=403) + if request.method in {"POST", "PUT", "PATCH"} and request.url.path.startswith("/api/"): + content_type = request.headers.get("content-type") + content_length = request.headers.get("content-length") + if content_length is not None: + try: + declared = int(content_length) + except ValueError: + return JSONResponse({"detail": "invalid content-length"}, status_code=400) + if declared > _MAX_CONTROL_BODY_BYTES: + return JSONResponse({"detail": "request body is too large"}, status_code=413) + body = await request.body() + if len(body) > _MAX_CONTROL_BODY_BYTES: + return JSONResponse({"detail": "request body is too large"}, status_code=413) + if body or content_type: + if not _is_json_content_type(content_type): + return JSONResponse( + {"detail": "request must be application/json"}, + status_code=415, + ) authenticated = _bearer_matches(request.headers.get("authorization"), token) if token and request.url.path.startswith("/api/") and not authenticated: return JSONResponse( @@ -731,6 +788,9 @@ async def stream( return origin = websocket.headers.get("origin") host = websocket.headers.get("host", "") + if _is_loopback_host(bind_host) and not _host_header_allowed(host, bind_host): + await websocket.close(code=4403, reason="host is not allowed") + return authenticated, selected_subprotocol = _websocket_auth(websocket, token) if not authenticated: await websocket.close(code=4401, reason="invalid or missing bearer token") @@ -1081,6 +1141,7 @@ def run_web_server( supervisor=supervisor, auth_token=token, allowed_origins=allowed_origins, + bind_host=host, ) uvicorn.run(app, host=host, port=port, log_level="info") return 0 @@ -1167,6 +1228,7 @@ def start_web_server( supervisor=supervisor, auth_token=token, allowed_origins=allowed_origins, + bind_host=host, ) config = uvicorn.Config(app, host=host, port=port, log_level="warning", access_log=False) server = uvicorn.Server(config) diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 00000000..209b6d31 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,37 @@ +"""Default TestClient Host to loopback so Host-header hardening stays on.""" + +from __future__ import annotations + +from urllib.parse import urljoin + +import fastapi.testclient as fastapi_testclient +import starlette.testclient as starlette_testclient + +_Orig = starlette_testclient.TestClient +_Upgrade = starlette_testclient._Upgrade + + +class LoopbackTestClient(_Orig): + def __init__(self, app, *args, **kwargs): + kwargs.setdefault("base_url", "http://127.0.0.1") + super().__init__(app, *args, **kwargs) + + def websocket_connect(self, url, subprotocols=None, **kwargs): + # Starlette hardcodes ws://testserver; rewrite so Host stays loopback. + url = urljoin("ws://127.0.0.1", url) + headers = kwargs.get("headers", {}) + headers.setdefault("connection", "upgrade") + headers.setdefault("sec-websocket-key", "testserver==") + headers.setdefault("sec-websocket-version", "13") + if subprotocols is not None: + headers.setdefault("sec-websocket-protocol", ", ".join(subprotocols)) + kwargs["headers"] = headers + try: + super().request("GET", url, **kwargs) + except _Upgrade as exc: + return exc.session + raise RuntimeError("Expected WebSocket upgrade") + + +starlette_testclient.TestClient = LoopbackTestClient +fastapi_testclient.TestClient = LoopbackTestClient diff --git a/tests/webapi/test_hardening.py b/tests/webapi/test_hardening.py index 8b78b0d7..8e24ff20 100644 --- a/tests/webapi/test_hardening.py +++ b/tests/webapi/test_hardening.py @@ -15,7 +15,7 @@ import lh_harness.dashboard.state as dashboard_state from lh_harness.dashboard.state import DashboardState from lh_harness.webapi.events import EventTailer -from lh_harness.webapi.server import create_app +from lh_harness.webapi.server import _MAX_CONTROL_BODY_BYTES, create_app def _run(tmp_path: Path) -> tuple[Path, DashboardState]: @@ -614,3 +614,79 @@ def test_attached_api_rejects_foreign_run_paths(tmp_path: Path) -> None: assert client.get("/api/runs/run-2/snapshot").status_code == 404 assert client.post("/api/runs/run-2/stop").status_code == 404 + + +def test_loopback_host_header_rejects_dns_rebind(tmp_path: Path) -> None: + root, state = _run(tmp_path) + client = TestClient(create_app(state=state, runs_root=root, run_id="run-1")) + + assert client.get("/api/meta").status_code == 200 + assert client.get("/api/meta", headers={"Host": "127.0.0.1:8799"}).status_code == 200 + assert client.get("/api/meta", headers={"Host": "localhost"}).status_code == 200 + assert client.get("/api/meta", headers={"Host": "[::1]:8799"}).status_code == 200 + assert client.get("/api/meta", headers={"Host": "evil.example"}).status_code == 403 + assert client.get("/api/meta", headers={"Host": "evil.example:8799"}).status_code == 403 + assert client.post( + "/api/runs/run-1/instructions", + headers={"Host": "evil.example"}, + json={"instructions": "injected"}, + ).status_code == 403 + + +def test_control_posts_require_json_and_bound_body(tmp_path: Path) -> None: + root, state = _run(tmp_path) + client = TestClient(create_app(state=state, runs_root=root, run_id="run-1")) + + assert client.post( + "/api/runs/run-1/instructions", + content=b'{"instructions":"keep going"}', + headers={"Content-Type": "text/plain"}, + ).status_code == 415 + assert client.post( + "/api/runs/run-1/instructions", + data={"instructions": "keep going"}, + ).status_code == 415 + assert client.post( + "/api/runs/run-1/instructions", + content=b'{"instructions":"keep going"}', + headers={"Content-Type": "application/json; charset=utf-8"}, + ).status_code == 200 + oversized = client.post( + "/api/runs/run-1/instructions", + content=b"x" * (_MAX_CONTROL_BODY_BYTES + 1), + headers={ + "Content-Type": "application/json", + "Content-Length": str(_MAX_CONTROL_BODY_BYTES + 1), + }, + ) + assert oversized.status_code == 413 + + +def test_bearer_and_non_loopback_host_behavior(tmp_path: Path) -> None: + root, state = _run(tmp_path) + loopback = TestClient( + create_app(state=state, runs_root=root, run_id="run-1", auth_token="secret") + ) + assert loopback.get("/api/meta").status_code == 401 + assert loopback.get( + "/api/meta", headers={"Authorization": "Bearer secret"} + ).status_code == 200 + assert loopback.get( + "/api/meta", + headers={"Authorization": "Bearer secret", "Host": "evil.example"}, + ).status_code == 403 + + exposed = TestClient( + create_app( + state=state, + runs_root=root, + run_id="run-1", + auth_token="secret", + bind_host="0.0.0.0", + ) + ) + assert exposed.get("/api/meta", headers={"Host": "evil.example"}).status_code == 401 + assert exposed.get( + "/api/meta", + headers={"Authorization": "Bearer secret", "Host": "lan.example:8799"}, + ).status_code == 200