From fe97c6000c5c6565d492df7b0178355338feef7e Mon Sep 17 00:00:00 2001 From: Vincent Koc Date: Mon, 1 Jun 2026 22:07:03 +0200 Subject: [PATCH] refactor: share browser auth test helpers --- .../server.auth.browser-hardening.test.ts | 340 +++++++++--------- 1 file changed, 173 insertions(+), 167 deletions(-) diff --git a/src/gateway/server.auth.browser-hardening.test.ts b/src/gateway/server.auth.browser-hardening.test.ts index b80892814a8..b93e35a11e7 100644 --- a/src/gateway/server.auth.browser-hardening.test.ts +++ b/src/gateway/server.auth.browser-hardening.test.ts @@ -45,6 +45,15 @@ const TRUSTED_PROXY_BROWSER_HEADERS = { const originForPort = (port: number) => `http://127.0.0.1:${port}`; +type GatewayConnectResponse = Awaited>; +type GatewayTestClient = { + id: string; + version: string; + platform: string; + mode: string; +}; +type SignedBrowserDevice = Awaited>; + const openWs = async (port: number, headers?: Record) => { const ws = new WebSocket(`ws://127.0.0.1:${port}`, headers ? { headers } : undefined); trackConnectChallengeNonce(ws); @@ -123,6 +132,89 @@ async function withTrustedProxyBrowserWs(origin: string, run: (ws: WebSocket) => }); } +function expectOriginNotAllowed(res: GatewayConnectResponse) { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("origin not allowed"); + expect((res.error?.details as { code?: string } | undefined)?.code).toBe( + ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, + ); +} + +function expectRetryLater(res: GatewayConnectResponse, retryLater: boolean) { + expect(res.ok).toBe(false); + const expectation = expect(res.error?.message ?? ""); + if (retryLater) { + expectation.toContain("retry later"); + } else { + expectation.not.toContain("retry later"); + } +} + +async function expectWrongTokenRejected(params: { + port: number; + headers?: Record; + retryLater: boolean; + device?: null; +}) { + const ws = await openWs(params.port, params.headers); + try { + const request = params.device === null ? { token: "wrong", device: null } : { token: "wrong" }; + const res = await connectReq(ws, request); + expectRetryLater(res, params.retryLater); + } finally { + ws.close(); + } +} + +async function createSignedBrowserDevice( + browserWs: WebSocket, + client: GatewayTestClient, + identityName: string, +) { + const nonce = await readConnectChallengeNonce(browserWs); + expect(typeof nonce).toBe("string"); + return createSignedDevice({ + token: "secret", + scopes: ["operator.admin"], + clientId: client.id, + clientMode: client.mode, + identityPath: path.join(os.tmpdir(), `openclaw-${identityName}-device-${randomUUID()}.json`), + nonce: nonce ?? "", + }); +} + +function enableSingleAttemptLoopbackTokenAuth() { + testState.gatewayAuth = { + mode: "token", + token: "secret", + rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, + }; +} + +async function withSignedBrowserConnect( + port: number, + client: GatewayTestClient, + identityName: string, + run: (session: { + identity: SignedBrowserDevice["identity"]; + res: GatewayConnectResponse; + }) => void | Promise, +) { + const browserWs = await openWs(port, { origin: originForPort(port) }); + try { + const { identity, device } = await createSignedBrowserDevice(browserWs, client, identityName); + const res = await connectReq(browserWs, { + token: "secret", + scopes: ["operator.admin"], + client, + device, + }); + await run({ identity, res }); + } finally { + browserWs.close(); + } +} + async function expectBrowserOriginConnectRejected(params: { client?: { id: string; @@ -140,11 +232,7 @@ async function expectBrowserOriginConnectRejected(params: { client: params.client ?? TEST_OPERATOR_CLIENT, ...(params.client ? { device: null } : {}), }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("origin not allowed"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, - ); + expectOriginNotAllowed(res); } finally { ws.close(); } @@ -158,11 +246,7 @@ describe("gateway auth browser hardening", () => { client: TEST_OPERATOR_CLIENT, device: null, }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("origin not allowed"); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, - ); + expectOriginNotAllowed(res); }); }); @@ -196,49 +280,42 @@ describe("gateway auth browser hardening", () => { name: "rejects disallowed origins", origin: "https://evil.example", ok: false, - expectedMessage: "origin not allowed", }, { name: "accepts allowed origins", origin: ALLOWED_BROWSER_ORIGIN, ok: true, }, - ])( - "keeps non-proxy browser-origin behavior unchanged: $name", - async ({ origin, ok, expectedMessage }) => { - const { writeConfigFile } = await import("../config/config.js"); - testState.gatewayAuth = { mode: "token", token: "secret" }; - await writeConfigFile({ - gateway: { - controlUi: { - allowedOrigins: [ALLOWED_BROWSER_ORIGIN], - }, + ])("keeps non-proxy browser-origin behavior unchanged: $name", async ({ origin, ok }) => { + const { writeConfigFile } = await import("../config/config.js"); + testState.gatewayAuth = { mode: "token", token: "secret" }; + await writeConfigFile({ + gateway: { + controlUi: { + allowedOrigins: [ALLOWED_BROWSER_ORIGIN], }, - }); + }, + }); - await withGatewayServer(async ({ port }) => { - const ws = await openWs(port, { origin }); - try { - const res = await connectReq(ws, { - token: "secret", - client: TEST_OPERATOR_CLIENT, - device: null, - }); - expect(res.ok).toBe(ok); - if (ok) { - expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); - } else { - expect(res.error?.message ?? "").toContain(expectedMessage ?? ""); - expect((res.error?.details as { code?: string } | undefined)?.code).toBe( - ConnectErrorDetailCodes.CONTROL_UI_ORIGIN_NOT_ALLOWED, - ); - } - } finally { - ws.close(); + await withGatewayServer(async ({ port }) => { + const ws = await openWs(port, { origin }); + try { + const res = await connectReq(ws, { + token: "secret", + client: TEST_OPERATOR_CLIENT, + device: null, + }); + expect(res.ok).toBe(ok); + if (ok) { + expect((res.payload as { type?: string } | undefined)?.type).toBe("hello-ok"); + } else { + expectOriginNotAllowed(res); } - }); - }, - ); + } finally { + ws.close(); + } + }); + }); test("rejects non-local browser origins for non-control-ui clients", async () => { await expectBrowserOriginConnectRejected({}); @@ -256,29 +333,11 @@ describe("gateway auth browser hardening", () => { }); test("rate-limits browser-origin auth failures on loopback even when loopback exemption is enabled", async () => { - testState.gatewayAuth = { - mode: "token", - token: "secret", - rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, - }; + enableSingleAttemptLoopbackTokenAuth(); await withGatewayServer(async ({ port }) => { - const firstWs = await openWs(port, { origin: originForPort(port) }); - try { - const first = await connectReq(firstWs, { token: "wrong" }); - expect(first.ok).toBe(false); - expect(first.error?.message ?? "").not.toContain("retry later"); - } finally { - firstWs.close(); - } - - const secondWs = await openWs(port, { origin: originForPort(port) }); - try { - const second = await connectReq(secondWs, { token: "wrong" }); - expect(second.ok).toBe(false); - expect(second.error?.message ?? "").toContain("retry later"); - } finally { - secondWs.close(); - } + const loopbackOrigin = { origin: originForPort(port) }; + await expectWrongTokenRejected({ port, headers: loopbackOrigin, retryLater: false }); + await expectWrongTokenRejected({ port, headers: loopbackOrigin, retryLater: true }); }); }); @@ -294,63 +353,36 @@ describe("gateway auth browser hardening", () => { await withGatewayServer(async ({ port }) => { const remoteHeaders = { "x-forwarded-for": "203.0.113.50" }; for (let attempt = 1; attempt <= 10; attempt += 1) { - const ws = await openWs(port, remoteHeaders); - try { - const res = await connectReq(ws, { token: "wrong", device: null }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").not.toContain("retry later"); - } finally { - ws.close(); - } + await expectWrongTokenRejected({ + port, + headers: remoteHeaders, + retryLater: false, + device: null, + }); } - const lockedWs = await openWs(port, remoteHeaders); - try { - const locked = await connectReq(lockedWs, { token: "wrong", device: null }); - expect(locked.ok).toBe(false); - expect(locked.error?.message ?? "").toContain("retry later"); - } finally { - lockedWs.close(); - } + await expectWrongTokenRejected({ + port, + headers: remoteHeaders, + retryLater: true, + device: null, + }); }); }); test("isolates loopback browser-origin auth lockouts per origin", async () => { - testState.gatewayAuth = { - mode: "token", - token: "secret", - rateLimit: { maxAttempts: 1, windowMs: 60_000, lockoutMs: 60_000, exemptLoopback: true }, - }; + enableSingleAttemptLoopbackTokenAuth(); await withGatewayServer(async ({ port }) => { const firstOrigin = originForPort(port); const secondOrigin = "http://localhost:5173"; - const firstWs = await openWs(port, { origin: firstOrigin }); - try { - const first = await connectReq(firstWs, { token: "wrong" }); - expect(first.ok).toBe(false); - expect(first.error?.message ?? "").not.toContain("retry later"); - } finally { - firstWs.close(); - } - - const secondWs = await openWs(port, { origin: secondOrigin }); - try { - const second = await connectReq(secondWs, { token: "wrong" }); - expect(second.ok).toBe(false); - expect(second.error?.message ?? "").not.toContain("retry later"); - } finally { - secondWs.close(); - } - - const thirdWs = await openWs(port, { origin: firstOrigin }); - try { - const third = await connectReq(thirdWs, { token: "wrong" }); - expect(third.ok).toBe(false); - expect(third.error?.message ?? "").toContain("retry later"); - } finally { - thirdWs.close(); - } + await expectWrongTokenRejected({ port, headers: { origin: firstOrigin }, retryLater: false }); + await expectWrongTokenRejected({ + port, + headers: { origin: secondOrigin }, + retryLater: false, + }); + await expectWrongTokenRejected({ port, headers: { origin: firstOrigin }, retryLater: true }); }); }); @@ -393,36 +425,24 @@ describe("gateway auth browser hardening", () => { testState.gatewayAuth = { mode: "token", token: "secret" }; await withGatewayServer(async ({ port }) => { - const browserWs = await openWs(port, { origin: originForPort(port) }); - try { - const nonce = await readConnectChallengeNonce(browserWs); - expect(typeof nonce).toBe("string"); - const { identity, device } = await createSignedDevice({ - token: "secret", - scopes: ["operator.admin"], - clientId: TEST_OPERATOR_CLIENT.id, - clientMode: TEST_OPERATOR_CLIENT.mode, - identityPath: path.join(os.tmpdir(), `openclaw-browser-device-${randomUUID()}.json`), - nonce: nonce ?? "", - }); - const res = await connectReq(browserWs, { - token: "secret", - scopes: ["operator.admin"], - client: TEST_OPERATOR_CLIENT, - device, - }); - expect(res.ok).toBe(false); - expect(res.error?.message ?? "").toContain("pairing required"); + await withSignedBrowserConnect( + port, + TEST_OPERATOR_CLIENT, + "browser", + async ({ identity, res }) => { + expect(res.ok).toBe(false); + expect(res.error?.message ?? "").toContain("pairing required"); - const pairing = await listDevicePairing(); - const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); - if (!pending) { - throw new Error("expected non-control browser client to create pending pairing request"); - } - expect(pending.silent).toBe(false); - } finally { - browserWs.close(); - } + const pairing = await listDevicePairing(); + const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId); + if (!pending) { + throw new Error( + "expected non-control browser client to create pending pairing request", + ); + } + expect(pending.silent).toBe(false); + }, + ); }); }); @@ -431,32 +451,18 @@ describe("gateway auth browser hardening", () => { testState.gatewayAuth = { mode: "token", token: "secret" }; await withGatewayServer(async ({ port }) => { - const browserWs = await openWs(port, { origin: originForPort(port) }); - try { - const nonce = await readConnectChallengeNonce(browserWs); - expect(typeof nonce).toBe("string"); - const { identity, device } = await createSignedDevice({ - token: "secret", - scopes: ["operator.admin"], - clientId: CONTROL_UI_CLIENT.id, - clientMode: CONTROL_UI_CLIENT.mode, - identityPath: path.join(os.tmpdir(), `openclaw-control-ui-device-${randomUUID()}.json`), - nonce: nonce ?? "", - }); - const res = await connectReq(browserWs, { - token: "secret", - scopes: ["operator.admin"], - client: CONTROL_UI_CLIENT, - device, - }); - expect(res.ok).toBe(true); + await withSignedBrowserConnect( + port, + CONTROL_UI_CLIENT, + "control-ui", + async ({ identity, res }) => { + expect(res.ok).toBe(true); - const pairing = await listDevicePairing(); - expect(pairing.pending.some((entry) => entry.deviceId === identity.deviceId)).toBe(false); - expect(pairing.paired.some((entry) => entry.deviceId === identity.deviceId)).toBe(true); - } finally { - browserWs.close(); - } + const pairing = await listDevicePairing(); + expect(pairing.pending.some((entry) => entry.deviceId === identity.deviceId)).toBe(false); + expect(pairing.paired.some((entry) => entry.deviceId === identity.deviceId)).toBe(true); + }, + ); }); });