refactor: share browser auth test helpers

This commit is contained in:
Vincent Koc
2026-06-01 22:07:03 +02:00
parent a99cbf29bd
commit fe97c6000c

View File

@@ -45,6 +45,15 @@ const TRUSTED_PROXY_BROWSER_HEADERS = {
const originForPort = (port: number) => `http://127.0.0.1:${port}`;
type GatewayConnectResponse = Awaited<ReturnType<typeof connectReq>>;
type GatewayTestClient = {
id: string;
version: string;
platform: string;
mode: string;
};
type SignedBrowserDevice = Awaited<ReturnType<typeof createSignedDevice>>;
const openWs = async (port: number, headers?: Record<string, string>) => {
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<string, string>;
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<void>,
) {
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);
},
);
});
});