mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-04 16:38:49 +00:00
feat(webui): support trusted proxy bootstrap auth
This commit is contained in:
parent
170c7083ed
commit
5cd14a42df
@ -67,7 +67,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
||||
> Official Docker usage currently means building from this repository with the included `Dockerfile`. Docker Hub images under third-party namespaces are not maintained or verified by HKUDS/nanobot; do not mount API keys or bot tokens into them unless you trust the publisher.
|
||||
|
||||
> [!IMPORTANT]
|
||||
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with a secret:
|
||||
> The gateway and WebSocket channel default to `host: "127.0.0.1"` in `config.json` (set in `nanobot/config/schema.py`). Docker `-p` port forwarding cannot reach a container's loopback interface, so for the host or LAN to reach the exposed ports you must set both binds to `0.0.0.0` in `~/.nanobot/config.json` before starting the container. To serve the bundled WebUI from Docker, bind the WebSocket channel externally and protect bootstrap with `tokenIssueSecret`:
|
||||
>
|
||||
> ```json
|
||||
> {
|
||||
@ -89,6 +89,36 @@ If deployment fails, open the service **Logs** page first. A missing model key f
|
||||
> must probe it directly, replace `127.0.0.1` in the port mapping with a trusted host
|
||||
> interface and restrict inbound traffic to the monitoring system.
|
||||
|
||||
### Cloudflare Tunnel + Cloudflare Access
|
||||
|
||||
For a local `cloudflared` process in front of nanobot, Cloudflare Access can
|
||||
authenticate the user before forwarding the request and add
|
||||
`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy bootstrap only when the
|
||||
direct TCP peer is the tunnel process and the assertion is non-empty:
|
||||
|
||||
```json
|
||||
{
|
||||
"gateway": { "host": "127.0.0.1" },
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"host": "127.0.0.1",
|
||||
"port": 8765,
|
||||
"trustedProxyAuth": {
|
||||
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
|
||||
"assertionHeader": "Cf-Access-Jwt-Assertion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This is two-part authorization: a trusted direct loopback peer **and** a
|
||||
non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bootstrap
|
||||
bypass. 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.
|
||||
|
||||
### Docker Compose
|
||||
|
||||
The default image preinstalls WhatsApp dependencies. To bake other enabled
|
||||
|
||||
@ -225,7 +225,10 @@ All fields go under `channels.websocket` in `config.json`.
|
||||
| `token` | string | `""` | Static shared secret. When set, clients must provide `?token=<value>` matching this secret (timing-safe comparison). Issued tokens are also accepted as a fallback. |
|
||||
| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). |
|
||||
| `tokenIssuePath` | string | `""` | HTTP path for issuing short-lived tokens. Must differ from `path`. See [Token Issuance](#token-issuance). |
|
||||
| `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` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret` or `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` still issues WebUI REST API tokens for same-machine localhost browser requests; remote or forwarded bootstrap requires `tokenIssueSecret`, `token`, or a fully configured `trustedProxyAuth`. |
|
||||
| `trustedProxyAuth` | object or `null` | `null` | Optional two-part bootstrap authorization for a directly connected upstream proxy. Both `trustedPeerCidrs` and a non-empty `assertionHeader` value must match; a CIDR alone never authorizes bootstrap. |
|
||||
| `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. |
|
||||
| `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). |
|
||||
|
||||
### Access Control
|
||||
@ -271,9 +274,47 @@ For production deployments where `websocketRequiresToken: true`, use short-lived
|
||||
4. The token is consumed (single use) and cannot be reused.
|
||||
|
||||
The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token.
|
||||
|
||||
It returns a separate `api_token` for REST routes to same-machine localhost
|
||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`
|
||||
or the static `token`.
|
||||
browser requests, or after the request proves knowledge of `tokenIssueSecret`,
|
||||
the static `token`, or the configured trusted proxy assertion.
|
||||
|
||||
### Trusted proxy bootstrap
|
||||
|
||||
`trustedProxyAuth` is an opt-in alternative for deployments where an
|
||||
identity-aware reverse proxy authenticates the user before connecting to nanobot.
|
||||
Bootstrap is accepted only when **both** the direct TCP peer matches one of
|
||||
`trustedPeerCidrs` and the configured assertion header is present and non-empty.
|
||||
A trusted address by itself is never sufficient.
|
||||
|
||||
Nanobot deliberately uses only `connection.remote_address` for the peer check.
|
||||
It never uses `X-Forwarded-For`, `Forwarded`, `X-Real-IP`, `CF-Connecting-IP`,
|
||||
or `X-Forwarded-Host` to decide whether the proxy is trusted. Nanobot trusts the
|
||||
assertion supplied by the explicitly trusted peer, but does not cryptographically
|
||||
validate or interpret the JWT/assertion contents. Do not enable this option if
|
||||
untrusted clients can connect directly to the nanobot listener.
|
||||
|
||||
For example, a local Cloudflare Tunnel with Cloudflare Access can validate the
|
||||
user at the edge and forward the resulting `Cf-Access-Jwt-Assertion`:
|
||||
|
||||
```json
|
||||
{
|
||||
"channels": {
|
||||
"websocket": {
|
||||
"host": "127.0.0.1",
|
||||
"trustedProxyAuth": {
|
||||
"trustedPeerCidrs": ["127.0.0.1/32", "::1/128"],
|
||||
"assertionHeader": "Cf-Access-Jwt-Assertion"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
This works only when the directly connected `cloudflared` process reaches
|
||||
nanobot over the configured loopback address and supplies a non-empty assertion.
|
||||
Keep nanobot firewalled from untrusted clients; this configuration is not a
|
||||
CIDR-based bootstrap bypass.
|
||||
|
||||
### Example setup
|
||||
|
||||
|
||||
@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import hmac
|
||||
import ipaddress
|
||||
import json
|
||||
import re
|
||||
import ssl
|
||||
@ -13,7 +14,7 @@ from contextlib import suppress
|
||||
from pathlib import Path
|
||||
from typing import Any, Self, TypeGuard, cast
|
||||
|
||||
from pydantic import Field, field_validator, model_validator
|
||||
from pydantic import Field, PrivateAttr, field_validator, model_validator
|
||||
from websockets.asyncio.server import ServerConnection, serve, unix_serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
from websockets.http11 import Request as WsRequest
|
||||
@ -89,6 +90,51 @@ from nanobot.webui.websocket_logging import websockets_server_logger
|
||||
_WEBUI_HTTP_OPEN_TIMEOUT_S = 360.0
|
||||
|
||||
|
||||
class TrustedProxyAuthConfig(Base):
|
||||
"""Authentication assertions accepted from explicitly trusted proxy peers."""
|
||||
|
||||
trusted_peer_cidrs: list[str] = Field(min_length=1)
|
||||
assertion_header: str = Field(min_length=1)
|
||||
_trusted_peer_networks: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...] = PrivateAttr(
|
||||
default=()
|
||||
)
|
||||
|
||||
@field_validator("trusted_peer_cidrs")
|
||||
@classmethod
|
||||
def validate_trusted_peer_cidrs(cls, values: list[str]) -> list[str]:
|
||||
normalized: list[str] = []
|
||||
for value in values:
|
||||
value = value.strip()
|
||||
try:
|
||||
network = ipaddress.ip_network(value, strict=False)
|
||||
except ValueError as exc:
|
||||
raise ValueError(f"invalid trusted proxy CIDR: {value!r}") from exc
|
||||
if network.prefixlen == 0:
|
||||
raise ValueError("universal trusted proxy CIDRs are not allowed")
|
||||
if isinstance(network, ipaddress.IPv6Network):
|
||||
mapped_start = ipaddress.IPv6Address("::ffff:0:0")
|
||||
mapped_end = ipaddress.IPv6Address("::ffff:ffff:ffff")
|
||||
if mapped_start in network and mapped_end in network:
|
||||
raise ValueError("trusted proxy CIDRs must not cover all IPv4-mapped addresses")
|
||||
normalized.append(network.with_prefixlen)
|
||||
return normalized
|
||||
|
||||
@field_validator("assertion_header")
|
||||
@classmethod
|
||||
def validate_assertion_header(cls, value: str) -> str:
|
||||
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")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def compile_trusted_peer_networks(self) -> Self:
|
||||
self._trusted_peer_networks = tuple(
|
||||
ipaddress.ip_network(value, strict=False) for value in self.trusted_peer_cidrs
|
||||
)
|
||||
return self
|
||||
|
||||
|
||||
class WebSocketConfig(Base):
|
||||
"""WebSocket server channel configuration.
|
||||
|
||||
@ -117,6 +163,7 @@ class WebSocketConfig(Base):
|
||||
token: str = ""
|
||||
token_issue_path: str = ""
|
||||
token_issue_secret: str = ""
|
||||
trusted_proxy_auth: TrustedProxyAuthConfig | None = None
|
||||
token_ttl_s: int = Field(default=300, ge=30, le=86_400)
|
||||
websocket_requires_token: bool = True
|
||||
allow_from: list[str] = Field(default_factory=lambda: ["*"])
|
||||
|
||||
@ -3321,6 +3321,128 @@ def test_local_browser_request_requires_loopback_host_and_forwarded_origin() ->
|
||||
)
|
||||
|
||||
|
||||
def _trusted_proxy_config(
|
||||
cidrs: list[str] | None = None,
|
||||
*,
|
||||
assertion_header: str = "Cf-Access-Jwt-Assertion",
|
||||
) -> dict[str, Any]:
|
||||
return {
|
||||
"trustedProxyAuth": {
|
||||
"trustedPeerCidrs": cidrs or ["127.0.0.1/32"],
|
||||
"assertionHeader": assertion_header,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_trusted_proxy_requires_non_empty_assertion(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, **_trusted_proxy_config())
|
||||
for assertion in (None, "", " "):
|
||||
headers = {"Cf-Access-Jwt-Assertion": assertion} if assertion is not None else {}
|
||||
resp = channel.gateway.http._handle_bootstrap(_LOCAL, _FakeReq(headers))
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_trusted_proxy_rejects_untrusted_peer_spoof(bus: MagicMock) -> None:
|
||||
channel = _ch(bus, **_trusted_proxy_config())
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_REMOTE,
|
||||
_FakeReq({"Cf-Access-Jwt-Assertion": "spoofed"}),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust(
|
||||
bus: MagicMock,
|
||||
) -> None:
|
||||
assertion = "opaque-upstream-assertion"
|
||||
channel = _ch(bus, **_trusted_proxy_config())
|
||||
log = MagicMock()
|
||||
channel.gateway.http._log = log
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_LOCAL,
|
||||
_FakeReq(
|
||||
{
|
||||
"Host": "nanobot.example",
|
||||
"X-Forwarded-For": "203.0.113.42",
|
||||
"Forwarded": "for=203.0.113.42;host=nanobot.example",
|
||||
"X-Real-IP": "203.0.113.42",
|
||||
"X-Forwarded-Host": "nanobot.example",
|
||||
"Cf-Access-Jwt-Assertion": assertion,
|
||||
}
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
body = resp.body.decode()
|
||||
assert assertion not in body
|
||||
assert assertion not in repr(log.mock_calls)
|
||||
payload = json.loads(body)
|
||||
assert payload["token"].startswith("nbwt_")
|
||||
assert payload["api_token"].startswith("nbwt_")
|
||||
assert payload["api_token"] != payload["token"]
|
||||
|
||||
|
||||
def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> None:
|
||||
channel = _ch(bus)
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_REMOTE,
|
||||
_FakeReq(
|
||||
{
|
||||
"Host": "nanobot.example",
|
||||
"X-Forwarded-For": "127.0.0.1",
|
||||
"Forwarded": "for=127.0.0.1",
|
||||
"X-Real-IP": "127.0.0.1",
|
||||
}
|
||||
),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
|
||||
def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> None:
|
||||
channel = _ch(
|
||||
bus,
|
||||
tokenIssueSecret="route-secret",
|
||||
**_trusted_proxy_config(),
|
||||
)
|
||||
resp = channel.gateway.http._handle_bootstrap(
|
||||
_LOCAL,
|
||||
_FakeReq({"Cf-Access-Jwt-Assertion": "present"}),
|
||||
)
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("peer", "cidr"),
|
||||
[
|
||||
("127.0.0.1", "127.0.0.1/32"),
|
||||
("::1", "::1/128"),
|
||||
("::ffff:127.0.0.1", "127.0.0.0/24"),
|
||||
("127.0.0.1", "::ffff:127.0.0.0/120"),
|
||||
],
|
||||
)
|
||||
def test_trusted_proxy_matches_ip_versions_and_mapped_peers(
|
||||
bus: MagicMock,
|
||||
peer: str,
|
||||
cidr: str,
|
||||
) -> None:
|
||||
from nanobot.webui.http_utils import is_trusted_proxy_authenticated_request
|
||||
|
||||
config = WebSocketConfig.model_validate(_trusted_proxy_config([cidr]))
|
||||
request = _FakeReq({"Cf-Access-Jwt-Assertion": "present"})
|
||||
assert is_trusted_proxy_authenticated_request(_FakeConn((peer, 12345)), request.headers, config)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"cidr",
|
||||
["not-a-cidr", "0.0.0.0/0", "::/0", "::/1", "::ffff:0:0/96"],
|
||||
)
|
||||
def test_trusted_proxy_rejects_invalid_or_universal_cidrs(
|
||||
cidr: str,
|
||||
) -> None:
|
||||
from pydantic_core import ValidationError
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
WebSocketConfig.model_validate(_trusted_proxy_config([cidr]))
|
||||
|
||||
def test_wildcard_host_without_auth_raises_on_startup(bus: MagicMock) -> None:
|
||||
import pytest
|
||||
from pydantic_core import ValidationError
|
||||
|
||||
@ -169,6 +169,50 @@ def is_localhost(connection: Any) -> bool:
|
||||
return host in {"127.0.0.1", "::1", "localhost"}
|
||||
|
||||
|
||||
def _connection_ip(connection: Any) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
||||
addr = getattr(connection, "remote_address", None)
|
||||
host = cast(Any, addr[0] if isinstance(addr, tuple) else addr)
|
||||
if not isinstance(host, str):
|
||||
return None
|
||||
try:
|
||||
return ipaddress.ip_address(host)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _address_matches_network(
|
||||
address: ipaddress.IPv4Address | ipaddress.IPv6Address,
|
||||
network: ipaddress.IPv4Network | ipaddress.IPv6Network,
|
||||
) -> bool:
|
||||
if isinstance(address, ipaddress.IPv4Address):
|
||||
if isinstance(network, ipaddress.IPv4Network):
|
||||
return address in network
|
||||
return ipaddress.IPv6Address(f"::ffff:{address}") in network
|
||||
if isinstance(network, ipaddress.IPv6Network):
|
||||
return address in network
|
||||
mapped = address.ipv4_mapped
|
||||
return mapped is not None and mapped in network
|
||||
|
||||
|
||||
def is_trusted_proxy_authenticated_request(
|
||||
connection: Any,
|
||||
headers: Any,
|
||||
config: Any,
|
||||
) -> bool:
|
||||
"""Return True when a configured proxy peer presents a non-empty assertion."""
|
||||
trusted_proxy_auth = getattr(config, "trusted_proxy_auth", None)
|
||||
if trusted_proxy_auth is None:
|
||||
return False
|
||||
address = _connection_ip(connection)
|
||||
if address is None:
|
||||
return False
|
||||
networks = getattr(trusted_proxy_auth, "_trusted_peer_networks", ())
|
||||
if not any(_address_matches_network(address, network) for network in networks):
|
||||
return False
|
||||
assertion_header = getattr(trusted_proxy_auth, "assertion_header", "")
|
||||
return bool(case_insensitive_header(headers, assertion_header))
|
||||
|
||||
|
||||
def _host_without_port(value: str) -> str:
|
||||
value = value.strip().strip('"').strip("'")
|
||||
if not value:
|
||||
|
||||
@ -60,6 +60,9 @@ from nanobot.webui.http_utils import (
|
||||
from nanobot.webui.http_utils import (
|
||||
is_localhost as _is_localhost,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request,
|
||||
)
|
||||
from nanobot.webui.http_utils import (
|
||||
issue_route_secret_matches as _issue_route_secret_matches,
|
||||
)
|
||||
@ -372,13 +375,18 @@ class GatewayHTTPHandler:
|
||||
def _handle_bootstrap(self, connection: Any, request: Any) -> Response:
|
||||
secret = self.config.token_issue_secret.strip() or self.config.token.strip()
|
||||
is_local_browser = _is_local_browser_request(connection, request.headers)
|
||||
is_proxy_authenticated = _is_trusted_proxy_authenticated_request(
|
||||
connection,
|
||||
request.headers,
|
||||
self.config,
|
||||
)
|
||||
if secret:
|
||||
if not _issue_route_secret_matches(request.headers, secret):
|
||||
return _http_error(401, "Unauthorized")
|
||||
elif not is_local_browser:
|
||||
elif not (is_local_browser or is_proxy_authenticated):
|
||||
return _http_error(403, "bootstrap is localhost-only")
|
||||
|
||||
api_token_allowed = bool(secret) or is_local_browser
|
||||
api_token_allowed = bool(secret) or is_local_browser or is_proxy_authenticated
|
||||
if not self.tokens.can_issue(include_api_token=api_token_allowed):
|
||||
return _http_response(
|
||||
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user