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
5 changes: 4 additions & 1 deletion backend/druks/browser/login.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
20 changes: 17 additions & 3 deletions backend/tests/test_browser_session_login_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
Loading