From fe81b1d7125a014b8280da461f34efbf5f761575 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 2 Feb 2026 16:55:53 -0800 Subject: [PATCH] fix(gateway): require shared auth before device bypass --- CHANGELOG.md | 1 + src/gateway/server.auth.e2e.test.ts | 53 +++++++++ .../server/ws-connection/message-handler.ts | 107 +++++++++++------- src/gateway/test-helpers.mocks.ts | 12 ++ src/gateway/test-helpers.server.ts | 2 + 5 files changed, 131 insertions(+), 44 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 75b9e38a1..c024ecb1f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -20,6 +20,7 @@ Docs: https://docs.openclaw.ai - Telegram: recover from grammY long-poll timed out errors. (#7466) Thanks @macmimi23. - Media understanding: skip binary media from file text extraction. (#7475) Thanks @AlexZhangji. - Security: enforce access-group gating for Slack slash commands when channel type lookup fails. +- Security: require validated shared-secret auth before skipping device identity on gateway connect. - Security: guard skill installer downloads with SSRF checks (block private/localhost URLs). - Media understanding: apply SSRF guardrails to provider fetches; allow private baseUrl overrides explicitly. - Tests: stub SSRF DNS pinning in web auto-reply + Gemini video coverage. (#6619) Thanks @joshp123. diff --git a/src/gateway/server.auth.e2e.test.ts b/src/gateway/server.auth.e2e.test.ts index 2fcf12a20..691522516 100644 --- a/src/gateway/server.auth.e2e.test.ts +++ b/src/gateway/server.auth.e2e.test.ts @@ -11,6 +11,7 @@ import { onceMessage, startGatewayServer, startServerWithClient, + testTailscaleWhois, testState, } from "./test-helpers.js"; @@ -35,6 +36,20 @@ const openWs = async (port: number) => { return ws; }; +const openTailscaleWs = async (port: number) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { + "x-forwarded-for": "100.64.0.1", + "x-forwarded-proto": "https", + "x-forwarded-host": "gateway.tailnet.ts.net", + "tailscale-user-login": "peter", + "tailscale-user-name": "Peter", + }, + }); + await new Promise((resolve) => ws.once("open", resolve)); + return ws; +}; + describe("gateway server auth/connect", () => { describe("default auth (token)", () => { let server: Awaited>; @@ -279,6 +294,44 @@ describe("gateway server auth/connect", () => { }); }); + describe("tailscale auth", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + testState.gatewayAuth = { mode: "token", token: "secret", allowTailscale: true }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + }); + + beforeEach(() => { + testTailscaleWhois.value = { login: "peter", name: "Peter" }; + }); + + afterEach(() => { + testTailscaleWhois.value = null; + }); + + test("requires device identity when only tailscale auth is available", async () => { + const ws = await openTailscaleWs(port); + const res = await connectReq(ws, { token: "dummy", device: null }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("device identity required"); + ws.close(); + }); + + test("allows shared token to skip device when tailscale auth is enabled", async () => { + const ws = await openTailscaleWs(port); + const res = await connectReq(ws, { token: "secret", device: null }); + expect(res.ok).toBe(true); + ws.close(); + }); + }); + test("allows control ui without device identity when insecure auth is enabled", async () => { testState.gatewayControlUi = { allowInsecureAuth: true }; const { server, ws, prevToken } = await startServerWithClient("secret"); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 3ddcb7253..04e3bf5ea 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -377,8 +377,63 @@ export function attachGatewayWsMessageHandler(params: { isControlUi && configSnapshot.gateway?.controlUi?.dangerouslyDisableDeviceAuth === true; const allowControlUiBypass = allowInsecureControlUi || disableControlUiDeviceAuth; const device = disableControlUiDeviceAuth ? null : deviceRaw; + + const authResult = await authorizeGatewayConnect({ + auth: resolvedAuth, + connectAuth: connectParams.auth, + req: upgradeReq, + trustedProxies, + }); + let authOk = authResult.ok; + let authMethod = + authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); + const sharedAuthResult = hasSharedAuth + ? await authorizeGatewayConnect({ + auth: { ...resolvedAuth, allowTailscale: false }, + connectAuth: connectParams.auth, + req: upgradeReq, + trustedProxies, + }) + : null; + const sharedAuthOk = + sharedAuthResult?.ok === true && + (sharedAuthResult.method === "token" || sharedAuthResult.method === "password"); + const rejectUnauthorized = () => { + setHandshakeState("failed"); + logWsControl.warn( + `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`, + ); + const authProvided: AuthProvidedKind = connectParams.auth?.token + ? "token" + : connectParams.auth?.password + ? "password" + : "none"; + const authMessage = formatGatewayAuthFailureMessage({ + authMode: resolvedAuth.mode, + authProvided, + reason: authResult.reason, + client: connectParams.client, + }); + setCloseCause("unauthorized", { + authMode: resolvedAuth.mode, + authProvided, + authReason: authResult.reason, + allowTailscale: resolvedAuth.allowTailscale, + client: connectParams.client.id, + clientDisplayName: connectParams.client.displayName, + mode: connectParams.client.mode, + version: connectParams.client.version, + }); + send({ + type: "res", + id: frame.id, + ok: false, + error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage), + }); + close(1008, truncateCloseReason(authMessage)); + }; if (!device) { - const canSkipDevice = allowControlUiBypass ? hasSharedAuth : hasTokenAuth; + const canSkipDevice = sharedAuthOk; if (isControlUi && !allowControlUiBypass) { const errorMessage = "control ui requires HTTPS or localhost (secure context)"; @@ -399,8 +454,12 @@ export function attachGatewayWsMessageHandler(params: { return; } - // Allow token-authenticated connections (e.g., control-ui) to skip device identity + // Allow shared-secret authenticated connections (e.g., control-ui) to skip device identity if (!canSkipDevice) { + if (!authOk && hasSharedAuth) { + rejectUnauthorized(); + return; + } setHandshakeState("failed"); setCloseCause("device-required", { client: connectParams.client.id, @@ -567,15 +626,6 @@ export function attachGatewayWsMessageHandler(params: { } } - const authResult = await authorizeGatewayConnect({ - auth: resolvedAuth, - connectAuth: connectParams.auth, - req: upgradeReq, - trustedProxies, - }); - let authOk = authResult.ok; - let authMethod = - authResult.method ?? (resolvedAuth.mode === "password" ? "password" : "token"); if (!authOk && connectParams.auth?.token && device) { const tokenCheck = await verifyDeviceToken({ deviceId: device.id, @@ -589,42 +639,11 @@ export function attachGatewayWsMessageHandler(params: { } } if (!authOk) { - setHandshakeState("failed"); - logWsControl.warn( - `unauthorized conn=${connId} remote=${remoteAddr ?? "?"} client=${clientLabel} ${connectParams.client.mode} v${connectParams.client.version} reason=${authResult.reason ?? "unknown"}`, - ); - const authProvided: AuthProvidedKind = connectParams.auth?.token - ? "token" - : connectParams.auth?.password - ? "password" - : "none"; - const authMessage = formatGatewayAuthFailureMessage({ - authMode: resolvedAuth.mode, - authProvided, - reason: authResult.reason, - client: connectParams.client, - }); - setCloseCause("unauthorized", { - authMode: resolvedAuth.mode, - authProvided, - authReason: authResult.reason, - allowTailscale: resolvedAuth.allowTailscale, - client: connectParams.client.id, - clientDisplayName: connectParams.client.displayName, - mode: connectParams.client.mode, - version: connectParams.client.version, - }); - send({ - type: "res", - id: frame.id, - ok: false, - error: errorShape(ErrorCodes.INVALID_REQUEST, authMessage), - }); - close(1008, truncateCloseReason(authMessage)); + rejectUnauthorized(); return; } - const skipPairing = allowControlUiBypass && hasSharedAuth; + const skipPairing = allowControlUiBypass && sharedAuthOk; if (device && devicePublicKey && !skipPairing) { const requirePairing = async (reason: string, _paired?: { deviceId: string }) => { const pairing = await requestDevicePairing({ diff --git a/src/gateway/test-helpers.mocks.ts b/src/gateway/test-helpers.mocks.ts index 792a644c9..aa811d850 100644 --- a/src/gateway/test-helpers.mocks.ts +++ b/src/gateway/test-helpers.mocks.ts @@ -7,6 +7,7 @@ import { Mock, vi } from "vitest"; import type { ChannelPlugin, ChannelOutboundAdapter } from "../channels/plugins/types.js"; import type { AgentBinding } from "../config/types.agents.js"; import type { HooksConfig } from "../config/types.hooks.js"; +import type { TailscaleWhoisIdentity } from "../infra/tailscale.js"; import type { PluginRegistry } from "../plugins/registry.js"; import { applyPluginAutoEnable } from "../config/plugin-auto-enable.js"; import { setActivePluginRegistry } from "../plugins/runtime.js"; @@ -167,6 +168,7 @@ const hoisted = vi.hoisted(() => ({ waitCalls: [] as string[], waitResults: new Map(), }, + testTailscaleWhois: { value: null as TailscaleWhoisIdentity | null }, getReplyFromConfig: vi.fn().mockResolvedValue(undefined), sendWhatsAppMock: vi.fn().mockResolvedValue({ messageId: "msg-1", toJid: "jid-1" }), })); @@ -196,6 +198,7 @@ export const setTestConfigRoot = (root: string) => { }; export const testTailnetIPv4 = hoisted.testTailnetIPv4; +export const testTailscaleWhois = hoisted.testTailscaleWhois; export const piSdkMock = hoisted.piSdkMock; export const cronIsolatedRun = hoisted.cronIsolatedRun; export const agentCommand: Mock<() => void> = hoisted.agentCommand; @@ -258,6 +261,15 @@ vi.mock("../infra/tailnet.js", () => ({ pickPrimaryTailnetIPv6: () => undefined, })); +vi.mock("../infra/tailscale.js", async () => { + const actual = + await vi.importActual("../infra/tailscale.js"); + return { + ...actual, + readTailscaleWhoisIdentity: async () => testTailscaleWhois.value, + }; +}); + vi.mock("../config/sessions.js", async () => { const actual = await vi.importActual("../config/sessions.js"); diff --git a/src/gateway/test-helpers.server.ts b/src/gateway/test-helpers.server.ts index 99317df44..8403e2a12 100644 --- a/src/gateway/test-helpers.server.ts +++ b/src/gateway/test-helpers.server.ts @@ -28,6 +28,7 @@ import { sessionStoreSaveDelayMs, setTestConfigRoot, testIsNixMode, + testTailscaleWhois, testState, testTailnetIPv4, } from "./test-helpers.mocks.js"; @@ -109,6 +110,7 @@ async function resetGatewayTestState(options: { uniqueConfigRoot: boolean }) { setTestConfigRoot(tempConfigRoot); sessionStoreSaveDelayMs.value = 0; testTailnetIPv4.value = undefined; + testTailscaleWhois.value = null; testState.gatewayBind = undefined; testState.gatewayAuth = { mode: "token", token: "test-gateway-token-1234567890" }; testState.gatewayControlUi = undefined;