From e318e21cad9bd859058b5ed813b1d196336f2776 Mon Sep 17 00:00:00 2001 From: concertypin <55056558+concertypin@users.noreply.github.com> Date: Sun, 2 Aug 2026 00:59:09 +0900 Subject: [PATCH] fix(webui): require proxy-generated auth assertions --- docs/deployment.md | 8 ++++--- docs/websocket.md | 8 ++++++- nanobot/channels/websocket/runtime.py | 23 +++++++++++++++++++ .../tests/test_websocket_http_routes.py | 10 ++++++++ 4 files changed, 45 insertions(+), 4 deletions(-) diff --git a/docs/deployment.md b/docs/deployment.md index e49b24467..cfdf40ccb 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -116,11 +116,13 @@ This is two-part authorization: a trusted direct loopback peer **and** a non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass. For this flow `/webui/bootstrap` returns connection metadata without a bootstrap token or REST API token; the proxy assertion authorizes the WebSocket -handshake and REST requests directly. Nanobot trusts the assertion but does +handshake and REST requests directly. The assertion header must be generated +by Cloudflare Access after authentication; routing/client metadata headers such +as `Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP` +are rejected as `assertionHeader` values. Nanobot trusts the assertion but does not cryptographically validate the JWT, so configure the tunnel and Access policy carefully and do not expose the nanobot listener directly to untrusted -clients. Forwarded client headers such as `X-Forwarded-For` do not establish -proxy trust. +clients. Forwarded client headers do not establish proxy trust. ### Docker Compose diff --git a/docs/websocket.md b/docs/websocket.md index 26926f709..273d8536c 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -228,7 +228,7 @@ All fields go under `channels.websocket` in `config.json`. | `tokenIssueSecret` | string | `""` | Secret required to obtain tokens via the issue endpoint. If empty, any client can obtain WebSocket connection tokens from `tokenIssuePath` (logged as a warning). `/webui/bootstrap` issues tokens for local/secret-authenticated requests; trusted-proxy requests intentionally receive no bootstrap or API token. | | `trustedProxyAuth` | object or `null` | `null` | Optional two-part no-token authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap or WebSocket/API access. | | `trustedProxyAuth.trustedPeerCidrs` | list of CIDR strings | — | Direct TCP peer networks that may present the assertion. IPv4, IPv6, and IPv4-mapped IPv6 peers are supported; universal CIDRs (`0.0.0.0/0`, `::/0`) are rejected. | -| `trustedProxyAuth.assertionHeader` | string | — | HTTP header whose non-empty value proves the upstream proxy authenticated the request. Nanobot trusts this value but does not cryptographically validate it. | +| `trustedProxyAuth.assertionHeader` | string | — | Header injected by the identity-aware proxy after successful authentication. Routing/client metadata headers (`Host`, `Forwarded`, `X-Forwarded-*`, `X-Real-IP`, `CF-Connecting-IP`) are rejected; nanobot trusts the remaining header's non-empty value but does not cryptographically validate it. | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | ### Access Control @@ -296,6 +296,12 @@ assertion supplied by the explicitly trusted peer, but does not cryptographicall validate or interpret the JWT/assertion contents. Do not enable this option if untrusted clients can connect directly to the nanobot listener. +The configured assertion header must be a proxy-generated authentication +assertion, not a routing or client metadata header. Headers such as `Host`, +`Forwarded`, `X-Forwarded-*`, `X-Real-IP`, and `CF-Connecting-IP` are rejected +by configuration; use the identity provider's post-authentication assertion +header instead (for example, `Cf-Access-Jwt-Assertion`). + For example, a local Cloudflare Tunnel with Cloudflare Access can validate the user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`: diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 9489b5adc..b0fb7390b 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -93,6 +93,24 @@ from nanobot.webui.websocket_logging import websockets_server_logger _WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0 +_ROUTING_ASSERTION_HEADERS = frozenset( + { + "host", + "forwarded", + "x-forwarded-for", + "x-forwarded-host", + "x-forwarded-proto", + "x-real-ip", + "cf-connecting-ip", + } +) + + +def _is_routing_assertion_header(value: str) -> bool: + normalized = value.casefold() + return normalized in _ROUTING_ASSERTION_HEADERS or normalized.startswith("x-forwarded-") + + class TrustedProxyAuthConfig(Base): """Authentication assertions accepted from explicitly trusted proxy peers.""" @@ -128,6 +146,11 @@ class TrustedProxyAuthConfig(Base): value = value.strip() if not value or any(char.isspace() or ord(char) < 0x21 for char in value): raise ValueError("assertion_header must be a valid HTTP header name") + if _is_routing_assertion_header(value): + raise ValueError( + "assertion_header must identify a proxy-generated authentication assertion, " + "not a routing or client metadata header" + ) return value @model_validator(mode="after") diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index 52d628377..5d073b7b0 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -3473,6 +3473,16 @@ def test_trusted_proxy_rejects_invalid_or_universal_cidrs( with pytest.raises(ValidationError): WebSocketConfig.model_validate(_trusted_proxy_config([cidr])) +@pytest.mark.parametrize( + "assertion_header", + ["Host", "Forwarded", "X-Forwarded-For", "X-Real-IP", "CF-Connecting-IP"], +) +def test_trusted_proxy_rejects_routing_headers(assertion_header: str) -> None: + from pydantic_core import ValidationError + + with pytest.raises(ValidationError, match="proxy-generated"): + WebSocketConfig.model_validate(_trusted_proxy_config(assertion_header=assertion_header)) + def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None: import pytest from pydantic_core import ValidationError