diff --git a/CHANGELOG.md b/CHANGELOG.md index 59641be8210..1c00c46ade7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/src/gateway/server-constants.ts b/src/gateway/server-constants.ts index d33c6fa7bc2..036ebc5b3fa 100644 --- a/src/gateway/server-constants.ts +++ b/src/gateway/server-constants.ts @@ -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); diff --git a/src/gateway/server-runtime-state.ts b/src/gateway/server-runtime-state.ts index 5733f3671e4..52832de93b8 100644 --- a/src/gateway/server-runtime-state.ts +++ b/src/gateway/server-runtime-state.ts @@ -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({ diff --git a/src/gateway/server.preauth-hardening.test.ts b/src/gateway/server.preauth-hardening.test.ts new file mode 100644 index 00000000000..df5c312286f --- /dev/null +++ b/src/gateway/server.preauth-hardening.test.ts @@ -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(); + } + }); +}); diff --git a/src/gateway/server/ws-connection/message-handler.ts b/src/gateway/server/ws-connection/message-handler.ts index 0897b51e937..7cd7e6450cb 100644 --- a/src/gateway/server/ws-connection/message-handler.ts +++ b/src/gateway/server/ws-connection/message-handler.ts @@ -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; + } +}