fix(gateway): harden browser websocket auth chain

This commit is contained in:
Peter Steinberger
2026-02-26 01:22:28 +01:00
parent f41715a18f
commit c736f11a16
7 changed files with 105 additions and 7 deletions

View File

@@ -16,6 +16,8 @@ export function attachGatewayWsHandlers(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
logGateway: ReturnType<typeof createSubsystemLogger>;
@@ -41,6 +43,7 @@ export function attachGatewayWsHandlers(params: {
canvasHostServerPort: params.canvasHostServerPort,
resolvedAuth: params.resolvedAuth,
rateLimiter: params.rateLimiter,
browserRateLimiter: params.browserRateLimiter,
gatewayMethods: params.gatewayMethods,
events: params.events,
logGateway: params.logGateway,

View File

@@ -672,6 +672,17 @@ describe("gateway server auth/connect", () => {
ws.close();
});
test("rejects non-local browser origins for non-control-ui clients", async () => {
const ws = await openWs(port, { origin: "https://attacker.example" });
const res = await connectReq(ws, {
token: "secret",
client: TEST_OPERATOR_CLIENT,
});
expect(res.ok).toBe(false);
expect(res.error?.message ?? "").toContain("origin not allowed");
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, {
@@ -701,6 +712,27 @@ describe("gateway server auth/connect", () => {
);
ws.close();
});
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 },
};
await withGatewayServer(async ({ port }) => {
const firstWs = await openWs(port, { origin: originForPort(port) });
const first = await connectReq(firstWs, { token: "wrong" });
expect(first.ok).toBe(false);
expect(first.error?.message ?? "").not.toContain("retry later");
firstWs.close();
const secondWs = await openWs(port, { origin: originForPort(port) });
const second = await connectReq(secondWs, { token: "wrong" });
expect(second.ok).toBe(false);
expect(second.error?.message ?? "").toContain("retry later");
secondWs.close();
});
});
});
describe("explicit none auth", () => {
@@ -1214,6 +1246,43 @@ describe("gateway server auth/connect", () => {
restoreGatewayToken(prevToken);
});
test("does not silently auto-pair non-control-ui browser clients on loopback", async () => {
const { listDevicePairing } = await import("../infra/device-pairing.js");
const { randomUUID } = await import("node:crypto");
const os = await import("node:os");
const path = await import("node:path");
const { server, ws, port, prevToken } = await startServerWithClient("secret");
ws.close();
const browserWs = await openWs(port, { origin: originForPort(port) });
const nonce = await readConnectChallengeNonce(browserWs);
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,
});
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");
const pairing = await listDevicePairing();
const pending = pairing.pending.find((entry) => entry.deviceId === identity.deviceId);
expect(pending).toBeTruthy();
expect(pending?.silent).toBe(false);
browserWs.close();
await server.close();
restoreGatewayToken(prevToken);
});
test("merges remote node/operator pairing requests for the same unpaired device", async () => {
const { mkdtemp } = await import("node:fs/promises");
const { tmpdir } = await import("node:os");

View File

@@ -316,6 +316,11 @@ export async function startGatewayServer(
const authRateLimiter: AuthRateLimiter | undefined = rateLimitConfig
? createAuthRateLimiter(rateLimitConfig)
: undefined;
// Always keep a browser-origin fallback limiter for WS auth attempts.
const browserAuthRateLimiter: AuthRateLimiter = createAuthRateLimiter({
...rateLimitConfig,
exemptLoopback: false,
});
let controlUiRootState: ControlUiRootState | undefined;
if (controlUiRootOverride) {
@@ -574,6 +579,7 @@ export async function startGatewayServer(
canvasHostServerPort,
resolvedAuth,
rateLimiter: authRateLimiter,
browserRateLimiter: browserAuthRateLimiter,
gatewayMethods,
events: GATEWAY_EVENTS,
logGateway: log,
@@ -777,6 +783,7 @@ export async function startGatewayServer(
}
skillsChangeUnsub();
authRateLimiter?.dispose();
browserAuthRateLimiter.dispose();
channelHealthMonitor?.stop();
await close(opts);
},

View File

@@ -65,6 +65,8 @@ export function attachGatewayWsConnectionHandler(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
logGateway: SubsystemLogger;
@@ -90,6 +92,7 @@ export function attachGatewayWsConnectionHandler(params: {
canvasHostServerPort,
resolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,
events,
logGateway,
@@ -278,6 +281,7 @@ export function attachGatewayWsConnectionHandler(params: {
connectNonce,
resolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,
events,
extraHandlers,

View File

@@ -99,6 +99,8 @@ export function attachGatewayWsMessageHandler(params: {
resolvedAuth: ResolvedGatewayAuth;
/** Optional rate limiter for auth brute-force protection. */
rateLimiter?: AuthRateLimiter;
/** Browser-origin fallback limiter (loopback is never exempt). */
browserRateLimiter?: AuthRateLimiter;
gatewayMethods: string[];
events: string[];
extraHandlers: GatewayRequestHandlers;
@@ -130,6 +132,7 @@ export function attachGatewayWsMessageHandler(params: {
connectNonce,
resolvedAuth,
rateLimiter,
browserRateLimiter,
gatewayMethods,
events,
extraHandlers,
@@ -192,6 +195,12 @@ export function attachGatewayWsMessageHandler(params: {
const isWebchatConnect = (p: ConnectParams | null | undefined) => isWebchatClient(p?.client);
const unauthorizedFloodGuard = new UnauthorizedFloodGuard();
const hasBrowserOriginHeader = Boolean(requestOrigin && requestOrigin.trim() !== "");
const enforceBrowserOriginForAnyClient = hasBrowserOriginHeader && !hasProxyHeaders;
const browserRateLimitClientIp =
hasBrowserOriginHeader && isLoopbackAddress(clientIp) ? "198.18.0.1" : clientIp;
const authRateLimiter =
hasBrowserOriginHeader && browserRateLimiter ? browserRateLimiter : rateLimiter;
socket.on("message", async (data) => {
if (isClosed()) {
@@ -329,7 +338,7 @@ export function attachGatewayWsMessageHandler(params: {
const isControlUi = connectParams.client.id === GATEWAY_CLIENT_IDS.CONTROL_UI;
const isWebchat = isWebchatConnect(connectParams);
if (isControlUi || isWebchat) {
if (enforceBrowserOriginForAnyClient || isControlUi || isWebchat) {
const originCheck = checkBrowserOrigin({
requestHost,
origin: requestOrigin,
@@ -377,8 +386,8 @@ export function attachGatewayWsMessageHandler(params: {
req: upgradeReq,
trustedProxies,
allowRealIpFallback,
rateLimiter,
clientIp,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
});
const rejectUnauthorized = (failedAuth: GatewayAuthResult) => {
markHandshakeFailure("unauthorized", {
@@ -556,8 +565,8 @@ export function attachGatewayWsMessageHandler(params: {
deviceId: device?.id,
role,
scopes,
rateLimiter,
clientIp,
rateLimiter: authRateLimiter,
clientIp: browserRateLimitClientIp,
verifyDeviceToken,
}));
if (!authOk) {
@@ -613,11 +622,15 @@ export function attachGatewayWsMessageHandler(params: {
const requirePairing = async (
reason: "not-paired" | "role-upgrade" | "scope-upgrade",
) => {
const allowSilentLocalPairing =
isLocalClient &&
(!hasBrowserOriginHeader || isControlUi || isWebchat) &&
(reason === "not-paired" || reason === "scope-upgrade");
const pairing = await requestDevicePairing({
deviceId: device.id,
publicKey: devicePublicKey,
...clientAccessMetadata,
silent: isLocalClient && (reason === "not-paired" || reason === "scope-upgrade"),
silent: allowSilentLocalPairing,
});
const context = buildRequestContext();
if (pairing.request.silent === true) {