fix(gateway): scope tailscale tokenless auth to websocket

This commit is contained in:
Peter Steinberger
2026-02-21 13:03:08 +01:00
parent 6aa11f3092
commit 356d61aacf
16 changed files with 134 additions and 15 deletions

View File

@@ -2060,7 +2060,7 @@ See [Plugins](/tools/plugin).
- **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default. - **Auth**: required by default. Non-loopback binds require a shared token/password. Onboarding wizard generates a token by default.
- `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts. - `auth.mode: "none"`: explicit no-auth mode. Use only for trusted local loopback setups; this is intentionally not offered by onboarding prompts.
- `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)). - `auth.mode: "trusted-proxy"`: delegate auth to an identity-aware reverse proxy and trust identity headers from `gateway.trustedProxies` (see [Trusted Proxy Auth](/gateway/trusted-proxy-auth)).
- `auth.allowTailscale`: when `true`, Tailscale Serve identity headers satisfy auth (verified via `tailscale whois`). This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`. - `auth.allowTailscale`: when `true`, Tailscale Serve identity headers can satisfy Control UI/WebSocket auth (verified via `tailscale whois`); HTTP API endpoints still require token/password auth. This tokenless flow assumes the gateway host is trusted. Defaults to `true` when `tailscale.mode = "serve"`.
- `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`. - `auth.rateLimit`: optional failed-auth limiter. Applies per client IP and per auth scope (shared-secret and device-token are tracked independently). Blocked attempts return `429` + `Retry-After`.
- `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments). - `auth.rateLimit.exemptLoopback` defaults to `true`; set `false` when you intentionally want localhost traffic rate-limited too (for test setups or strict proxy deployments).
- `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth). - `tailscale.mode`: `serve` (tailnet only, loopback bind) or `funnel` (public, requires auth).

View File

@@ -122,9 +122,10 @@ Short version: **keep the Gateway loopback-only** unless youre sure you need
- **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords. - **Non-loopback binds** (`lan`/`tailnet`/`custom`, or `auto` when loopback is unavailable) must use auth tokens/passwords.
- `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth. - `gateway.remote.token` is **only** for remote CLI calls — it does **not** enable local auth.
- `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`. - `gateway.remote.tlsFingerprint` pins the remote TLS cert when using `wss://`.
- **Tailscale Serve** can authenticate via identity headers when `gateway.auth.allowTailscale: true`. - **Tailscale Serve** can authenticate Control UI/WebSocket traffic via identity
This tokenless flow assumes the gateway host is trusted. Set it to `false` if you headers when `gateway.auth.allowTailscale: true`; HTTP API endpoints still
want tokens/passwords instead. require token/password auth. This tokenless flow assumes the gateway host is
trusted. Set it to `false` if you want tokens/passwords everywhere.
- Treat browser control like operator access: tailnet-only + deliberate node pairing. - Treat browser control like operator access: tailnet-only + deliberate node pairing.
Deep dive: [Security](/gateway/security). Deep dive: [Security](/gateway/security).

View File

@@ -532,12 +532,14 @@ Rotation checklist (token/password):
### 0.6) Tailscale Serve identity headers ### 0.6) Tailscale Serve identity headers
When `gateway.auth.allowTailscale` is `true` (default for Serve), OpenClaw When `gateway.auth.allowTailscale` is `true` (default for Serve), OpenClaw
accepts Tailscale Serve identity headers (`tailscale-user-login`) as accepts Tailscale Serve identity headers (`tailscale-user-login`) for Control
authentication. OpenClaw verifies the identity by resolving the UI/WebSocket authentication. OpenClaw verifies the identity by resolving the
`x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`) `x-forwarded-for` address through the local Tailscale daemon (`tailscale whois`)
and matching it to the header. This only triggers for requests that hit loopback and matching it to the header. This only triggers for requests that hit loopback
and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as and include `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` as
injected by Tailscale. injected by Tailscale.
HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`)
still require token/password auth.
**Trust assumption:** tokenless Serve auth assumes the gateway host is trusted. **Trust assumption:** tokenless Serve auth assumes the gateway host is trusted.
Do not treat this as protection against hostile same-host processes. If untrusted Do not treat this as protection against hostile same-host processes. If untrusted

View File

@@ -26,13 +26,15 @@ Set `gateway.auth.mode` to control the handshake:
- `password` (shared secret via `OPENCLAW_GATEWAY_PASSWORD` or config) - `password` (shared secret via `OPENCLAW_GATEWAY_PASSWORD` or config)
When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`, When `tailscale.mode = "serve"` and `gateway.auth.allowTailscale` is `true`,
valid Serve proxy requests can authenticate via Tailscale identity headers Control UI/WebSocket auth can use Tailscale identity headers
(`tailscale-user-login`) without supplying a token/password. OpenClaw verifies (`tailscale-user-login`) without supplying a token/password. OpenClaw verifies
the identity by resolving the `x-forwarded-for` address via the local Tailscale the identity by resolving the `x-forwarded-for` address via the local Tailscale
daemon (`tailscale whois`) and matching it to the header before accepting it. daemon (`tailscale whois`) and matching it to the header before accepting it.
OpenClaw only treats a request as Serve when it arrives from loopback with OpenClaw only treats a request as Serve when it arrives from loopback with
Tailscales `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host` Tailscales `x-forwarded-for`, `x-forwarded-proto`, and `x-forwarded-host`
headers. headers.
HTTP API endpoints (for example `/v1/*`, `/tools/invoke`, and `/api/channels/*`)
still require token/password auth.
This tokenless flow assumes the gateway host is trusted. If untrusted local code This tokenless flow assumes the gateway host is trusted. If untrusted local code
may run on the same host, disable `gateway.auth.allowTailscale` and require may run on the same host, disable `gateway.auth.allowTailscale` and require
token/password auth instead. token/password auth instead.

View File

@@ -348,7 +348,7 @@ The wizard opens your browser with a clean (non-tokenized) dashboard URL right a
**Not on localhost:** **Not on localhost:**
- **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy auth (no token, assumes trusted gateway host). - **Tailscale Serve** (recommended): keep bind loopback, run `openclaw gateway --tailscale serve`, open `https://<magicdns>/`. If `gateway.auth.allowTailscale` is `true`, identity headers satisfy Control UI/WebSocket auth (no token, assumes trusted gateway host); HTTP APIs still require token/password.
- **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings. - **Tailnet bind**: run `openclaw gateway --bind tailnet --token "<token>"`, open `http://<tailscale-ip>:18789/`, paste token in dashboard settings.
- **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/` and paste the token in Control UI settings. - **SSH tunnel**: `ssh -N -L 18789:127.0.0.1:18789 user@host` then open `http://127.0.0.1:18789/` and paste the token in Control UI settings.

View File

@@ -132,7 +132,7 @@ Open: `https://<magicdns>/`
Notes: Notes:
- Serve keeps the Gateway loopback-only and authenticates via Tailscale identity headers (tokenless auth assumes trusted gateway host). - Serve keeps the Gateway loopback-only and authenticates Control UI/WebSocket traffic via Tailscale identity headers (tokenless auth assumes trusted gateway host; HTTP APIs still require token/password).
- To require token/password instead, set `gateway.auth.allowTailscale: false` or use `gateway.auth.mode: "password"`. - To require token/password instead, set `gateway.auth.allowTailscale: false` or use `gateway.auth.mode: "password"`.
**Option C: Tailnet bind (no Serve)** **Option C: Tailnet bind (no Serve)**

View File

@@ -117,7 +117,7 @@ Open:
- `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`) - `https://<magicdns>/` (or your configured `gateway.controlUi.basePath`)
By default, Serve requests can authenticate via Tailscale identity headers By default, Control UI/WebSocket Serve requests can authenticate via Tailscale identity headers
(`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw (`tailscale-user-login`) when `gateway.auth.allowTailscale` is `true`. OpenClaw
verifies the identity by resolving the `x-forwarded-for` address with verifies the identity by resolving the `x-forwarded-for` address with
`tailscale whois` and matching it to the header, and only accepts these when the `tailscale whois` and matching it to the header, and only accepts these when the

View File

@@ -37,7 +37,7 @@ Prefer localhost, Tailscale Serve, or an SSH tunnel.
- **Localhost**: open `http://127.0.0.1:18789/`. - **Localhost**: open `http://127.0.0.1:18789/`.
- **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect. - **Token source**: `gateway.auth.token` (or `OPENCLAW_GATEWAY_TOKEN`); the UI stores a copy in localStorage after you connect.
- **Not localhost**: use Tailscale Serve (tokenless if `gateway.auth.allowTailscale: true`, assumes trusted gateway host), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web). - **Not localhost**: use Tailscale Serve (tokenless for Control UI/WebSocket if `gateway.auth.allowTailscale: true`, assumes trusted gateway host; HTTP APIs still need token/password), tailnet bind with a token, or an SSH tunnel. See [Web surfaces](/web).
## If you see “unauthorized” / 1008 ## If you see “unauthorized” / 1008

View File

@@ -101,8 +101,9 @@ Open:
- The UI sends `connect.params.auth.token` or `connect.params.auth.password`. - The UI sends `connect.params.auth.token` or `connect.params.auth.password`.
- The Control UI sends anti-clickjacking headers and only accepts same-origin browser - The Control UI sends anti-clickjacking headers and only accepts same-origin browser
websocket connections unless `gateway.controlUi.allowedOrigins` is set. websocket connections unless `gateway.controlUi.allowedOrigins` is set.
- With Serve, Tailscale identity headers can satisfy auth when - With Serve, Tailscale identity headers can satisfy Control UI/WebSocket auth
`gateway.auth.allowTailscale` is `true` (no token/password required). Set when `gateway.auth.allowTailscale` is `true` (no token/password required).
HTTP API endpoints still require token/password. Set
`gateway.auth.allowTailscale: false` to require explicit credentials. See `gateway.auth.allowTailscale: false` to require explicit credentials. See
[Tailscale](/gateway/tailscale) and [Security](/gateway/security). This [Tailscale](/gateway/tailscale) and [Security](/gateway/security). This
tokenless flow assumes the gateway host is trusted. tokenless flow assumes the gateway host is trusted.

View File

@@ -188,7 +188,7 @@ describe("gateway auth", () => {
expect(res.method).toBe("token"); expect(res.method).toBe("token");
}); });
it("allows tailscale identity to satisfy token mode auth", async () => { it("does not allow tailscale identity to satisfy token mode auth by default", async () => {
const res = await authorizeGatewayConnect({ const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true }, auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null, connectAuth: null,
@@ -206,6 +206,29 @@ describe("gateway auth", () => {
} as never, } as never,
}); });
expect(res.ok).toBe(false);
expect(res.reason).toBe("token_missing");
});
it("allows tailscale identity when header auth is explicitly enabled", async () => {
const res = await authorizeGatewayConnect({
auth: { mode: "token", token: "secret", allowTailscale: true },
connectAuth: null,
tailscaleWhois: async () => ({ login: "peter", name: "Peter" }),
allowTailscaleHeaderAuth: true,
req: {
socket: { remoteAddress: "127.0.0.1" },
headers: {
host: "gateway.local",
"x-forwarded-for": "100.64.0.1",
"x-forwarded-proto": "https",
"x-forwarded-host": "ai-hub.bone-egret.ts.net",
"tailscale-user-login": "peter",
"tailscale-user-name": "Peter",
},
} as never,
});
expect(res.ok).toBe(true); expect(res.ok).toBe(true);
expect(res.method).toBe("tailscale"); expect(res.method).toBe("tailscale");
expect(res.user).toBe("peter"); expect(res.user).toBe("peter");

View File

@@ -325,6 +325,11 @@ export async function authorizeGatewayConnect(params: {
req?: IncomingMessage; req?: IncomingMessage;
trustedProxies?: string[]; trustedProxies?: string[];
tailscaleWhois?: TailscaleWhoisLookup; tailscaleWhois?: TailscaleWhoisLookup;
/**
* Opt-in for accepting Tailscale Serve identity headers as primary auth.
* Default is disabled for HTTP surfaces; WS connect enables this explicitly.
*/
allowTailscaleHeaderAuth?: boolean;
/** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */ /** Optional rate limiter instance; when provided, failed attempts are tracked per IP. */
rateLimiter?: AuthRateLimiter; rateLimiter?: AuthRateLimiter;
/** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */ /** Client IP used for rate-limit tracking. Falls back to proxy-aware request IP resolution. */
@@ -334,6 +339,7 @@ export async function authorizeGatewayConnect(params: {
}): Promise<GatewayAuthResult> { }): Promise<GatewayAuthResult> {
const { auth, connectAuth, req, trustedProxies } = params; const { auth, connectAuth, req, trustedProxies } = params;
const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity; const tailscaleWhois = params.tailscaleWhois ?? readTailscaleWhoisIdentity;
const allowTailscaleHeaderAuth = params.allowTailscaleHeaderAuth === true;
const localDirect = isLocalDirectRequest(req, trustedProxies); const localDirect = isLocalDirectRequest(req, trustedProxies);
if (auth.mode === "trusted-proxy") { if (auth.mode === "trusted-proxy") {
@@ -376,7 +382,7 @@ export async function authorizeGatewayConnect(params: {
} }
} }
if (auth.allowTailscale && !localDirect) { if (allowTailscaleHeaderAuth && auth.allowTailscale && !localDirect) {
const tailscaleCheck = await resolveVerifiedTailscaleUser({ const tailscaleCheck = await resolveVerifiedTailscaleUser({
req, req,
tailscaleWhois, tailscaleWhois,

View File

@@ -0,0 +1,79 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedGatewayAuth } from "./auth.js";
import { authorizeGatewayBearerRequestOrReply } from "./http-auth-helpers.js";
vi.mock("./auth.js", () => ({
authorizeGatewayConnect: vi.fn(),
}));
vi.mock("./http-common.js", () => ({
sendGatewayAuthFailure: vi.fn(),
}));
vi.mock("./http-utils.js", () => ({
getBearerToken: vi.fn(),
}));
const { authorizeGatewayConnect } = await import("./auth.js");
const { sendGatewayAuthFailure } = await import("./http-common.js");
const { getBearerToken } = await import("./http-utils.js");
describe("authorizeGatewayBearerRequestOrReply", () => {
beforeEach(() => {
vi.clearAllMocks();
});
it("disables tailscale header auth for HTTP bearer checks", async () => {
vi.mocked(getBearerToken).mockReturnValue(null);
vi.mocked(authorizeGatewayConnect).mockResolvedValue({
ok: false,
reason: "token_missing",
});
const ok = await authorizeGatewayBearerRequestOrReply({
req: {} as IncomingMessage,
res: {} as ServerResponse,
auth: {
mode: "token",
token: "secret",
password: undefined,
allowTailscale: true,
} satisfies ResolvedGatewayAuth,
});
expect(ok).toBe(false);
expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
expect.objectContaining({
allowTailscaleHeaderAuth: false,
connectAuth: null,
}),
);
expect(vi.mocked(sendGatewayAuthFailure)).toHaveBeenCalledTimes(1);
});
it("forwards bearer token and returns true on successful auth", async () => {
vi.mocked(getBearerToken).mockReturnValue("abc");
vi.mocked(authorizeGatewayConnect).mockResolvedValue({ ok: true, method: "token" });
const ok = await authorizeGatewayBearerRequestOrReply({
req: {} as IncomingMessage,
res: {} as ServerResponse,
auth: {
mode: "token",
token: "secret",
password: undefined,
allowTailscale: true,
} satisfies ResolvedGatewayAuth,
});
expect(ok).toBe(true);
expect(vi.mocked(authorizeGatewayConnect)).toHaveBeenCalledWith(
expect.objectContaining({
allowTailscaleHeaderAuth: false,
connectAuth: { token: "abc", password: "abc" },
}),
);
expect(vi.mocked(sendGatewayAuthFailure)).not.toHaveBeenCalled();
});
});

View File

@@ -17,6 +17,7 @@ export async function authorizeGatewayBearerRequestOrReply(params: {
connectAuth: token ? { token, password: token } : null, connectAuth: token ? { token, password: token } : null,
req: params.req, req: params.req,
trustedProxies: params.trustedProxies, trustedProxies: params.trustedProxies,
allowTailscaleHeaderAuth: false,
rateLimiter: params.rateLimiter, rateLimiter: params.rateLimiter,
}); });
if (!authResult.ok) { if (!authResult.ok) {

View File

@@ -155,6 +155,7 @@ async function authorizeCanvasRequest(params: {
connectAuth: { token, password: token }, connectAuth: { token, password: token },
req, req,
trustedProxies, trustedProxies,
allowTailscaleHeaderAuth: false,
rateLimiter, rateLimiter,
}); });
if (authResult.ok) { if (authResult.ok) {
@@ -532,6 +533,7 @@ export function createGatewayHttpServer(opts: {
connectAuth: token ? { token, password: token } : null, connectAuth: token ? { token, password: token } : null,
req, req,
trustedProxies, trustedProxies,
allowTailscaleHeaderAuth: false,
rateLimiter, rateLimiter,
}); });
if (!authResult.ok) { if (!authResult.ok) {

View File

@@ -351,6 +351,7 @@ export function attachGatewayWsMessageHandler(params: {
connectAuth: connectParams.auth, connectAuth: connectParams.auth,
req: upgradeReq, req: upgradeReq,
trustedProxies, trustedProxies,
allowTailscaleHeaderAuth: true,
rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter, rateLimiter: hasDeviceTokenCandidate ? undefined : rateLimiter,
clientIp, clientIp,
rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET, rateLimitScope: AUTH_RATE_LIMIT_SCOPE_SHARED_SECRET,

View File

@@ -151,6 +151,7 @@ export async function handleToolsInvokeHttpRequest(
connectAuth: token ? { token, password: token } : null, connectAuth: token ? { token, password: token } : null,
req, req,
trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies, trustedProxies: opts.trustedProxies ?? cfg.gateway?.trustedProxies,
allowTailscaleHeaderAuth: false,
rateLimiter: opts.rateLimiter, rateLimiter: opts.rateLimiter,
}); });
if (!authResult.ok) { if (!authResult.ok) {