diff --git a/docs/deployment.md b/docs/deployment.md index 717e8f8df..e49b24467 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -82,7 +82,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f > } > ``` > -> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token` or `tokenIssueSecret` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details. +> When the WebSocket `host` is `0.0.0.0`, the channel refuses to start unless `token`, `tokenIssueSecret`, or a fully configured `trustedProxyAuth` is also configured. See [`webui.md#lan-access`](./webui.md#lan-access) for details. > The gateway health route itself is intentionally minimal and unauthenticated. When the > container binds it to `0.0.0.0`, publish port `18790` to host loopback only; place any > remotely monitored health endpoint behind a firewall or reverse proxy. If another host @@ -93,7 +93,7 @@ If deployment fails, open the service **Logs** page first. A missing model key f 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 +`Cf-Access-Jwt-Assertion`. Opt in to trusted-proxy no-token mode only when the direct TCP peer is the tunnel process and the assertion is non-empty: ```json @@ -113,11 +113,14 @@ direct TCP peer is the tunnel process and the assertion is non-empty: ``` 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. +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 +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 diff --git a/docs/websocket.md b/docs/websocket.md index 12cfb66a5..26926f709 100644 --- a/docs/websocket.md +++ b/docs/websocket.md @@ -76,7 +76,7 @@ ws://{host}:{port}{path}?client_id={id}&token={token} | Parameter | Required | Description | |-----------|----------|-------------| | `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. | -| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured. | +| `token` | Conditional | Authentication token. Required when `websocketRequiresToken` is `true` or `token` (static secret) is configured, unless the request comes through an authenticated `trustedProxyAuth` peer. | ## Wire Protocol @@ -222,11 +222,11 @@ All fields go under `channels.websocket` in `config.json`. | Field | Type | Default | Description | |-------|------|---------|-------------| -| `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). | +| `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. A trusted proxy assertion bypasses this requirement. | +| `websocketRequiresToken` | bool | `true` | When `true` and no static `token` is configured, clients must still present a valid issued token, unless `trustedProxyAuth` authenticates the direct proxy peer. 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`, `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. | +| `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. | | `tokenTtlS` | int | `300` | Time-to-live for issued tokens in seconds (30 – 86,400). | @@ -273,16 +273,18 @@ For production deployments where `websocketRequiresToken: true`, use short-lived 3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`. 4. The token is consumed (single use) and cannot be reused. -The embedded WebUI's `/webui/bootstrap` route also returns a WebSocket token. +The embedded WebUI's `/webui/bootstrap` route returns a WebSocket token and +REST `api_token` for local or secret-authenticated requests. When +`trustedProxyAuth` authenticates the direct proxy peer, it returns connection +metadata only: no bootstrap token, no REST API token, and no token query +parameter is required for the WebSocket handshake or subsequent REST requests. -It returns a separate `api_token` for REST routes to same-machine localhost -browser requests, or after the request proves knowledge of `tokenIssueSecret`, -the static `token`, or the configured trusted proxy assertion. - -### Trusted proxy bootstrap +### Trusted proxy no-token bootstrap `trustedProxyAuth` is an opt-in alternative for deployments where an identity-aware reverse proxy authenticates the user before connecting to nanobot. +The proxy assertion becomes the authentication boundary for the entire WebUI +surface: `/webui/bootstrap`, the WebSocket handshake, and REST API routes. 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. diff --git a/nanobot/channels/websocket/runtime.py b/nanobot/channels/websocket/runtime.py index 54a2cbef1..9489b5adc 100644 --- a/nanobot/channels/websocket/runtime.py +++ b/nanobot/channels/websocket/runtime.py @@ -61,6 +61,9 @@ from nanobot.session.webui_turns import ( from nanobot.webui.cli_apps_api import normalize_cli_app_mentions from nanobot.webui.forking import handle_webui_fork_chat from nanobot.webui.gateway_services import GatewayServices +from nanobot.webui.http_utils import ( + is_trusted_proxy_authenticated_request as _is_trusted_proxy_authenticated_request, +) from nanobot.webui.http_utils import ( normalize_config_path as _normalize_config_path, ) @@ -220,11 +223,11 @@ class WebSocketConfig(Base): def wildcard_host_requires_auth(self) -> Self: if self.host not in ("0.0.0.0", "::"): return self - if self.token.strip() or self.token_issue_secret.strip(): + if self.token.strip() or self.token_issue_secret.strip() or self.trusted_proxy_auth is not None: return self raise ValueError( - "host is 0.0.0.0 (all interfaces) but neither token nor " - "token_issue_secret is set — set one to prevent unauthenticated access" + "host is 0.0.0.0 (all interfaces) but neither token, token_issue_secret, " + "nor trusted_proxy_auth is set — set one to prevent unauthenticated access" ) @@ -480,16 +483,16 @@ class WebSocketChannel(BaseChannel): async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any: """Route an inbound HTTP request to the HTTP handler or WS upgrade.""" got, query = _parse_request_path(request.path) + expected_ws = self._expected_path() # WebSocket upgrade — channel handles this itself - expected_ws = self._expected_path() if got == expected_ws and _is_websocket_upgrade(request): client_id = _query_first(query, "client_id") or "" if len(client_id) > 128: client_id = client_id[:128] if not self.is_allowed(client_id): return connection.respond(403, "Forbidden") - return self._authorize_websocket_handshake(connection, query) + return self._authorize_websocket_handshake(connection, query, request.headers) # Everything else goes to the HTTP handler return await self._http_router.dispatch(connection, request) @@ -498,7 +501,12 @@ class WebSocketChannel(BaseChannel): self, connection: ServerConnection, query: dict[str, list[str]], + headers: Any = None, ) -> Any: + if _is_trusted_proxy_authenticated_request(connection, headers or {}, self.config): + self._webui_connections.add(connection) + return None + supplied = _query_first(query, "token") static_token = self.config.token.strip() diff --git a/nanobot/channels/websocket/tests/test_websocket_http_routes.py b/nanobot/channels/websocket/tests/test_websocket_http_routes.py index f5afc1cac..52d628377 100644 --- a/nanobot/channels/websocket/tests/test_websocket_http_routes.py +++ b/nanobot/channels/websocket/tests/test_websocket_http_routes.py @@ -3351,7 +3351,7 @@ def test_trusted_proxy_rejects_untrusted_peer_spoof(bus: MagicMock) -> None: assert resp.status_code == 403 -def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust( +def test_trusted_proxy_bootstrap_has_no_tokens( bus: MagicMock, ) -> None: assertion = "opaque-upstream-assertion" @@ -3376,9 +3376,36 @@ def test_trusted_proxy_accepts_assertion_without_forwarded_header_trust( 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"] + assert "token" not in payload + assert "api_token" not in payload + assert payload["ws_path"] == "/" + + +@pytest.mark.asyncio +async def test_trusted_proxy_authorizes_rest_without_api_token(bus: MagicMock) -> None: + channel = _ch(bus, **_trusted_proxy_config()) + response = await channel.gateway.http.dispatch( + _LOCAL, + _FakeReq( + { + "Host": "nanobot.example", + "Cf-Access-Jwt-Assertion": "present", + }, + path="/api/sessions", + ), + ) + assert response.status_code == 503 + + +def test_trusted_proxy_authorizes_websocket_without_token(bus: MagicMock) -> None: + channel = _ch(bus, **_trusted_proxy_config()) + response = channel._authorize_websocket_handshake( + _LOCAL, + {}, + {"Cf-Access-Jwt-Assertion": "present"}, + ) + assert response is None + assert _LOCAL in channel._webui_connections def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> None: @@ -3397,7 +3424,7 @@ def test_forwarding_headers_alone_never_authorize_bootstrap(bus: MagicMock) -> N assert resp.status_code == 403 -def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> None: +def test_trusted_proxy_bypasses_bootstrap_secret_and_tokens(bus: MagicMock) -> None: channel = _ch( bus, tokenIssueSecret="route-secret", @@ -3407,7 +3434,10 @@ def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> Non _LOCAL, _FakeReq({"Cf-Access-Jwt-Assertion": "present"}), ) - assert resp.status_code == 401 + assert resp.status_code == 200 + payload = json.loads(resp.body) + assert "token" not in payload + assert "api_token" not in payload @pytest.mark.parametrize( @@ -3461,6 +3491,11 @@ def test_wildcard_host_with_secret_is_valid(bus: MagicMock) -> None: assert channel.config.host == "0.0.0.0" +def test_wildcard_host_with_trusted_proxy_auth_is_valid(bus: MagicMock) -> None: + channel = _ch(bus, host="0.0.0.0", **_trusted_proxy_config()) + assert channel.config.host == "0.0.0.0" + + def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None: import pytest from pydantic_core import ValidationError diff --git a/nanobot/webui/ws_http.py b/nanobot/webui/ws_http.py index 1ac1e641d..60edee929 100644 --- a/nanobot/webui/ws_http.py +++ b/nanobot/webui/ws_http.py @@ -266,6 +266,8 @@ class GatewayHTTPHandler: # -- Token management --------------------------------------------------- def check_api_token(self, request: WsRequest) -> bool: + if getattr(request, "_nanobot_trusted_proxy_authenticated", False): + return True return self.tokens.check_api_token(request) # -- Main dispatch ------------------------------------------------------ @@ -275,6 +277,11 @@ class GatewayHTTPHandler: got, _ = _parse_request_path(request.path) started = time.perf_counter() response: Any | None = None + setattr( + request, + "_nanobot_trusted_proxy_authenticated", + _is_trusted_proxy_authenticated_request(connection, request.headers, self.config), + ) try: response = await self._dispatch_resolved(connection, request, got) @@ -380,13 +387,27 @@ class GatewayHTTPHandler: 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 or is_proxy_authenticated): - return _http_error(403, "bootstrap is localhost-only") + if not is_proxy_authenticated: + if secret: + if not _issue_route_secret_matches(request.headers, secret): + return _http_error(401, "Unauthorized") + elif not is_local_browser: + return _http_error(403, "bootstrap is localhost-only") - api_token_allowed = bool(secret) or is_local_browser or is_proxy_authenticated + if is_proxy_authenticated: + payload = { + "ws_path": _normalize_config_path(self.config.path), + "ws_url": self._bootstrap_ws_url(request), + "limits": self.ingress.bootstrap_limits( + max_frame_bytes=self.config.max_message_bytes, + ), + "model_name": _resolve_bootstrap_model_name(self.runtime_model_name), + "runtime_surface": self._runtime_surface, + "runtime_capabilities": self._capabilities, + } + return _http_json_response(payload) + + api_token_allowed = bool(secret) or is_local_browser 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"), diff --git a/webui/src/App.tsx b/webui/src/App.tsx index f432ef21e..de10ca966 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -70,7 +70,7 @@ type BootState = status: "ready"; client: NanobotClient; token: string; - tokenExpiresAt: number; + tokenExpiresAt: number | null; modelName: string | null; ingressLimits: BootstrapResponse["limits"] | null; runtimeSurface: RuntimeSurface; @@ -733,7 +733,9 @@ export default function App() { ? toRuntimeSurface(boot.runtime_surface) : fallbackSurface; const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); - const tokenExpiresAt = bootstrapTokenExpiresAt(boot.expires_in); + const tokenExpiresAt = boot.expires_in + ? bootstrapTokenExpiresAt(boot.expires_in) + : null; if (runtimeHost.socketFactory) { client.updateUrl(url, runtimeHost.socketFactory); } else { @@ -744,7 +746,7 @@ export default function App() { current.status === "ready" && current.client === client ? { ...current, - token: boot.api_token, + token: boot.api_token ?? "", tokenExpiresAt, modelName: boot.model_name ?? current.modelName, ingressLimits: boot.limits ?? current.ingressLimits, @@ -752,7 +754,7 @@ export default function App() { } : current, ); - return { token: boot.api_token, url }; + return { token: boot.api_token ?? "", url }; }, [], ); @@ -787,8 +789,10 @@ export default function App() { setState({ status: "ready", client, - token: boot.api_token, - tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), + token: boot.api_token ?? "", + tokenExpiresAt: boot.expires_in + ? bootstrapTokenExpiresAt(boot.expires_in) + : null, modelName: boot.model_name ?? null, ingressLimits: boot.limits ?? null, runtimeSurface, @@ -813,7 +817,7 @@ export default function App() { ); useEffect(() => { - if (state.status !== "ready") return; + if (state.status !== "ready" || state.tokenExpiresAt === null) return; const client = state.client; const timer = window.setTimeout(async () => { try { diff --git a/webui/src/lib/bootstrap.ts b/webui/src/lib/bootstrap.ts index d65c2b168..5c1cba8e5 100644 --- a/webui/src/lib/bootstrap.ts +++ b/webui/src/lib/bootstrap.ts @@ -87,13 +87,8 @@ export async function fetchBootstrap( throw new Error(`bootstrap failed: HTTP ${res.status}`); } const body = (await res.json()) as BootstrapResponse; - if (!body.token || !body.ws_path) { - throw new Error("bootstrap response missing token or ws_path"); - } - if (!body.api_token) { - throw new BootstrapAuthRequiredError( - "bootstrap authentication required: missing api_token", - ); + if (!body.ws_path) { + throw new Error("bootstrap response missing ws_path"); } return body; } @@ -107,10 +102,10 @@ export async function fetchBootstrap( */ export function deriveWsUrl( wsPath: string, - token: string, + token: string | null | undefined, wsUrl?: string | null, ): string { - const query = `?token=${encodeURIComponent(token)}`; + const query = token ? `?token=${encodeURIComponent(token)}` : ""; const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`; if (typeof window !== "undefined" && window.location.port === "5173") { const host = window.location.hostname.includes(":") @@ -127,6 +122,7 @@ export function deriveWsUrl( return `${scheme}://${authority}${path}${query}`; } if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) { + if (!token) return wsUrl; const join = wsUrl.includes("?") ? "&" : "?"; return `${wsUrl}${join}token=${encodeURIComponent(token)}`; } diff --git a/webui/src/lib/types.ts b/webui/src/lib/types.ts index 19d41405e..7aa483fd5 100644 --- a/webui/src/lib/types.ts +++ b/webui/src/lib/types.ts @@ -390,11 +390,11 @@ export interface SidebarStatePayload { } export interface BootstrapResponse { - token: string; - api_token: string; + token?: string; + api_token?: string; ws_path: string; ws_url?: string | null; - expires_in: number; + expires_in?: number; limits?: WebUIIngressLimits; model_name?: string | null; runtime_surface?: RuntimeSurface; diff --git a/webui/src/tests/bootstrap.test.ts b/webui/src/tests/bootstrap.test.ts index 6ee98bbf6..43317bb11 100644 --- a/webui/src/tests/bootstrap.test.ts +++ b/webui/src/tests/bootstrap.test.ts @@ -1,7 +1,6 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { - BootstrapAuthRequiredError, consumeUrlBootstrapSecret, deriveWsUrl, fetchBootstrap, @@ -57,6 +56,12 @@ describe("bootstrap helpers", () => { ); }); + it("does not append a token for trusted-proxy websocket URLs", () => { + expect(deriveWsUrl("/", undefined, "wss://proxy.example/")).toBe( + "wss://proxy.example/", + ); + }); + it("times out when the bootstrap endpoint never responds", async () => { vi.useFakeTimers(); vi.stubGlobal("fetch", vi.fn(() => new Promise(() => {}))); @@ -69,21 +74,19 @@ describe("bootstrap helpers", () => { await pending; }); - it("treats bootstrap responses without an API token as auth-required", async () => { + it("accepts tokenless trusted-proxy bootstrap responses", async () => { vi.stubGlobal( "fetch", vi.fn(async () => ({ ok: true, - json: async () => ({ token: "ws-token", ws_path: "/", expires_in: 300 }), + json: async () => ({ ws_path: "/", ws_url: "wss://proxy.example/" }), })), ); - const promise = fetchBootstrap(); - await expect(promise).rejects.toMatchObject({ - name: "BootstrapAuthRequiredError", - message: "bootstrap authentication required: missing api_token", + await expect(fetchBootstrap()).resolves.toMatchObject({ + ws_path: "/", + ws_url: "wss://proxy.example/", }); - await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError); }); it("consumes bootstrap secrets from the URL fragment", () => {