feat(webui): bypass tokens for trusted proxy auth

This commit is contained in:
concertypin 2026-08-02 00:38:17 +09:00 committed by Xubin Ren
parent 5cd14a42df
commit 465a918cf8
9 changed files with 134 additions and 62 deletions

View File

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

View File

@ -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=<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). |
| `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. 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.

View File

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

View File

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

View File

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

View File

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

View File

@ -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)}`;
}

View File

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

View File

@ -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<Response>(() => {})));
@ -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", () => {