Skip to content

Commit eff0d5a

Browse files
authored
Hardening: tighten preauth WebSocket handshake limits (openclaw#44089)
* Gateway: tighten preauth handshake limits * Changelog: note WebSocket preauth hardening * Gateway: count preauth frame bytes accurately * Gateway: cap WebSocket payloads before auth
1 parent 3e730c0 commit eff0d5a

File tree

5 files changed

+121
-4
lines changed

5 files changed

+121
-4
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
1515
- Security/browser.request: block persistent browser profile create/delete routes from write-scoped `browser.request` so callers can no longer persist admin-only browser profile changes through the browser control surface. (`GHSA-vmhq-cqm9-6p7q`)(#43800) Thanks @tdjackey and @vincentkoc.
1616
- Security/agent: reject public spawned-run lineage fields and keep workspace inheritance on the internal spawned-session path so external `agent` callers can no longer override the gateway workspace boundary. (`GHSA-2rqg-gjgv-84jm`)(#43801) Thanks @tdjackey and @vincentkoc.
1717
- Security/exec allowlist: preserve POSIX case sensitivity and keep `?` within a single path segment so exact-looking allowlist patterns no longer overmatch executables across case or directory boundaries. (`GHSA-f8r2-vg7x-gh8m`)(#43798) Thanks @zpbrent and @vincentkoc.
18+
- Security/WebSocket preauth: shorten unauthenticated handshake retention and reject oversized pre-auth frames before application-layer parsing to reduce pre-pairing exposure on unsupported public deployments. (`GHSA-jv4g-m82p-2j93`)(#44089) (`GHSA-xwx2-ppv2-wx98`)(#44089) Thanks @ez-lbz and @vincentkoc.
1819
- Security/Feishu reactions: preserve looked-up group chat typing and fail closed on ambiguous reaction context so group authorization and mention gating cannot be bypassed through synthetic `p2p` reactions. (`GHSA-m69h-jm2f-2pv8`)(#44088) Thanks @zpbrent and @vincentkoc.
1920
- Security/LINE webhook: require signatures for empty-event POST probes too so unsigned requests no longer confirm webhook reachability with a `200` response. (`GHSA-mhxh-9pjm-w7q5`)(#44090) Thanks @TerminalsandCoffee and @vincentkoc.
2021

src/gateway/server-constants.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
// don't get disconnected mid-invoke with "Max payload size exceeded".
33
export const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
44
export const MAX_BUFFERED_BYTES = 50 * 1024 * 1024; // per-connection send buffer limit (2x max payload)
5+
export const MAX_PREAUTH_PAYLOAD_BYTES = 64 * 1024;
56

67
const DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
78
let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
@@ -20,7 +21,7 @@ export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => {
2021
maxChatHistoryMessagesBytes = value;
2122
}
2223
};
23-
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
24+
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3_000;
2425
export const getHandshakeTimeoutMs = () => {
2526
if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) {
2627
const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);

src/gateway/server-runtime-state.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ import {
2222
createChatRunState,
2323
createToolEventRecipientRegistry,
2424
} from "./server-chat.js";
25-
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
25+
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
2626
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
2727
import type { DedupeEntry } from "./server-shared.js";
2828
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
@@ -185,7 +185,7 @@ export async function createGatewayRuntimeState(params: {
185185

186186
const wss = new WebSocketServer({
187187
noServer: true,
188-
maxPayload: MAX_PAYLOAD_BYTES,
188+
maxPayload: MAX_PREAUTH_PAYLOAD_BYTES,
189189
});
190190
for (const server of httpServers) {
191191
attachGatewayUpgradeHandler({
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import { afterEach, describe, expect, it } from "vitest";
2+
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
3+
import { createGatewaySuiteHarness, readConnectChallengeNonce } from "./test-helpers.server.js";
4+
5+
let cleanupEnv: Array<() => void> = [];
6+
7+
afterEach(async () => {
8+
while (cleanupEnv.length > 0) {
9+
cleanupEnv.pop()?.();
10+
}
11+
});
12+
13+
describe("gateway pre-auth hardening", () => {
14+
it("closes idle unauthenticated sockets after the handshake timeout", async () => {
15+
const previous = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
16+
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "200";
17+
cleanupEnv.push(() => {
18+
if (previous === undefined) {
19+
delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
20+
} else {
21+
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = previous;
22+
}
23+
});
24+
25+
const harness = await createGatewaySuiteHarness();
26+
try {
27+
const ws = await harness.openWs();
28+
await readConnectChallengeNonce(ws);
29+
const close = await new Promise<{ code: number; elapsedMs: number }>((resolve) => {
30+
const startedAt = Date.now();
31+
ws.once("close", (code) => {
32+
resolve({ code, elapsedMs: Date.now() - startedAt });
33+
});
34+
});
35+
expect(close.code).toBe(1000);
36+
expect(close.elapsedMs).toBeGreaterThan(0);
37+
expect(close.elapsedMs).toBeLessThan(1_000);
38+
} finally {
39+
await harness.close();
40+
}
41+
});
42+
43+
it("rejects oversized pre-auth connect frames before application-level auth responses", async () => {
44+
const harness = await createGatewaySuiteHarness();
45+
try {
46+
const ws = await harness.openWs();
47+
await readConnectChallengeNonce(ws);
48+
49+
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
50+
ws.once("close", (code, reason) => {
51+
resolve({ code, reason: reason.toString() });
52+
});
53+
});
54+
55+
const large = "A".repeat(MAX_PREAUTH_PAYLOAD_BYTES + 1024);
56+
ws.send(
57+
JSON.stringify({
58+
type: "req",
59+
id: "oversized-connect",
60+
method: "connect",
61+
params: {
62+
minProtocol: 3,
63+
maxProtocol: 3,
64+
client: { id: "test", version: "1.0.0", platform: "test", mode: "test" },
65+
pathEnv: large,
66+
role: "operator",
67+
},
68+
}),
69+
);
70+
71+
const result = await closed;
72+
expect(result.code).toBe(1009);
73+
} finally {
74+
await harness.close();
75+
}
76+
});
77+
});

src/gateway/server/ws-connection/message-handler.ts

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,12 @@ import {
6262
validateRequestFrame,
6363
} from "../../protocol/index.js";
6464
import { parseGatewayRole } from "../../role-policy.js";
65-
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
65+
import {
66+
MAX_BUFFERED_BYTES,
67+
MAX_PAYLOAD_BYTES,
68+
MAX_PREAUTH_PAYLOAD_BYTES,
69+
TICK_INTERVAL_MS,
70+
} from "../../server-constants.js";
6671
import { handleGatewayRequest } from "../../server-methods.js";
6772
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
6873
import { formatError } from "../../server-utils.js";
@@ -364,6 +369,18 @@ export function attachGatewayWsMessageHandler(params: {
364369
if (isClosed()) {
365370
return;
366371
}
372+
373+
const preauthPayloadBytes = !getClient() ? getRawDataByteLength(data) : undefined;
374+
if (preauthPayloadBytes !== undefined && preauthPayloadBytes > MAX_PREAUTH_PAYLOAD_BYTES) {
375+
setHandshakeState("failed");
376+
setCloseCause("preauth-payload-too-large", {
377+
payloadBytes: preauthPayloadBytes,
378+
limitBytes: MAX_PREAUTH_PAYLOAD_BYTES,
379+
});
380+
close(1009, "preauth payload too large");
381+
return;
382+
}
383+
367384
const text = rawDataToString(data);
368385
try {
369386
const parsed = JSON.parse(text);
@@ -1091,6 +1108,7 @@ export function attachGatewayWsMessageHandler(params: {
10911108
canvasCapability,
10921109
canvasCapabilityExpiresAtMs,
10931110
};
1111+
setSocketMaxPayload(socket, MAX_PAYLOAD_BYTES);
10941112
setClient(nextClient);
10951113
setHandshakeState("connected");
10961114
if (role === "node") {
@@ -1240,3 +1258,23 @@ export function attachGatewayWsMessageHandler(params: {
12401258
}
12411259
});
12421260
}
1261+
1262+
function getRawDataByteLength(data: unknown): number {
1263+
if (Buffer.isBuffer(data)) {
1264+
return data.byteLength;
1265+
}
1266+
if (Array.isArray(data)) {
1267+
return data.reduce((total, chunk) => total + chunk.byteLength, 0);
1268+
}
1269+
if (data instanceof ArrayBuffer) {
1270+
return data.byteLength;
1271+
}
1272+
return Buffer.byteLength(String(data));
1273+
}
1274+
1275+
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
1276+
const receiver = (socket as { _receiver?: { _maxPayload?: number } })._receiver;
1277+
if (receiver) {
1278+
receiver._maxPayload = maxPayload;
1279+
}
1280+
}

0 commit comments

Comments
 (0)