diff --git a/docs/deployment.md b/docs/deployment.md index 0e009b601..717e8f8df 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -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 diff --git a/docs/websocket.md b/docs/websocket.md index 41c785830..12cfb66a5 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -225,7 +225,10 @@ All fields go under `channels.websocket` in `config.json`. | `token` | string | `""` | Static shared secret. When set, clients must provide `?token=` 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 diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 4b20f9d7f..54a2cbef1 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -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: ["*"]) diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index f294a84b1..f5afc1cac 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -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 diff --git a/nanobot/webui/http_utils.py b/nanobot/webui/http_utils.py index 375dfaeac..c77033954 100644 --- a/nanobot/webui/http_utils.py +++ b/nanobot/webui/http_utils.py @@ -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: diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 1fa17e575..1ac1e641d 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -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"),