mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-07 09:58:34 +00:00
fix(webui): require proxy-generated auth assertions
This commit is contained in:
parent
465a918cf8
commit
e318e21cad
@ -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.
|
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
|
||||||
For this flow `/webui/bootstrap` returns connection metadata without a
|
For this flow `/webui/bootstrap` returns connection metadata without a
|
||||||
bootstrap token or REST API token; the proxy assertion authorizes the WebSocket
|
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
|
not cryptographically validate the JWT, so configure the tunnel and Access
|
||||||
policy carefully and do not expose the nanobot listener directly to untrusted
|
policy carefully and do not expose the nanobot listener directly to untrusted
|
||||||
clients. Forwarded client headers such as `X-Forwarded-For` do not establish
|
clients. Forwarded client headers do not establish proxy trust.
|
||||||
proxy trust.
|
|
||||||
|
|
||||||
### Docker Compose
|
### Docker Compose
|
||||||
|
|
||||||
|
|||||||
@ -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. |
|
| `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` | 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.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). |
|
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||||
|
|
||||||
### Access Control
|
### 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
|
validate or interpret the JWT/assertion contents. Do not enable this option if
|
||||||
untrusted clients can connect directly to the nanobot listener.
|
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
|
For example, a local Cloudflare Tunnel with Cloudflare Access can validate the
|
||||||
user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`:
|
user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`:
|
||||||
|
|
||||||
|
|||||||
@ -93,6 +93,24 @@ from nanobot.webui.websocket_logging import websockets_server_logger
|
|||||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
_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):
|
class TrustedProxyAuthConfig(Base):
|
||||||
"""Authentication assertions accepted from explicitly trusted proxy peers."""
|
"""Authentication assertions accepted from explicitly trusted proxy peers."""
|
||||||
|
|
||||||
@ -128,6 +146,11 @@ class TrustedProxyAuthConfig(Base):
|
|||||||
value = value.strip()
|
value = value.strip()
|
||||||
if not value or any(char.isspace() or ord(char) < 0x21 for char in value):
|
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")
|
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
|
return value
|
||||||
|
|
||||||
@model_validator(mode="after")
|
@model_validator(mode="after")
|
||||||
|
|||||||
@ -3473,6 +3473,16 @@ def test_trusted_proxy_rejects_invalid_or_universal_cidrs(
|
|||||||
with pytest.raises(ValidationError):
|
with pytest.raises(ValidationError):
|
||||||
WebSocketConfig.model_validate(_trusted_proxy_config([cidr]))
|
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:
|
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
|
||||||
import pytest
|
import pytest
|
||||||
from pydantic_core import ValidationError
|
from pydantic_core import ValidationError
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user