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 > 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 > 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 > 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 For a local `cloudflared` process in front of nanobot, Cloudflare Access can
authenticate the user before forwarding the request and add 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: direct TCP peer is the tunnel process and the assertion is non-empty:
```json ```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 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 non-empty Cloudflare Access assertion. A trusted CIDR alone is not a bypass.
bypass. Nanobot trusts the assertion but does not cryptographically validate For this flow `/webui/bootstrap` returns connection metadata without a
the JWT, so configure the tunnel and Access policy carefully and do not expose bootstrap token or REST API token; the proxy assertion authorizes the WebSocket
the nanobot listener directly to untrusted clients. Forwarded client headers handshake and REST requests directly. Nanobot trusts the assertion but does
such as `X-Forwarded-For` do not establish proxy trust. 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 ### Docker Compose

View File

@ -76,7 +76,7 @@ ws://{host}:{port}{path}?client_id={id}&token={token}
| Parameter | Required | Description | | Parameter | Required | Description |
|-----------|----------|-------------| |-----------|----------|-------------|
| `client_id` | No | Identifier for `allowFrom` authorization. Auto-generated as `anon-xxxxxxxxxxxx` if omitted. Truncated to 128 chars. | | `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 ## Wire Protocol
@ -222,11 +222,11 @@ All fields go under `channels.websocket` in `config.json`.
| Field | Type | Default | Description | | 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. | | `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. Set to `false` to allow unauthenticated connections (only safe for local/trusted networks). | | `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). | | `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`. | | `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 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` | 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 | — | 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). | | `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=...`. 3. Client opens WebSocket with `?token=nbwt_aBcDeFg...&client_id=...`.
4. The token is consumed (single use) and cannot be reused. 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 ### Trusted proxy no-token bootstrap
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 `trustedProxyAuth` is an opt-in alternative for deployments where an
identity-aware reverse proxy authenticates the user before connecting to nanobot. 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 Bootstrap is accepted only when **both** the direct TCP peer matches one of
`trustedPeerCidrs` and the configured assertion header is present and non-empty. `trustedPeerCidrs` and the configured assertion header is present and non-empty.
A trusted address by itself is never sufficient. 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.cli_apps_api import normalize_cli_app_mentions
from nanobot.webui.forking import handle_webui_fork_chat from nanobot.webui.forking import handle_webui_fork_chat
from nanobot.webui.gateway_services import GatewayServices 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 ( from nanobot.webui.http_utils import (
normalize_config_path as _normalize_config_path, normalize_config_path as _normalize_config_path,
) )
@ -220,11 +223,11 @@ class WebSocketConfig(Base):
def wildcard_host_requires_auth(self) -> Self: def wildcard_host_requires_auth(self) -> Self:
if self.host not in ("0.0.0.0", "::"): if self.host not in ("0.0.0.0", "::"):
return self 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 return self
raise ValueError( raise ValueError(
"host is 0.0.0.0 (all interfaces) but neither token nor " "host is 0.0.0.0 (all interfaces) but neither token, token_issue_secret, "
"token_issue_secret is set — set one to prevent unauthenticated access" "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: async def _dispatch_http(self, connection: ServerConnection, request: WsRequest) -> Any:
"""Route an inbound HTTP request to the HTTP handler or WS upgrade.""" """Route an inbound HTTP request to the HTTP handler or WS upgrade."""
got, query = _parse_request_path(request.path) got, query = _parse_request_path(request.path)
expected_ws = self._expected_path()
# WebSocket upgrade — channel handles this itself # WebSocket upgrade — channel handles this itself
expected_ws = self._expected_path()
if got == expected_ws and _is_websocket_upgrade(request): if got == expected_ws and _is_websocket_upgrade(request):
client_id = _query_first(query, "client_id") or "" client_id = _query_first(query, "client_id") or ""
if len(client_id) > 128: if len(client_id) > 128:
client_id = client_id[:128] client_id = client_id[:128]
if not self.is_allowed(client_id): if not self.is_allowed(client_id):
return connection.respond(403, "Forbidden") 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 # Everything else goes to the HTTP handler
return await self._http_router.dispatch(connection, request) return await self._http_router.dispatch(connection, request)
@ -498,7 +501,12 @@ class WebSocketChannel(BaseChannel):
self, self,
connection: ServerConnection, connection: ServerConnection,
query: dict[str, list[str]], query: dict[str, list[str]],
headers: Any = None,
) -> Any: ) -> Any:
if _is_trusted_proxy_authenticated_request(connection, headers or {}, self.config):
self._webui_connections.add(connection)
return None
supplied = _query_first(query, "token") supplied = _query_first(query, "token")
static_token = self.config.token.strip() 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 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, bus: MagicMock,
) -> None: ) -> None:
assertion = "opaque-upstream-assertion" 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 body
assert assertion not in repr(log.mock_calls) assert assertion not in repr(log.mock_calls)
payload = json.loads(body) payload = json.loads(body)
assert payload["token"].startswith("nbwt_") assert "token" not in payload
assert payload["api_token"].startswith("nbwt_") assert "api_token" not in payload
assert payload["api_token"] != payload["token"] 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: 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 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( channel = _ch(
bus, bus,
tokenIssueSecret="route-secret", tokenIssueSecret="route-secret",
@ -3407,7 +3434,10 @@ def test_trusted_proxy_does_not_override_bootstrap_secret(bus: MagicMock) -> Non
_LOCAL, _LOCAL,
_FakeReq({"Cf-Access-Jwt-Assertion": "present"}), _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( @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" 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: def test_wildcard_ipv6_without_auth_raises(bus: MagicMock) -> None:
import pytest import pytest
from pydantic_core import ValidationError from pydantic_core import ValidationError

View File

@ -266,6 +266,8 @@ class GatewayHTTPHandler:
# -- Token management --------------------------------------------------- # -- Token management ---------------------------------------------------
def check_api_token(self, request: WsRequest) -> bool: 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) return self.tokens.check_api_token(request)
# -- Main dispatch ------------------------------------------------------ # -- Main dispatch ------------------------------------------------------
@ -275,6 +277,11 @@ class GatewayHTTPHandler:
got, _ = _parse_request_path(request.path) got, _ = _parse_request_path(request.path)
started = time.perf_counter() started = time.perf_counter()
response: Any | None = None response: Any | None = None
setattr(
request,
"_nanobot_trusted_proxy_authenticated",
_is_trusted_proxy_authenticated_request(connection, request.headers, self.config),
)
try: try:
response = await self._dispatch_resolved(connection, request, got) response = await self._dispatch_resolved(connection, request, got)
@ -380,13 +387,27 @@ class GatewayHTTPHandler:
request.headers, request.headers,
self.config, self.config,
) )
if secret: if not is_proxy_authenticated:
if not _issue_route_secret_matches(request.headers, secret): if secret:
return _http_error(401, "Unauthorized") if not _issue_route_secret_matches(request.headers, secret):
elif not (is_local_browser or is_proxy_authenticated): return _http_error(401, "Unauthorized")
return _http_error(403, "bootstrap is localhost-only") 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): if not self.tokens.can_issue(include_api_token=api_token_allowed):
return _http_response( return _http_response(
json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"), json.dumps({"error": "too many outstanding tokens"}).encode("utf-8"),

View File

@ -70,7 +70,7 @@ type BootState =
status: "ready"; status: "ready";
client: NanobotClient; client: NanobotClient;
token: string; token: string;
tokenExpiresAt: number; tokenExpiresAt: number | null;
modelName: string | null; modelName: string | null;
ingressLimits: BootstrapResponse["limits"] | null; ingressLimits: BootstrapResponse["limits"] | null;
runtimeSurface: RuntimeSurface; runtimeSurface: RuntimeSurface;
@ -733,7 +733,9 @@ export default function App() {
? toRuntimeSurface(boot.runtime_surface) ? toRuntimeSurface(boot.runtime_surface)
: fallbackSurface; : fallbackSurface;
const runtimeHost = createRuntimeHost(runtimeSurface, boot.runtime_capabilities); 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) { if (runtimeHost.socketFactory) {
client.updateUrl(url, runtimeHost.socketFactory); client.updateUrl(url, runtimeHost.socketFactory);
} else { } else {
@ -744,7 +746,7 @@ export default function App() {
current.status === "ready" && current.client === client current.status === "ready" && current.client === client
? { ? {
...current, ...current,
token: boot.api_token, token: boot.api_token ?? "",
tokenExpiresAt, tokenExpiresAt,
modelName: boot.model_name ?? current.modelName, modelName: boot.model_name ?? current.modelName,
ingressLimits: boot.limits ?? current.ingressLimits, ingressLimits: boot.limits ?? current.ingressLimits,
@ -752,7 +754,7 @@ export default function App() {
} }
: current, : current,
); );
return { token: boot.api_token, url }; return { token: boot.api_token ?? "", url };
}, },
[], [],
); );
@ -787,8 +789,10 @@ export default function App() {
setState({ setState({
status: "ready", status: "ready",
client, client,
token: boot.api_token, token: boot.api_token ?? "",
tokenExpiresAt: bootstrapTokenExpiresAt(boot.expires_in), tokenExpiresAt: boot.expires_in
? bootstrapTokenExpiresAt(boot.expires_in)
: null,
modelName: boot.model_name ?? null, modelName: boot.model_name ?? null,
ingressLimits: boot.limits ?? null, ingressLimits: boot.limits ?? null,
runtimeSurface, runtimeSurface,
@ -813,7 +817,7 @@ export default function App() {
); );
useEffect(() => { useEffect(() => {
if (state.status !== "ready") return; if (state.status !== "ready" || state.tokenExpiresAt === null) return;
const client = state.client; const client = state.client;
const timer = window.setTimeout(async () => { const timer = window.setTimeout(async () => {
try { try {

View File

@ -87,13 +87,8 @@ export async function fetchBootstrap(
throw new Error(`bootstrap failed: HTTP ${res.status}`); throw new Error(`bootstrap failed: HTTP ${res.status}`);
} }
const body = (await res.json()) as BootstrapResponse; const body = (await res.json()) as BootstrapResponse;
if (!body.token || !body.ws_path) { if (!body.ws_path) {
throw new Error("bootstrap response missing token or ws_path"); throw new Error("bootstrap response missing ws_path");
}
if (!body.api_token) {
throw new BootstrapAuthRequiredError(
"bootstrap authentication required: missing api_token",
);
} }
return body; return body;
} }
@ -107,10 +102,10 @@ export async function fetchBootstrap(
*/ */
export function deriveWsUrl( export function deriveWsUrl(
wsPath: string, wsPath: string,
token: string, token: string | null | undefined,
wsUrl?: string | null, wsUrl?: string | null,
): string { ): string {
const query = `?token=${encodeURIComponent(token)}`; const query = token ? `?token=${encodeURIComponent(token)}` : "";
const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`; const path = wsPath && wsPath.startsWith("/") ? wsPath : `/${wsPath || ""}`;
if (typeof window !== "undefined" && window.location.port === "5173") { if (typeof window !== "undefined" && window.location.port === "5173") {
const host = window.location.hostname.includes(":") const host = window.location.hostname.includes(":")
@ -127,6 +122,7 @@ export function deriveWsUrl(
return `${scheme}://${authority}${path}${query}`; return `${scheme}://${authority}${path}${query}`;
} }
if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) { if (wsUrl && /^(wss?|nanobot-host):\/\//i.test(wsUrl)) {
if (!token) return wsUrl;
const join = wsUrl.includes("?") ? "&" : "?"; const join = wsUrl.includes("?") ? "&" : "?";
return `${wsUrl}${join}token=${encodeURIComponent(token)}`; return `${wsUrl}${join}token=${encodeURIComponent(token)}`;
} }

View File

@ -390,11 +390,11 @@ export interface SidebarStatePayload {
} }
export interface BootstrapResponse { export interface BootstrapResponse {
token: string; token?: string;
api_token: string; api_token?: string;
ws_path: string; ws_path: string;
ws_url?: string | null; ws_url?: string | null;
expires_in: number; expires_in?: number;
limits?: WebUIIngressLimits; limits?: WebUIIngressLimits;
model_name?: string | null; model_name?: string | null;
runtime_surface?: RuntimeSurface; runtime_surface?: RuntimeSurface;

View File

@ -1,7 +1,6 @@
import { afterEach, describe, expect, it, vi } from "vitest"; import { afterEach, describe, expect, it, vi } from "vitest";
import { import {
BootstrapAuthRequiredError,
consumeUrlBootstrapSecret, consumeUrlBootstrapSecret,
deriveWsUrl, deriveWsUrl,
fetchBootstrap, 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 () => { it("times out when the bootstrap endpoint never responds", async () => {
vi.useFakeTimers(); vi.useFakeTimers();
vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {}))); vi.stubGlobal("fetch", vi.fn(() => new Promise<Response>(() => {})));
@ -69,21 +74,19 @@ describe("bootstrap helpers", () => {
await pending; await pending;
}); });
it("treats bootstrap responses without an API token as auth-required", async () => { it("accepts tokenless trusted-proxy bootstrap responses", async () => {
vi.stubGlobal( vi.stubGlobal(
"fetch", "fetch",
vi.fn(async () => ({ vi.fn(async () => ({
ok: true, 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(fetchBootstrap()).resolves.toMatchObject({
await expect(promise).rejects.toMatchObject({ ws_path: "/",
name: "BootstrapAuthRequiredError", ws_url: "wss://proxy.example/",
message: "bootstrap authentication required: missing api_token",
}); });
await expect(promise).rejects.toBeInstanceOf(BootstrapAuthRequiredError);
}); });
it("consumes bootstrap secrets from the URL fragment", () => { it("consumes bootstrap secrets from the URL fragment", () => {