Hardening: tighten preauth WebSocket handshake limits (#44089)

* Gateway: tighten preauth handshake limits

* Changelog: note WebSocket preauth hardening

* Gateway: count preauth frame bytes accurately

* Gateway: cap WebSocket payloads before auth
This commit is contained in:
Vincent Koc 2026-03-12 10:55:41 -04:00 committed by GitHub
parent 3e730c0332
commit eff0d5a947
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 121 additions and 4 deletions

View File

@ -15,6 +15,7 @@ Docs: https://docs.openclaw.ai
- 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.
- 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.
- 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.
- 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.
- 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.
- 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.

View File

@ -2,6 +2,7 @@
// don't get disconnected mid-invoke with "Max payload size exceeded".
export const MAX_PAYLOAD_BYTES = 25 * 1024 * 1024;
export const MAX_BUFFERED_BYTES = 50 * 1024 * 1024; // per-connection send buffer limit (2x max payload)
export const MAX_PREAUTH_PAYLOAD_BYTES = 64 * 1024;
const DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES = 6 * 1024 * 1024; // keep history responses comfortably under client WS limits
let maxChatHistoryMessagesBytes = DEFAULT_MAX_CHAT_HISTORY_MESSAGES_BYTES;
@ -20,7 +21,7 @@ export const __setMaxChatHistoryMessagesBytesForTest = (value?: number) => {
maxChatHistoryMessagesBytes = value;
}
};
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 10_000;
export const DEFAULT_HANDSHAKE_TIMEOUT_MS = 3_000;
export const getHandshakeTimeoutMs = () => {
if (process.env.VITEST && process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS) {
const parsed = Number(process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS);

View File

@ -22,7 +22,7 @@ import {
createChatRunState,
createToolEventRecipientRegistry,
} from "./server-chat.js";
import { MAX_PAYLOAD_BYTES } from "./server-constants.js";
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
import { attachGatewayUpgradeHandler, createGatewayHttpServer } from "./server-http.js";
import type { DedupeEntry } from "./server-shared.js";
import { createGatewayHooksRequestHandler } from "./server/hooks.js";
@ -185,7 +185,7 @@ export async function createGatewayRuntimeState(params: {
const wss = new WebSocketServer({
noServer: true,
maxPayload: MAX_PAYLOAD_BYTES,
maxPayload: MAX_PREAUTH_PAYLOAD_BYTES,
});
for (const server of httpServers) {
attachGatewayUpgradeHandler({

View File

@ -0,0 +1,77 @@
import { afterEach, describe, expect, it } from "vitest";
import { MAX_PREAUTH_PAYLOAD_BYTES } from "./server-constants.js";
import { createGatewaySuiteHarness, readConnectChallengeNonce } from "./test-helpers.server.js";
let cleanupEnv: Array<() => void> = [];
afterEach(async () => {
while (cleanupEnv.length > 0) {
cleanupEnv.pop()?.();
}
});
describe("gateway pre-auth hardening", () => {
it("closes idle unauthenticated sockets after the handshake timeout", async () => {
const previous = process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = "200";
cleanupEnv.push(() => {
if (previous === undefined) {
delete process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS;
} else {
process.env.OPENCLAW_TEST_HANDSHAKE_TIMEOUT_MS = previous;
}
});
const harness = await createGatewaySuiteHarness();
try {
const ws = await harness.openWs();
await readConnectChallengeNonce(ws);
const close = await new Promise<{ code: number; elapsedMs: number }>((resolve) => {
const startedAt = Date.now();
ws.once("close", (code) => {
resolve({ code, elapsedMs: Date.now() - startedAt });
});
});
expect(close.code).toBe(1000);
expect(close.elapsedMs).toBeGreaterThan(0);
expect(close.elapsedMs).toBeLessThan(1_000);
} finally {
await harness.close();
}
});
it("rejects oversized pre-auth connect frames before application-level auth responses", async () => {
const harness = await createGatewaySuiteHarness();
try {
const ws = await harness.openWs();
await readConnectChallengeNonce(ws);
const closed = new Promise<{ code: number; reason: string }>((resolve) => {
ws.once("close", (code, reason) => {
resolve({ code, reason: reason.toString() });
});
});
const large = "A".repeat(MAX_PREAUTH_PAYLOAD_BYTES + 1024);
ws.send(
JSON.stringify({
type: "req",
id: "oversized-connect",
method: "connect",
params: {
minProtocol: 3,
maxProtocol: 3,
client: { id: "test", version: "1.0.0", platform: "test", mode: "test" },
pathEnv: large,
role: "operator",
},
}),
);
const result = await closed;
expect(result.code).toBe(1009);
} finally {
await harness.close();
}
});
});

View File

@ -62,7 +62,12 @@ import {
validateRequestFrame,
} from "../../protocol/index.js";
import { parseGatewayRole } from "../../role-policy.js";
import { MAX_BUFFERED_BYTES, MAX_PAYLOAD_BYTES, TICK_INTERVAL_MS } from "../../server-constants.js";
import {
MAX_BUFFERED_BYTES,
MAX_PAYLOAD_BYTES,
MAX_PREAUTH_PAYLOAD_BYTES,
TICK_INTERVAL_MS,
} from "../../server-constants.js";
import { handleGatewayRequest } from "../../server-methods.js";
import type { GatewayRequestContext, GatewayRequestHandlers } from "../../server-methods/types.js";
import { formatError } from "../../server-utils.js";
@ -364,6 +369,18 @@ export function attachGatewayWsMessageHandler(params: {
if (isClosed()) {
return;
}
const preauthPayloadBytes = !getClient() ? getRawDataByteLength(data) : undefined;
if (preauthPayloadBytes !== undefined && preauthPayloadBytes > MAX_PREAUTH_PAYLOAD_BYTES) {
setHandshakeState("failed");
setCloseCause("preauth-payload-too-large", {
payloadBytes: preauthPayloadBytes,
limitBytes: MAX_PREAUTH_PAYLOAD_BYTES,
});
close(1009, "preauth payload too large");
return;
}
const text = rawDataToString(data);
try {
const parsed = JSON.parse(text);
@ -1091,6 +1108,7 @@ export function attachGatewayWsMessageHandler(params: {
canvasCapability,
canvasCapabilityExpiresAtMs,
};
setSocketMaxPayload(socket, MAX_PAYLOAD_BYTES);
setClient(nextClient);
setHandshakeState("connected");
if (role === "node") {
@ -1240,3 +1258,23 @@ export function attachGatewayWsMessageHandler(params: {
}
});
}
function getRawDataByteLength(data: unknown): number {
if (Buffer.isBuffer(data)) {
return data.byteLength;
}
if (Array.isArray(data)) {
return data.reduce((total, chunk) => total + chunk.byteLength, 0);
}
if (data instanceof ArrayBuffer) {
return data.byteLength;
}
return Buffer.byteLength(String(data));
}
function setSocketMaxPayload(socket: WebSocket, maxPayload: number): void {
const receiver = (socket as { _receiver?: { _maxPayload?: number } })._receiver;
if (receiver) {
receiver._maxPayload = maxPayload;
}
}