diff --git a/src/gateway/server.auth.test.ts b/src/gateway/server.auth.control-ui.suite.ts similarity index 54% rename from src/gateway/server.auth.test.ts rename to src/gateway/server.auth.control-ui.suite.ts index fe4bc550949..68b27e657b2 100644 --- a/src/gateway/server.auth.test.ts +++ b/src/gateway/server.auth.control-ui.suite.ts @@ -1,898 +1,33 @@ -import os from "node:os"; -import path from "node:path"; -import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; +import { expect, test } from "vitest"; import { WebSocket } from "ws"; -import { withEnvAsync } from "../test-utils/env.js"; -import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; -import { buildDeviceAuthPayload } from "./device-auth.js"; -import { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js"; -import { PROTOCOL_VERSION } from "./protocol/index.js"; -import { getHandshakeTimeoutMs } from "./server-constants.js"; import { + approvePendingPairingIfNeeded, + BACKEND_GATEWAY_CLIENT, + buildDeviceAuthPayload, connectReq, - getTrackedConnectChallengeNonce, - getFreePort, - installGatewayTestHooks, + configureTrustedProxyControlUiAuth, + CONTROL_UI_CLIENT, + ConnectErrorDetailCodes, + createSignedDevice, + ensurePairedDeviceTokenForCurrentIdentity, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, onceMessage, + openWs, + originForPort, + readConnectChallengeNonce, + restoreGatewayToken, rpcReq, - startGatewayServer, + startRateLimitedTokenServerWithPairedDeviceToken, startServerWithClient, - trackConnectChallengeNonce, - testTailscaleWhois, + TEST_OPERATOR_CLIENT, testState, + TRUSTED_PROXY_CONTROL_UI_HEADERS, withGatewayServer, -} from "./test-helpers.js"; - -installGatewayTestHooks({ scope: "suite" }); - -async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { - if (ws.readyState === WebSocket.CLOSED) { - return true; - } - return await new Promise((resolve) => { - const timer = setTimeout(() => resolve(ws.readyState === WebSocket.CLOSED), timeoutMs); - ws.once("close", () => { - clearTimeout(timer); - resolve(true); - }); - }); -} - -const openWs = async (port: number, headers?: Record) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); - trackConnectChallengeNonce(ws); - await new Promise((resolve) => ws.once("open", resolve)); - return ws; -}; - -const readConnectChallengeNonce = async (ws: WebSocket) => { - const cached = getTrackedConnectChallengeNonce(ws); - if (cached) { - return cached; - } - const challenge = await onceMessage<{ - type?: string; - event?: string; - payload?: Record | null; - }>(ws, (o) => o.type === "event" && o.event === "connect.challenge"); - const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - return String(nonce); -}; - -const openTailscaleWs = async (port: number) => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { - origin: "https://gateway.tailnet.ts.net", - "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", - }, - }); - trackConnectChallengeNonce(ws); - await new Promise((resolve) => ws.once("open", resolve)); - return ws; -}; - -const originForPort = (port: number) => `http://127.0.0.1:${port}`; - -function restoreGatewayToken(prevToken: string | undefined) { - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } -} - -async function withRuntimeVersionEnv( - env: Record, - run: () => Promise, -): Promise { - return withEnvAsync(env, run); -} - -const TEST_OPERATOR_CLIENT = { - id: GATEWAY_CLIENT_NAMES.TEST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.TEST, -}; - -const CONTROL_UI_CLIENT = { - id: GATEWAY_CLIENT_NAMES.CONTROL_UI, - version: "1.0.0", - platform: "web", - mode: GATEWAY_CLIENT_MODES.WEBCHAT, -}; - -const TRUSTED_PROXY_CONTROL_UI_HEADERS = { - origin: "https://localhost", - "x-forwarded-for": "203.0.113.10", - "x-forwarded-proto": "https", - "x-forwarded-user": "peter@example.com", -} as const; - -const NODE_CLIENT = { - id: GATEWAY_CLIENT_NAMES.NODE_HOST, - version: "1.0.0", - platform: "test", - mode: GATEWAY_CLIENT_MODES.NODE, -}; - -const BACKEND_GATEWAY_CLIENT = { - id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, - version: "1.0.0", - platform: "node", - mode: GATEWAY_CLIENT_MODES.BACKEND, -}; - -async function expectHelloOkServerVersion(port: number, expectedVersion: string) { - const ws = await openWs(port); - try { - const res = await connectReq(ws); - expect(res.ok).toBe(true); - const payload = res.payload as - | { - type?: unknown; - server?: { version?: string }; - } - | undefined; - expect(payload?.type).toBe("hello-ok"); - expect(payload?.server?.version).toBe(expectedVersion); - } finally { - ws.close(); - } -} - -async function createSignedDevice(params: { - token?: string | null; - scopes: string[]; - clientId: string; - clientMode: string; - role?: "operator" | "node"; - identityPath?: string; - nonce: string; - signedAtMs?: number; -}) { - const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = - await import("../infra/device-identity.js"); - const identity = params.identityPath - ? loadOrCreateDeviceIdentity(params.identityPath) - : loadOrCreateDeviceIdentity(); - const signedAtMs = params.signedAtMs ?? Date.now(); - const payload = buildDeviceAuthPayload({ - deviceId: identity.deviceId, - clientId: params.clientId, - clientMode: params.clientMode, - role: params.role ?? "operator", - scopes: params.scopes, - signedAtMs, - token: params.token ?? null, - nonce: params.nonce, - }); - return { - identity, - signedAtMs, - device: { - id: identity.deviceId, - publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), - signature: signDevicePayload(identity.privateKeyPem, payload), - signedAt: signedAtMs, - nonce: params.nonce, - }, - }; -} - -function resolveGatewayTokenOrEnv(): string { - const token = - typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" - ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) - : process.env.OPENCLAW_GATEWAY_TOKEN; - expect(typeof token).toBe("string"); - return String(token ?? ""); -} - -async function approvePendingPairingIfNeeded() { - const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); - const list = await listDevicePairing(); - const pending = list.pending.at(0); - expect(pending?.requestId).toBeDefined(); - if (pending?.requestId) { - await approveDevicePairing(pending.requestId); - } -} - -async function configureTrustedProxyControlUiAuth() { - testState.gatewayAuth = { - mode: "trusted-proxy", - trustedProxy: { - userHeader: "x-forwarded-user", - requiredHeaders: ["x-forwarded-proto"], - }, - }; - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - gateway: { - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any); -} - -function isConnectResMessage(id: string) { - return (o: unknown) => { - if (!o || typeof o !== "object" || Array.isArray(o)) { - return false; - } - const rec = o as Record; - return rec.type === "res" && rec.id === id; - }; -} - -async function sendRawConnectReq( - ws: WebSocket, - params: { - id: string; - token?: string; - device: { id: string; publicKey: string; signature: string; signedAt: number; nonce?: string }; - }, -) { - ws.send( - JSON.stringify({ - type: "req", - id: params.id, - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: TEST_OPERATOR_CLIENT, - caps: [], - role: "operator", - auth: params.token ? { token: params.token } : undefined, - device: params.device, - }, - }), - ); - return onceMessage<{ - type?: string; - id?: string; - ok?: boolean; - payload?: Record | null; - error?: { - message?: string; - details?: { - code?: string; - reason?: string; - }; - }; - }>(ws, isConnectResMessage(params.id)); -} - -async function startRateLimitedTokenServerWithPairedDeviceToken() { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - const { getPairedDevice } = await import("../infra/device-pairing.js"); - - testState.gatewayAuth = { - mode: "token", - token: "secret", - rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any; - - const { server, ws, port, prevToken } = await startServerWithClient(); - const deviceIdentityPath = path.join( - os.tmpdir(), - `openclaw-auth-rate-limit-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - try { - const initial = await connectReq(ws, { token: "secret", deviceIdentityPath }); - if (!initial.ok) { - await approvePendingPairingIfNeeded(); - } - - const identity = loadOrCreateDeviceIdentity(deviceIdentityPath); - const paired = await getPairedDevice(identity.deviceId); - const deviceToken = paired?.tokens?.operator?.token; - expect(paired?.deviceId).toBe(identity.deviceId); - expect(deviceToken).toBeDefined(); - - ws.close(); - return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath }; - } catch (err) { - ws.close(); - await server.close(); - restoreGatewayToken(prevToken); - throw err; - } -} - -async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{ - identity: { deviceId: string }; - deviceToken: string; - deviceIdentityPath: string; -}> { - const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); - const { getPairedDevice } = await import("../infra/device-pairing.js"); - - const deviceIdentityPath = path.join( - os.tmpdir(), - `openclaw-auth-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, - ); - - const res = await connectReq(ws, { token: "secret", deviceIdentityPath }); - if (!res.ok) { - await approvePendingPairingIfNeeded(); - } - - const identity = loadOrCreateDeviceIdentity(deviceIdentityPath); - const paired = await getPairedDevice(identity.deviceId); - const deviceToken = paired?.tokens?.operator?.token; - expect(paired?.deviceId).toBe(identity.deviceId); - expect(deviceToken).toBeDefined(); - return { - identity: { deviceId: identity.deviceId }, - deviceToken: String(deviceToken ?? ""), - deviceIdentityPath, - }; -} - -describe("gateway server auth/connect", () => { - describe("default auth (token)", () => { - let server: Awaited>; - let port: number; - - beforeAll(async () => { - port = await getFreePort(); - server = await startGatewayServer(port); - }); - - afterAll(async () => { - await server.close(); - }); - - test("closes silent handshakes after timeout", async () => { - vi.useRealTimers(); - const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; - process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20"; - try { - const ws = await openWs(port); - const handshakeTimeoutMs = getHandshakeTimeoutMs(); - const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500); - expect(closed).toBe(true); - } finally { - if (prevHandshakeTimeout === undefined) { - delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; - } else { - process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; - } - } - }); - - test("connect (req) handshake returns hello-ok payload", async () => { - const { CONFIG_PATH, STATE_DIR } = await import("../config/config.js"); - const ws = await openWs(port); - - const res = await connectReq(ws); - expect(res.ok).toBe(true); - const payload = res.payload as - | { - type?: unknown; - snapshot?: { configPath?: string; stateDir?: string }; - } - | undefined; - expect(payload?.type).toBe("hello-ok"); - expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH); - expect(payload?.snapshot?.stateDir).toBe(STATE_DIR); - - ws.close(); - }); - - test("connect (req) handshake resolves server version from env precedence", async () => { - for (const testCase of [ - { - env: { - OPENCLAW_VERSION: " ", - OPENCLAW_SERVICE_VERSION: "2.4.6-service", - npm_package_version: "1.0.0-package", - }, - expectedVersion: "2.4.6-service", - }, - { - env: { - OPENCLAW_VERSION: "9.9.9-cli", - OPENCLAW_SERVICE_VERSION: "2.4.6-service", - npm_package_version: "1.0.0-package", - }, - expectedVersion: "9.9.9-cli", - }, - { - env: { - OPENCLAW_VERSION: " ", - OPENCLAW_SERVICE_VERSION: "\t", - npm_package_version: "1.0.0-package", - }, - expectedVersion: "1.0.0-package", - }, - ]) { - await withRuntimeVersionEnv(testCase.env, async () => - expectHelloOkServerVersion(port, testCase.expectedVersion), - ); - } - }); - - test("device-less auth matrix", async () => { - const token = resolveGatewayTokenOrEnv(); - const matrix: Array<{ - name: string; - opts: Parameters[1]; - expectConnectOk: boolean; - expectConnectError?: string; - expectStatusOk?: boolean; - expectStatusError?: string; - }> = [ - { - name: "operator + valid shared token => connected with preserved scopes", - opts: { role: "operator", token, device: null }, - expectConnectOk: true, - expectStatusOk: true, - }, - { - name: "node + valid shared token => rejected without device", - opts: { role: "node", token, device: null, client: NODE_CLIENT }, - expectConnectOk: false, - expectConnectError: "device identity required", - }, - { - name: "operator + invalid shared token => unauthorized", - opts: { role: "operator", token: "wrong", device: null }, - expectConnectOk: false, - expectConnectError: "unauthorized", - }, - ]; - - for (const scenario of matrix) { - const ws = await openWs(port); - try { - const res = await connectReq(ws, scenario.opts); - expect(res.ok, scenario.name).toBe(scenario.expectConnectOk); - if (!scenario.expectConnectOk) { - expect(res.error?.message ?? "", scenario.name).toContain( - String(scenario.expectConnectError ?? ""), - ); - continue; - } - if (scenario.expectStatusOk !== undefined) { - const status = await rpcReq(ws, "status"); - expect(status.ok, scenario.name).toBe(scenario.expectStatusOk); - if (!scenario.expectStatusOk && scenario.expectStatusError) { - expect(status.error?.message ?? "", scenario.name).toContain( - scenario.expectStatusError, - ); - } - } - } finally { - ws.close(); - } - } - }); - - test("keeps health available but admin status restricted when scopes are empty", async () => { - const ws = await openWs(port); - try { - const res = await connectReq(ws, { scopes: [] }); - expect(res.ok).toBe(true); - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message).toContain("missing scope"); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - } finally { - ws.close(); - } - }); - - test("does not grant admin when scopes are omitted", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - - const { randomUUID } = await import("node:crypto"); - const os = await import("node:os"); - const path = await import("node:path"); - // Fresh identity: avoid leaking prior scopes (presence merges lists). - const { identity, device } = await createSignedDevice({ - token, - scopes: [], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`), - nonce, - }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-no-scopes", - token, - device, - }); - expect(connectRes.ok).toBe(true); - const helloOk = connectRes.payload as - | { - snapshot?: { - presence?: Array<{ deviceId?: unknown; scopes?: unknown }>; - }; - } - | undefined; - const presence = helloOk?.snapshot?.presence; - expect(Array.isArray(presence)).toBe(true); - const mine = presence?.find((entry) => entry.deviceId === identity.deviceId); - expect(mine).toBeTruthy(); - const presenceScopes = Array.isArray(mine?.scopes) ? mine?.scopes : []; - expect(presenceScopes).toEqual([]); - expect(presenceScopes).not.toContain("operator.admin"); - - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(false); - expect(status.error?.message).toContain("missing scope"); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - - ws.close(); - }); - - test("rejects device signature when scopes are omitted but signed with admin", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - - const { device } = await createSignedDevice({ - token, - scopes: ["operator.admin"], - clientId: GATEWAY_CLIENT_NAMES.TEST, - clientMode: GATEWAY_CLIENT_MODES.TEST, - nonce, - }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-no-scopes-signed-admin", - token, - device, - }); - expect(connectRes.ok).toBe(false); - expect(connectRes.error?.message ?? "").toContain("device signature invalid"); - expect(connectRes.error?.details?.code).toBe( - ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID, - ); - expect(connectRes.error?.details?.reason).toBe("device-signature"); - await new Promise((resolve) => ws.once("close", () => resolve())); - }); - - test("sends connect challenge on open", async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`); - const evtPromise = onceMessage<{ - type?: string; - event?: string; - payload?: Record | null; - }>(ws, (o) => o.type === "event" && o.event === "connect.challenge"); - await new Promise((resolve) => ws.once("open", resolve)); - const evt = await evtPromise; - const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; - expect(typeof nonce).toBe("string"); - ws.close(); - }); - - test("rejects protocol mismatch", async () => { - const ws = await openWs(port); - try { - const res = await connectReq(ws, { - minProtocol: PROTOCOL_VERSION + 1, - maxProtocol: PROTOCOL_VERSION + 2, - }); - expect(res.ok).toBe(false); - } catch { - // If the server closed before we saw the frame, that's acceptable. - } - ws.close(); - }); - - test("rejects non-connect first request", async () => { - const ws = await openWs(port); - ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); - const res = await onceMessage<{ type?: string; id?: string; ok?: boolean; error?: unknown }>( - ws, - (o) => o.type === "res" && o.id === "h1", - ); - expect(res.ok).toBe(false); - await new Promise((resolve) => ws.once("close", () => resolve())); - }); - - test("requires nonce for device auth", async () => { - const ws = new WebSocket(`ws://127.0.0.1:${port}`, { - headers: { host: "example.com" }, - }); - await new Promise((resolve) => ws.once("open", resolve)); - - const { device } = await createSignedDevice({ - token: "secret", - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - nonce: "nonce-not-sent", - }); - const { nonce: _nonce, ...deviceWithoutNonce } = device; - const res = await connectReq(ws, { - token: "secret", - device: deviceWithoutNonce, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("must have required property 'nonce'"); - await new Promise((resolve) => ws.once("close", () => resolve())); - }); - - test("returns nonce-required detail code when nonce is blank", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token, - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - nonce, - }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-blank-nonce", - token, - device: { ...device, nonce: " " }, - }); - expect(connectRes.ok).toBe(false); - expect(connectRes.error?.message ?? "").toContain("device nonce required"); - expect(connectRes.error?.details?.code).toBe( - ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED, - ); - expect(connectRes.error?.details?.reason).toBe("device-nonce-missing"); - await new Promise((resolve) => ws.once("close", () => resolve())); - }); - - test("returns nonce-mismatch detail code when nonce does not match challenge", async () => { - const ws = await openWs(port); - const token = resolveGatewayTokenOrEnv(); - const nonce = await readConnectChallengeNonce(ws); - const { device } = await createSignedDevice({ - token, - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - nonce, - }); - - const connectRes = await sendRawConnectReq(ws, { - id: "c-wrong-nonce", - token, - device: { ...device, nonce: `${nonce}-stale` }, - }); - expect(connectRes.ok).toBe(false); - expect(connectRes.error?.message ?? "").toContain("device nonce mismatch"); - expect(connectRes.error?.details?.code).toBe( - ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH, - ); - expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch"); - await new Promise((resolve) => ws.once("close", () => resolve())); - }); - - test("invalid connect params surface in response and close reason", async () => { - const ws = await openWs(port); - const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { - ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); - }); - - ws.send( - JSON.stringify({ - type: "req", - id: "h-bad", - method: "connect", - params: { - minProtocol: PROTOCOL_VERSION, - maxProtocol: PROTOCOL_VERSION, - client: { - id: "bad-client", - version: "dev", - platform: "web", - mode: "webchat", - }, - device: { - id: 123, - publicKey: "bad", - signature: "bad", - signedAt: "bad", - }, - }, - }), - ); - - const res = await onceMessage<{ - ok: boolean; - error?: { message?: string }; - }>( - ws, - (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", - ); - expect(res.ok).toBe(false); - expect(String(res.error?.message ?? "")).toContain("invalid connect params"); - - const closeInfo = await closeInfoPromise; - expect(closeInfo.code).toBe(1008); - expect(closeInfo.reason).toContain("invalid connect params"); - }); - }); - - describe("password auth", () => { - let server: Awaited>; - let port: number; - - beforeAll(async () => { - testState.gatewayAuth = { mode: "password", password: "secret" }; - port = await getFreePort(); - server = await startGatewayServer(port); - }); - - afterAll(async () => { - await server.close(); - }); - - test("accepts password auth when configured", async () => { - const ws = await openWs(port); - const res = await connectReq(ws, { password: "secret" }); - expect(res.ok).toBe(true); - ws.close(); - }); - - test("rejects invalid password", async () => { - const ws = await openWs(port); - const res = await connectReq(ws, { password: "wrong" }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("unauthorized"); - ws.close(); - }); - }); - - describe("token auth", () => { - let server: Awaited>; - let port: number; - let prevToken: string | undefined; - - beforeAll(async () => { - prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; - port = await getFreePort(); - server = await startGatewayServer(port); - }); - - afterAll(async () => { - await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - }); - - test("rejects invalid token", async () => { - const ws = await openWs(port); - const res = await connectReq(ws, { token: "wrong" }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("unauthorized"); - ws.close(); - }); - - test("returns control ui hint when token is missing", async () => { - const ws = await openWs(port, { origin: originForPort(port) }); - const res = await connectReq(ws, { - skipDefaultAuth: true, - client: { - ...CONTROL_UI_CLIENT, - }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("Control UI settings"); - ws.close(); - }); - - test("rejects control ui without device identity by default", async () => { - const ws = await openWs(port, { origin: originForPort(port) }); - const res = await connectReq(ws, { - token: "secret", - device: null, - client: { - ...CONTROL_UI_CLIENT, - }, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("secure context"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, - ); - ws.close(); - }); - }); - - describe("explicit none auth", () => { - let server: Awaited>; - let port: number; - let prevToken: string | undefined; - - beforeAll(async () => { - prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; - delete process.env.OPENCLAW_GATEWAY_TOKEN; - testState.gatewayAuth = { mode: "none" }; - port = await getFreePort(); - server = await startGatewayServer(port); - }); - - afterAll(async () => { - await server.close(); - if (prevToken === undefined) { - delete process.env.OPENCLAW_GATEWAY_TOKEN; - } else { - process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; - } - }); - - test("allows loopback connect without shared secret when mode is none", async () => { - const ws = await openWs(port); - const res = await connectReq(ws, { skipDefaultAuth: true }); - expect(res.ok).toBe(true); - ws.close(); - }); - }); - - 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); - const status = await rpcReq(ws, "status"); - expect(status.ok).toBe(true); - const health = await rpcReq(ws, "health"); - expect(health.ok).toBe(true); - ws.close(); - }); - }); + writeTrustedProxyControlUiConfig, +} from "./server.auth.shared.js"; +export function registerControlUiAndPairingSuite(): void { const trustedProxyControlUiCases: Array<{ name: string; role: "operator" | "node"; @@ -1031,16 +166,7 @@ describe("gateway server auth/connect", () => { allowedOrigins: ["https://localhost"], }; testState.gatewayAuth = { mode: "token", token: "secret" }; - const { writeConfigFile } = await import("../config/config.js"); - await writeConfigFile({ - gateway: { - trustedProxies: ["127.0.0.1"], - controlUi: { - allowedOrigins: ["https://localhost"], - }, - }, - // oxlint-disable-next-line typescript/no-explicit-any - } as any); + await writeTrustedProxyControlUiConfig({ allowInsecureAuth: true }); const prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; try { @@ -1839,6 +965,4 @@ describe("gateway server auth/connect", () => { restoreGatewayToken(prevToken); } }); - - // Remaining tests require isolated gateway state. -}); +} diff --git a/src/gateway/server.auth.control-ui.test.ts b/src/gateway/server.auth.control-ui.test.ts new file mode 100644 index 00000000000..eae87394dac --- /dev/null +++ b/src/gateway/server.auth.control-ui.test.ts @@ -0,0 +1,9 @@ +import { describe } from "vitest"; +import { registerControlUiAndPairingSuite } from "./server.auth.control-ui.suite.js"; +import { installGatewayTestHooks } from "./server.auth.shared.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway server auth/connect", () => { + registerControlUiAndPairingSuite(); +}); diff --git a/src/gateway/server.auth.default-token.suite.ts b/src/gateway/server.auth.default-token.suite.ts new file mode 100644 index 00000000000..0f779a3cacb --- /dev/null +++ b/src/gateway/server.auth.default-token.suite.ts @@ -0,0 +1,415 @@ +import { afterAll, beforeAll, describe, expect, test, vi } from "vitest"; +import { WebSocket } from "ws"; +import { + connectReq, + ConnectErrorDetailCodes, + createSignedDevice, + expectHelloOkServerVersion, + getFreePort, + getHandshakeTimeoutMs, + GATEWAY_CLIENT_MODES, + GATEWAY_CLIENT_NAMES, + NODE_CLIENT, + onceMessage, + openWs, + PROTOCOL_VERSION, + readConnectChallengeNonce, + resolveGatewayTokenOrEnv, + rpcReq, + sendRawConnectReq, + startGatewayServer, + TEST_OPERATOR_CLIENT, + waitForWsClose, + withRuntimeVersionEnv, +} from "./server.auth.shared.js"; + +export function registerDefaultAuthTokenSuite(): void { + describe("default auth (token)", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + }); + + test("closes silent handshakes after timeout", async () => { + vi.useRealTimers(); + const prevHandshakeTimeout = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "20"; + try { + const ws = await openWs(port); + const handshakeTimeoutMs = getHandshakeTimeoutMs(); + const closed = await waitForWsClose(ws, handshakeTimeoutMs + 500); + expect(closed).toBe(true); + } finally { + if (prevHandshakeTimeout === undefined) { + delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS; + } else { + process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = prevHandshakeTimeout; + } + } + }); + + test("connect (req) handshake returns hello-ok payload", async () => { + const { CONFIG_PATH, STATE_DIR } = await import("../config/config.js"); + const ws = await openWs(port); + + const res = await connectReq(ws); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + type?: unknown; + snapshot?: { configPath?: string; stateDir?: string }; + } + | undefined; + expect(payload?.type).toBe("hello-ok"); + expect(payload?.snapshot?.configPath).toBe(CONFIG_PATH); + expect(payload?.snapshot?.stateDir).toBe(STATE_DIR); + + ws.close(); + }); + + test("connect (req) handshake resolves server version from env precedence", async () => { + for (const testCase of [ + { + env: { + OPENCLAW_VERSION: " ", + OPENCLAW_SERVICE_VERSION: "2.4.6-service", + npm_package_version: "1.0.0-package", + }, + expectedVersion: "2.4.6-service", + }, + { + env: { + OPENCLAW_VERSION: "9.9.9-cli", + OPENCLAW_SERVICE_VERSION: "2.4.6-service", + npm_package_version: "1.0.0-package", + }, + expectedVersion: "9.9.9-cli", + }, + { + env: { + OPENCLAW_VERSION: " ", + OPENCLAW_SERVICE_VERSION: "\t", + npm_package_version: "1.0.0-package", + }, + expectedVersion: "1.0.0-package", + }, + ]) { + await withRuntimeVersionEnv(testCase.env, async () => + expectHelloOkServerVersion(port, testCase.expectedVersion), + ); + } + }); + + test("device-less auth matrix", async () => { + const token = resolveGatewayTokenOrEnv(); + const matrix: Array<{ + name: string; + opts: Parameters[1]; + expectConnectOk: boolean; + expectConnectError?: string; + expectStatusOk?: boolean; + expectStatusError?: string; + }> = [ + { + name: "operator + valid shared token => connected with preserved scopes", + opts: { role: "operator", token, device: null }, + expectConnectOk: true, + expectStatusOk: true, + }, + { + name: "node + valid shared token => rejected without device", + opts: { role: "node", token, device: null, client: NODE_CLIENT }, + expectConnectOk: false, + expectConnectError: "device identity required", + }, + { + name: "operator + invalid shared token => unauthorized", + opts: { role: "operator", token: "wrong", device: null }, + expectConnectOk: false, + expectConnectError: "unauthorized", + }, + ]; + + for (const scenario of matrix) { + const ws = await openWs(port); + try { + const res = await connectReq(ws, scenario.opts); + expect(res.ok, scenario.name).toBe(scenario.expectConnectOk); + if (!scenario.expectConnectOk) { + expect(res.error?.message ?? "", scenario.name).toContain( + String(scenario.expectConnectError ?? ""), + ); + continue; + } + if (scenario.expectStatusOk !== undefined) { + const status = await rpcReq(ws, "status"); + expect(status.ok, scenario.name).toBe(scenario.expectStatusOk); + if (!scenario.expectStatusOk && scenario.expectStatusError) { + expect(status.error?.message ?? "", scenario.name).toContain( + scenario.expectStatusError, + ); + } + } + } finally { + ws.close(); + } + } + }); + + test("keeps health available but admin status restricted when scopes are empty", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { scopes: [] }); + expect(res.ok).toBe(true); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message).toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + } finally { + ws.close(); + } + }); + + test("does not grant admin when scopes are omitted", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + + const { randomUUID } = await import("node:crypto"); + const os = await import("node:os"); + const path = await import("node:path"); + // Fresh identity: avoid leaking prior scopes (presence merges lists). + const { identity, device } = await createSignedDevice({ + token, + scopes: [], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + identityPath: path.join(os.tmpdir(), `openclaw-test-device-${randomUUID()}.json`), + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-no-scopes", + token, + device, + }); + expect(connectRes.ok).toBe(true); + const helloOk = connectRes.payload as + | { + snapshot?: { + presence?: Array<{ deviceId?: unknown; scopes?: unknown }>; + }; + } + | undefined; + const presence = helloOk?.snapshot?.presence; + expect(Array.isArray(presence)).toBe(true); + const mine = presence?.find((entry) => entry.deviceId === identity.deviceId); + expect(mine).toBeTruthy(); + const presenceScopes = Array.isArray(mine?.scopes) ? mine?.scopes : []; + expect(presenceScopes).toEqual([]); + expect(presenceScopes).not.toContain("operator.admin"); + + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(false); + expect(status.error?.message).toContain("missing scope"); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + + ws.close(); + }); + + test("rejects device signature when scopes are omitted but signed with admin", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: GATEWAY_CLIENT_NAMES.TEST, + clientMode: GATEWAY_CLIENT_MODES.TEST, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-no-scopes-signed-admin", + token, + device, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device signature invalid"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_SIGNATURE_INVALID, + ); + expect(connectRes.error?.details?.reason).toBe("device-signature"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("sends connect challenge on open", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`); + const evtPromise = onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(ws, (o) => o.type === "event" && o.event === "connect.challenge"); + await new Promise((resolve) => ws.once("open", resolve)); + const evt = await evtPromise; + const nonce = (evt.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + ws.close(); + }); + + test("rejects protocol mismatch", async () => { + const ws = await openWs(port); + try { + const res = await connectReq(ws, { + minProtocol: PROTOCOL_VERSION + 1, + maxProtocol: PROTOCOL_VERSION + 2, + }); + expect(res.ok).toBe(false); + } catch { + // If the server closed before we saw the frame, that's acceptable. + } + ws.close(); + }); + + test("rejects non-connect first request", async () => { + const ws = await openWs(port); + ws.send(JSON.stringify({ type: "req", id: "h1", method: "health" })); + const res = await onceMessage<{ type?: string; id?: string; ok?: boolean; error?: unknown }>( + ws, + (o) => o.type === "res" && o.id === "h1", + ); + expect(res.ok).toBe(false); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("requires nonce for device auth", async () => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { host: "example.com" }, + }); + await new Promise((resolve) => ws.once("open", resolve)); + + const { device } = await createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce: "nonce-not-sent", + }); + const { nonce: _nonce, ...deviceWithoutNonce } = device; + const res = await connectReq(ws, { + token: "secret", + device: deviceWithoutNonce, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("must have required property 'nonce'"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("returns nonce-required detail code when nonce is blank", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-blank-nonce", + token, + device: { ...device, nonce: " " }, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device nonce required"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_REQUIRED, + ); + expect(connectRes.error?.details?.reason).toBe("device-nonce-missing"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("returns nonce-mismatch detail code when nonce does not match challenge", async () => { + const ws = await openWs(port); + const token = resolveGatewayTokenOrEnv(); + const nonce = await readConnectChallengeNonce(ws); + const { device } = await createSignedDevice({ + token, + scopes: ["operator.admin"], + clientId: TEST_OPERATOR_CLIENT.id, + clientMode: TEST_OPERATOR_CLIENT.mode, + nonce, + }); + + const connectRes = await sendRawConnectReq(ws, { + id: "c-wrong-nonce", + token, + device: { ...device, nonce: `${nonce}-stale` }, + }); + expect(connectRes.ok).toBe(false); + expect(connectRes.error?.message ?? "").toContain("device nonce mismatch"); + expect(connectRes.error?.details?.code).toBe( + ConnectErrorDetailCodes.DEVICE_AUTH_NONCE_MISMATCH, + ); + expect(connectRes.error?.details?.reason).toBe("device-nonce-mismatch"); + await new Promise((resolve) => ws.once("close", () => resolve())); + }); + + test("invalid connect params surface in response and close reason", async () => { + const ws = await openWs(port); + const closeInfoPromise = new Promise<{ code: number; reason: string }>((resolve) => { + ws.once("close", (code, reason) => resolve({ code, reason: reason.toString() })); + }); + + ws.send( + JSON.stringify({ + type: "req", + id: "h-bad", + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: { + id: "bad-client", + version: "dev", + platform: "web", + mode: "webchat", + }, + device: { + id: 123, + publicKey: "bad", + signature: "bad", + signedAt: "bad", + }, + }, + }), + ); + + const res = await onceMessage<{ + ok: boolean; + error?: { message?: string }; + }>( + ws, + (o) => (o as { type?: string }).type === "res" && (o as { id?: string }).id === "h-bad", + ); + expect(res.ok).toBe(false); + expect(String(res.error?.message ?? "")).toContain("invalid connect params"); + + const closeInfo = await closeInfoPromise; + expect(closeInfo.code).toBe(1008); + expect(closeInfo.reason).toContain("invalid connect params"); + }); + }); +} diff --git a/src/gateway/server.auth.default-token.test.ts b/src/gateway/server.auth.default-token.test.ts new file mode 100644 index 00000000000..e22cc79502c --- /dev/null +++ b/src/gateway/server.auth.default-token.test.ts @@ -0,0 +1,9 @@ +import { describe } from "vitest"; +import { registerDefaultAuthTokenSuite } from "./server.auth.default-token.suite.js"; +import { installGatewayTestHooks } from "./server.auth.shared.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway server auth/connect", () => { + registerDefaultAuthTokenSuite(); +}); diff --git a/src/gateway/server.auth.modes.suite.ts b/src/gateway/server.auth.modes.suite.ts new file mode 100644 index 00000000000..a37c992da21 --- /dev/null +++ b/src/gateway/server.auth.modes.suite.ts @@ -0,0 +1,178 @@ +import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, test } from "vitest"; +import { + connectReq, + CONTROL_UI_CLIENT, + ConnectErrorDetailCodes, + getFreePort, + openTailscaleWs, + openWs, + originForPort, + rpcReq, + startGatewayServer, + testState, + testTailscaleWhois, +} from "./server.auth.shared.js"; + +export function registerAuthModesSuite(): void { + describe("password auth", () => { + let server: Awaited>; + let port: number; + + beforeAll(async () => { + testState.gatewayAuth = { mode: "password", password: "secret" }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + }); + + test("accepts password auth when configured", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { password: "secret" }); + expect(res.ok).toBe(true); + ws.close(); + }); + + test("rejects invalid password", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { password: "wrong" }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("unauthorized"); + ws.close(); + }); + }); + + describe("token auth", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + process.env.OPENCLAW_GATEWAY_TOKEN = "secret"; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + }); + + test("rejects invalid token", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { token: "wrong" }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("unauthorized"); + ws.close(); + }); + + test("returns control ui hint when token is missing", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + const res = await connectReq(ws, { + skipDefaultAuth: true, + client: { + ...CONTROL_UI_CLIENT, + }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("Control UI settings"); + ws.close(); + }); + + test("rejects control ui without device identity by default", async () => { + const ws = await openWs(port, { origin: originForPort(port) }); + const res = await connectReq(ws, { + token: "secret", + device: null, + client: { + ...CONTROL_UI_CLIENT, + }, + }); + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("secure context"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_DEVICE_IDENTITY_REQUIRED, + ); + ws.close(); + }); + }); + + describe("explicit none auth", () => { + let server: Awaited>; + let port: number; + let prevToken: string | undefined; + + beforeAll(async () => { + prevToken = process.env.OPENCLAW_GATEWAY_TOKEN; + delete process.env.OPENCLAW_GATEWAY_TOKEN; + testState.gatewayAuth = { mode: "none" }; + port = await getFreePort(); + server = await startGatewayServer(port); + }); + + afterAll(async () => { + await server.close(); + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } + }); + + test("allows loopback connect without shared secret when mode is none", async () => { + const ws = await openWs(port); + const res = await connectReq(ws, { skipDefaultAuth: true }); + expect(res.ok).toBe(true); + ws.close(); + }); + }); + + 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); + const status = await rpcReq(ws, "status"); + expect(status.ok).toBe(true); + const health = await rpcReq(ws, "health"); + expect(health.ok).toBe(true); + ws.close(); + }); + }); +} diff --git a/src/gateway/server.auth.modes.test.ts b/src/gateway/server.auth.modes.test.ts new file mode 100644 index 00000000000..0b8ca52414d --- /dev/null +++ b/src/gateway/server.auth.modes.test.ts @@ -0,0 +1,9 @@ +import { describe } from "vitest"; +import { registerAuthModesSuite } from "./server.auth.modes.suite.js"; +import { installGatewayTestHooks } from "./server.auth.shared.js"; + +installGatewayTestHooks({ scope: "suite" }); + +describe("gateway server auth/connect", () => { + registerAuthModesSuite(); +}); diff --git a/src/gateway/server.auth.shared.ts b/src/gateway/server.auth.shared.ts new file mode 100644 index 00000000000..7142855a49a --- /dev/null +++ b/src/gateway/server.auth.shared.ts @@ -0,0 +1,384 @@ +import os from "node:os"; +import path from "node:path"; +import { expect } from "vitest"; +import { WebSocket } from "ws"; +import { withEnvAsync } from "../test-utils/env.js"; +import { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; +import { buildDeviceAuthPayload } from "./device-auth.js"; +import { PROTOCOL_VERSION } from "./protocol/index.js"; +import { + connectReq, + getTrackedConnectChallengeNonce, + getFreePort, + installGatewayTestHooks, + onceMessage, + rpcReq, + startGatewayServer, + startServerWithClient, + trackConnectChallengeNonce, + testTailscaleWhois, + testState, + withGatewayServer, +} from "./test-helpers.js"; + +async function waitForWsClose(ws: WebSocket, timeoutMs: number): Promise { + if (ws.readyState === WebSocket.CLOSED) { + return true; + } + return await new Promise((resolve) => { + const timer = setTimeout(() => resolve(ws.readyState === WebSocket.CLOSED), timeoutMs); + ws.once("close", () => { + clearTimeout(timer); + resolve(true); + }); + }); +} + +const openWs = async (port: number, headers?: Record) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); + trackConnectChallengeNonce(ws); + await new Promise((resolve) => ws.once("open", resolve)); + return ws; +}; + +const readConnectChallengeNonce = async (ws: WebSocket) => { + const cached = getTrackedConnectChallengeNonce(ws); + if (cached) { + return cached; + } + const challenge = await onceMessage<{ + type?: string; + event?: string; + payload?: Record | null; + }>(ws, (o) => o.type === "event" && o.event === "connect.challenge"); + const nonce = (challenge.payload as { nonce?: unknown } | undefined)?.nonce; + expect(typeof nonce).toBe("string"); + return String(nonce); +}; + +const openTailscaleWs = async (port: number) => { + const ws = new WebSocket(`ws://127.0.0.1:${port}`, { + headers: { + origin: "https://gateway.tailnet.ts.net", + "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", + }, + }); + trackConnectChallengeNonce(ws); + await new Promise((resolve) => ws.once("open", resolve)); + return ws; +}; + +const originForPort = (port: number) => `http://127.0.0.1:${port}`; + +function restoreGatewayToken(prevToken: string | undefined) { + if (prevToken === undefined) { + delete process.env.OPENCLAW_GATEWAY_TOKEN; + } else { + process.env.OPENCLAW_GATEWAY_TOKEN = prevToken; + } +} + +async function withRuntimeVersionEnv( + env: Record, + run: () => Promise, +): Promise { + return withEnvAsync(env, run); +} + +const TEST_OPERATOR_CLIENT = { + id: GATEWAY_CLIENT_NAMES.TEST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.TEST, +}; + +const CONTROL_UI_CLIENT = { + id: GATEWAY_CLIENT_NAMES.CONTROL_UI, + version: "1.0.0", + platform: "web", + mode: GATEWAY_CLIENT_MODES.WEBCHAT, +}; + +const TRUSTED_PROXY_CONTROL_UI_HEADERS = { + origin: "https://localhost", + "x-forwarded-for": "203.0.113.10", + "x-forwarded-proto": "https", + "x-forwarded-user": "peter@example.com", +} as const; + +const NODE_CLIENT = { + id: GATEWAY_CLIENT_NAMES.NODE_HOST, + version: "1.0.0", + platform: "test", + mode: GATEWAY_CLIENT_MODES.NODE, +}; + +const BACKEND_GATEWAY_CLIENT = { + id: GATEWAY_CLIENT_NAMES.GATEWAY_CLIENT, + version: "1.0.0", + platform: "node", + mode: GATEWAY_CLIENT_MODES.BACKEND, +}; + +async function expectHelloOkServerVersion(port: number, expectedVersion: string) { + const ws = await openWs(port); + try { + const res = await connectReq(ws); + expect(res.ok).toBe(true); + const payload = res.payload as + | { + type?: unknown; + server?: { version?: string }; + } + | undefined; + expect(payload?.type).toBe("hello-ok"); + expect(payload?.server?.version).toBe(expectedVersion); + } finally { + ws.close(); + } +} + +async function createSignedDevice(params: { + token?: string | null; + scopes: string[]; + clientId: string; + clientMode: string; + role?: "operator" | "node"; + identityPath?: string; + nonce: string; + signedAtMs?: number; +}) { + const { loadOrCreateDeviceIdentity, publicKeyRawBase64UrlFromPem, signDevicePayload } = + await import("../infra/device-identity.js"); + const identity = params.identityPath + ? loadOrCreateDeviceIdentity(params.identityPath) + : loadOrCreateDeviceIdentity(); + const signedAtMs = params.signedAtMs ?? Date.now(); + const payload = buildDeviceAuthPayload({ + deviceId: identity.deviceId, + clientId: params.clientId, + clientMode: params.clientMode, + role: params.role ?? "operator", + scopes: params.scopes, + signedAtMs, + token: params.token ?? null, + nonce: params.nonce, + }); + return { + identity, + signedAtMs, + device: { + id: identity.deviceId, + publicKey: publicKeyRawBase64UrlFromPem(identity.publicKeyPem), + signature: signDevicePayload(identity.privateKeyPem, payload), + signedAt: signedAtMs, + nonce: params.nonce, + }, + }; +} + +function resolveGatewayTokenOrEnv(): string { + const token = + typeof (testState.gatewayAuth as { token?: unknown } | undefined)?.token === "string" + ? ((testState.gatewayAuth as { token?: string }).token ?? undefined) + : process.env.OPENCLAW_GATEWAY_TOKEN; + expect(typeof token).toBe("string"); + return String(token ?? ""); +} + +async function approvePendingPairingIfNeeded() { + const { approveDevicePairing, listDevicePairing } = await import("../infra/device-pairing.js"); + const list = await listDevicePairing(); + const pending = list.pending.at(0); + expect(pending?.requestId).toBeDefined(); + if (pending?.requestId) { + await approveDevicePairing(pending.requestId); + } +} + +async function configureTrustedProxyControlUiAuth() { + testState.gatewayAuth = { + mode: "trusted-proxy", + trustedProxy: { + userHeader: "x-forwarded-user", + requiredHeaders: ["x-forwarded-proto"], + }, + }; + await writeTrustedProxyControlUiConfig(); +} + +async function writeTrustedProxyControlUiConfig(params?: { allowInsecureAuth?: boolean }) { + const { writeConfigFile } = await import("../config/config.js"); + await writeConfigFile({ + gateway: { + trustedProxies: ["127.0.0.1"], + controlUi: { + allowedOrigins: ["https://localhost"], + ...(params?.allowInsecureAuth ? { allowInsecureAuth: true } : {}), + }, + }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any); +} + +function isConnectResMessage(id: string) { + return (o: unknown) => { + if (!o || typeof o !== "object" || Array.isArray(o)) { + return false; + } + const rec = o as Record; + return rec.type === "res" && rec.id === id; + }; +} + +async function sendRawConnectReq( + ws: WebSocket, + params: { + id: string; + token?: string; + device: { id: string; publicKey: string; signature: string; signedAt: number; nonce?: string }; + }, +) { + ws.send( + JSON.stringify({ + type: "req", + id: params.id, + method: "connect", + params: { + minProtocol: PROTOCOL_VERSION, + maxProtocol: PROTOCOL_VERSION, + client: TEST_OPERATOR_CLIENT, + caps: [], + role: "operator", + auth: params.token ? { token: params.token } : undefined, + device: params.device, + }, + }), + ); + return onceMessage<{ + type?: string; + id?: string; + ok?: boolean; + payload?: Record | null; + error?: { + message?: string; + details?: { + code?: string; + reason?: string; + }; + }; + }>(ws, isConnectResMessage(params.id)); +} + +async function startRateLimitedTokenServerWithPairedDeviceToken() { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); + + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: false }, + // oxlint-disable-next-line typescript/no-explicit-any + } as any; + + const { server, ws, port, prevToken } = await startServerWithClient(); + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-rate-limit-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + try { + const initial = await connectReq(ws, { token: "secret", deviceIdentityPath }); + if (!initial.ok) { + await approvePendingPairingIfNeeded(); + } + + const identity = loadOrCreateDeviceIdentity(deviceIdentityPath); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(paired?.deviceId).toBe(identity.deviceId); + expect(deviceToken).toBeDefined(); + + ws.close(); + return { server, port, prevToken, deviceToken: String(deviceToken ?? ""), deviceIdentityPath }; + } catch (err) { + ws.close(); + await server.close(); + restoreGatewayToken(prevToken); + throw err; + } +} + +async function ensurePairedDeviceTokenForCurrentIdentity(ws: WebSocket): Promise<{ + identity: { deviceId: string }; + deviceToken: string; + deviceIdentityPath: string; +}> { + const { loadOrCreateDeviceIdentity } = await import("../infra/device-identity.js"); + const { getPairedDevice } = await import("../infra/device-pairing.js"); + + const deviceIdentityPath = path.join( + os.tmpdir(), + `openclaw-auth-device-${Date.now()}-${Math.random().toString(36).slice(2)}.json`, + ); + + const res = await connectReq(ws, { token: "secret", deviceIdentityPath }); + if (!res.ok) { + await approvePendingPairingIfNeeded(); + } + + const identity = loadOrCreateDeviceIdentity(deviceIdentityPath); + const paired = await getPairedDevice(identity.deviceId); + const deviceToken = paired?.tokens?.operator?.token; + expect(paired?.deviceId).toBe(identity.deviceId); + expect(deviceToken).toBeDefined(); + return { + identity: { deviceId: identity.deviceId }, + deviceToken: String(deviceToken ?? ""), + deviceIdentityPath, + }; +} + +export { + approvePendingPairingIfNeeded, + BACKEND_GATEWAY_CLIENT, + buildDeviceAuthPayload, + configureTrustedProxyControlUiAuth, + connectReq, + CONTROL_UI_CLIENT, + createSignedDevice, + ensurePairedDeviceTokenForCurrentIdentity, + expectHelloOkServerVersion, + getFreePort, + getTrackedConnectChallengeNonce, + installGatewayTestHooks, + NODE_CLIENT, + onceMessage, + openTailscaleWs, + openWs, + originForPort, + readConnectChallengeNonce, + resolveGatewayTokenOrEnv, + restoreGatewayToken, + rpcReq, + sendRawConnectReq, + startGatewayServer, + startRateLimitedTokenServerWithPairedDeviceToken, + startServerWithClient, + TEST_OPERATOR_CLIENT, + trackConnectChallengeNonce, + TRUSTED_PROXY_CONTROL_UI_HEADERS, + testState, + testTailscaleWhois, + waitForWsClose, + withGatewayServer, + withRuntimeVersionEnv, + writeTrustedProxyControlUiConfig, +}; +export { ConnectErrorDetailCodes } from "./protocol/connect-error-details.js"; +export { getHandshakeTimeoutMs } from "./server-constants.js"; +export { PROTOCOL_VERSION } from "./protocol/index.js"; +export { GATEWAY_CLIENT_MODES, GATEWAY_CLIENT_NAMES } from "../utils/message-channel.js"; diff --git a/src/infra/archive.ts b/src/infra/archive.ts index 12a6f1c92d4..3407d66c9a4 100644 --- a/src/infra/archive.ts +++ b/src/infra/archive.ts @@ -1,4 +1,3 @@ -import { constants as fsConstants } from "node:fs"; import type { FileHandle } from "node:fs/promises"; import fs from "node:fs/promises"; import path from "node:path"; @@ -11,9 +10,8 @@ import { stripArchivePath, validateArchiveEntryPath, } from "./archive-path.js"; -import { sameFileIdentity } from "./file-identity.js"; -import { resolveOpenedFileRealPathForHandle } from "./fs-safe.js"; -import { isNotFoundPathError, isPathInside, isSymlinkOpenError } from "./path-guards.js"; +import { openWritableFileWithinRoot, SafeOpenError } from "./fs-safe.js"; +import { isNotFoundPathError, isPathInside } from "./path-guards.js"; export type ArchiveKind = "tar" | "zip"; @@ -67,14 +65,6 @@ const ERROR_ARCHIVE_EXTRACTED_SIZE_EXCEEDS_LIMIT = "archive extracted size excee const ERROR_ARCHIVE_ENTRY_TRAVERSES_SYMLINK = "archive entry traverses symlink in destination"; const TAR_SUFFIXES = [".tgz", ".tar.gz", ".tar"]; -const SUPPORTS_NOFOLLOW = process.platform !== "win32" && "O_NOFOLLOW" in fsConstants; -const OPEN_WRITE_EXISTING_FLAGS = - fsConstants.O_WRONLY | (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); -const OPEN_WRITE_CREATE_FLAGS = - fsConstants.O_WRONLY | - fsConstants.O_CREAT | - fsConstants.O_EXCL | - (SUPPORTS_NOFOLLOW ? fsConstants.O_NOFOLLOW : 0); export function resolveArchiveKind(filePath: string): ArchiveKind | null { const lower = filePath.toLowerCase(); @@ -288,93 +278,28 @@ type OpenZipOutputFileResult = { }; async function openZipOutputFile(params: { - outPath: string; + relPath: string; originalPath: string; destinationRealDir: string; }): Promise { - let ioPath = params.outPath; try { - const resolvedRealPath = await fs.realpath(params.outPath); - if (!isPathInside(params.destinationRealDir, resolvedRealPath)) { - throw symlinkTraversalError(params.originalPath); - } - ioPath = resolvedRealPath; + return await openWritableFileWithinRoot({ + rootDir: params.destinationRealDir, + relativePath: params.relPath, + mkdir: false, + mode: 0o666, + }); } catch (err) { - if (err instanceof ArchiveSecurityError) { - throw err; - } - if (!isNotFoundPathError(err)) { - throw err; - } - } - - let handle: FileHandle; - let createdForWrite = false; - try { - try { - handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o666); - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o666); - createdForWrite = true; - } - } catch (err) { - if (isSymlinkOpenError(err)) { + if ( + err instanceof SafeOpenError && + (err.code === "invalid-path" || + err.code === "outside-workspace" || + err.code === "path-mismatch") + ) { throw symlinkTraversalError(params.originalPath); } throw err; } - - let openedRealPath: string | null = null; - try { - const stat = await handle.stat(); - if (!stat.isFile()) { - throw symlinkTraversalError(params.originalPath); - } - - try { - const lstat = await fs.lstat(ioPath); - if (lstat.isSymbolicLink() || !lstat.isFile()) { - throw symlinkTraversalError(params.originalPath); - } - if (!sameFileIdentity(stat, lstat)) { - throw symlinkTraversalError(params.originalPath); - } - } catch (err) { - if (!isNotFoundPathError(err)) { - throw err; - } - } - - const realPath = await resolveOpenedFileRealPathForHandle(handle, ioPath); - openedRealPath = realPath; - const realStat = await fs.stat(realPath); - if (!sameFileIdentity(stat, realStat)) { - throw symlinkTraversalError(params.originalPath); - } - if (!isPathInside(params.destinationRealDir, realPath)) { - throw symlinkTraversalError(params.originalPath); - } - - // Truncate only after identity + boundary checks complete. - if (!createdForWrite) { - await handle.truncate(0); - } - - return { - handle, - createdForWrite, - openedRealPath: realPath, - }; - } catch (err) { - if (createdForWrite && openedRealPath) { - await fs.rm(openedRealPath, { force: true }).catch(() => undefined); - } - await handle.close().catch(() => undefined); - throw err; - } } async function cleanupPartialRegularFile(filePath: string): Promise { @@ -467,12 +392,12 @@ async function prepareZipOutputPath(params: { async function writeZipFileEntry(params: { entry: ZipEntry; - outPath: string; + relPath: string; destinationRealDir: string; budget: ZipExtractBudget; }): Promise { const opened = await openZipOutputFile({ - outPath: params.outPath, + relPath: params.relPath, originalPath: params.entry.name, destinationRealDir: params.destinationRealDir, }); @@ -558,7 +483,7 @@ async function extractZip(params: { await writeZipFileEntry({ entry, - outPath: output.outPath, + relPath: output.relPath, destinationRealDir, budget, }); diff --git a/src/infra/fs-safe.ts b/src/infra/fs-safe.ts index ec1bbf131f5..e653229cc1a 100644 --- a/src/infra/fs-safe.ts +++ b/src/infra/fs-safe.ts @@ -321,6 +321,7 @@ export async function openWritableFileWithinRoot(params: { rootDir: string; relativePath: string; mkdir?: boolean; + mode?: number; }): Promise { const { rootReal, rootWithSep, resolved } = await resolvePathWithinRoot(params); try { @@ -352,16 +353,18 @@ export async function openWritableFileWithinRoot(params: { } } + const fileMode = params.mode ?? 0o600; + let handle: FileHandle; let createdForWrite = false; try { try { - handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, 0o600); + handle = await fs.open(ioPath, OPEN_WRITE_EXISTING_FLAGS, fileMode); } catch (err) { if (!isNotFoundPathError(err)) { throw err; } - handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, 0o600); + handle = await fs.open(ioPath, OPEN_WRITE_CREATE_FLAGS, fileMode); createdForWrite = true; } } catch (err) {