mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 04:02:53 +00:00
refactor: share browser auth test helpers
This commit is contained in:
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user