mirror of https://github.com/openclaw/openclaw.git
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:
parent
3e730c0332
commit
eff0d5a947
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue