fix(webui): require proxy-generated auth assertions

This commit is contained in:
concertypin 2026-08-02 00:59:09 +09:00 committed by Xubin Ren
parent 465a918cf8
commit e318e21cad
4 changed files with 45 additions and 4 deletions

View File

@ -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

View File

@ -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`:

View File

@ -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")

View File

@ -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