Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions src/lh_harness/webapi/server.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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."""

Expand All @@ -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)
Expand All @@ -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(
Expand Down Expand Up @@ -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")
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
37 changes: 37 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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
78 changes: 77 additions & 1 deletion tests/webapi/test_hardening.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]:
Expand Down Expand Up @@ -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