diff --git a/backend/druks/browser/login.py b/backend/druks/browser/login.py index 92224fcd..ea9ed01b 100644 --- a/backend/druks/browser/login.py +++ b/backend/druks/browser/login.py @@ -131,10 +131,13 @@ def _key(session_name: str) -> str: def is_same_origin(websocket: WebSocket) -> bool: + # The browser reaches Druks at urls.endpoint. An edge can rewrite Host to its + # upstream address, so Host is the fallback only when no endpoint is set. # A TLS edge leaves us seeing ws while the browser's Origin says https, so # only the host is comparable — and it's the boundary that matters. + endpoint = websocket.app.state.settings.urls.endpoint origins = websocket.headers.getlist("origin") - hosts = websocket.headers.getlist("host") + hosts = [urlsplit(endpoint).netloc] if endpoint else websocket.headers.getlist("host") return ( len(origins) == 1 and len(hosts) == 1 diff --git a/backend/tests/test_browser_session_login_window.py b/backend/tests/test_browser_session_login_window.py index ef8dc3f1..a67a04a3 100644 --- a/backend/tests/test_browser_session_login_window.py +++ b/backend/tests/test_browser_session_login_window.py @@ -277,11 +277,25 @@ async def test_websocket_identity_resolves_and_cross_origin_is_refused(druks_db, assert not is_same_origin(connection) -def test_tls_origin_matches_on_host_alone(): - assert is_same_origin( - _websocket([(b"host", b"druks.test"), (b"origin", b"https://druks.test")]) +@pytest.mark.parametrize( + ("endpoint", "host", "origin", "is_expected"), + [ + ("", "druks.test", "https://druks.test", True), + ("https://druks.example.com", "127.0.0.1:8000", "https://druks.example.com", True), + ("https://druks.example.com", "druks.test", "https://druks.test", False), + ], +) +def test_origin_matches_the_endpoint_host_else_the_host_header( + tmp_path, endpoint, host, origin, is_expected +): + settings = make_settings(tmp_path, urls={"endpoint": endpoint}) + connection = _websocket( + [(b"host", host.encode()), (b"origin", origin.encode())], + app=SimpleNamespace(state=SimpleNamespace(settings=settings)), ) + assert is_same_origin(connection) == is_expected + async def test_websocket_bearer_is_refused(druks_db): connection = _websocket( diff --git a/docs/configuration.md b/docs/configuration.md index f3d31198..c4fc661f 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -146,7 +146,7 @@ caches, and the sandbox provisioning gate. | TOML key | Purpose | | --- | --- | -| `urls.endpoint` | Browser-visible dashboard URL and MCP OAuth callback base. It is also the `/mcp` base when `urls.webhook_host` is empty | +| `urls.endpoint` | Browser-visible dashboard URL and MCP OAuth callback base. It is also the `/mcp` base when `urls.webhook_host` is empty. WebSocket upgrades must come from its host. When it is empty, they must come from the request's `Host` | | `urls.webhook_host` | Public webhook hostname and the HTTPS host for this installation's `/mcp` endpoint | | `identity.mode` | `none` (default, no authentication, single operator), `header` (edge-asserted identity), or `jwt` (validated edge-signed assertion) | | `identity.header` | The trusted identity header. The shipped Caddy edge also uses it. Header and JWT modes have no default and require it |