High severity7.5GHSA Advisory· Published May 5, 2026· Updated May 5, 2026
CVE-2026-42437
CVE-2026-42437
Description
OpenClaw versions 2026.4.9 before 2026.4.10 contain a denial of service vulnerability in the voice-call realtime WebSocket path that accepts oversized frames without proper validation. Remote attackers can send oversized WebSocket frames to cause service unavailability for deployments exposing the webhook path.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | >= 2026.4.9, < 2026.4.10 | 2026.4.10 |
Affected products
1Patches
1afadb7dae673fix(voice-call): reject oversized realtime WebSocket frames
3 files changed · +178 −19
CHANGELOG.md+2 −0 modified@@ -120,6 +120,8 @@ Docs: https://docs.openclaw.ai - Gateway/restart sentinel: route restart notices only from stored canonical delivery metadata and skip outbound guessing from lossy session keys, avoiding misdelivery on case-sensitive channels like Matrix. (#64391) Thanks @gumadeiras. - Cron/isolated agent: run scheduled agent turns as non-owner senders so owner-only tools stay unavailable during cron execution. (#63878) +- Voice Call/realtime: reject oversized realtime WebSocket frames before bridge setup so large pre-start payloads cannot crash the gateway. (#63890) Thanks @mmaps. + ## 2026.4.9 ### Changes
extensions/voice-call/src/webhook/realtime-handler.test.ts+138 −8 modified@@ -1,9 +1,11 @@ +import { once } from "node:events"; import http from "node:http"; import type { RealtimeVoiceBridge, RealtimeVoiceProviderPlugin, } from "openclaw/plugin-sdk/realtime-voice"; import { describe, expect, it, vi } from "vitest"; +import { WebSocket } from "ws"; import type { VoiceCallRealtimeConfig } from "../config.js"; import type { CallManager } from "../manager.js"; import type { VoiceCallProvider } from "../providers/base.js"; @@ -30,14 +32,25 @@ function makeBridge(): RealtimeVoiceBridge { }; } -const realtimeProvider: RealtimeVoiceProviderPlugin = { - id: "openai", - label: "OpenAI", - isConfigured: () => true, - createBridge: () => makeBridge(), -}; +function makeRealtimeProvider( + createBridge: () => RealtimeVoiceBridge, +): RealtimeVoiceProviderPlugin { + return { + id: "openai", + label: "OpenAI", + isConfigured: () => true, + createBridge, + }; +} -function makeHandler(overrides?: Partial<VoiceCallRealtimeConfig>) { +function makeHandler( + overrides?: Partial<VoiceCallRealtimeConfig>, + deps?: { + manager?: Partial<CallManager>; + provider?: Partial<VoiceCallProvider>; + realtimeProvider?: RealtimeVoiceProviderPlugin; + }, +) { return new RealtimeCallHandler( { enabled: true, @@ -50,6 +63,7 @@ function makeHandler(overrides?: Partial<VoiceCallRealtimeConfig>) { { processEvent: vi.fn(), getCallByProviderCallId: vi.fn(), + ...deps?.manager, } as unknown as CallManager, { name: "twilio", @@ -61,13 +75,84 @@ function makeHandler(overrides?: Partial<VoiceCallRealtimeConfig>) { startListening: vi.fn(), stopListening: vi.fn(), getCallStatus: vi.fn(), + ...deps?.provider, } as unknown as VoiceCallProvider, - realtimeProvider, + deps?.realtimeProvider ?? makeRealtimeProvider(() => makeBridge()), { apiKey: "test-key" }, "/voice/webhook", ); } +const withTimeout = async <T>(promise: Promise<T>, timeoutMs = 2000): Promise<T> => { + let timer: ReturnType<typeof setTimeout> | null = null; + const timeout = new Promise<never>((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out after ${timeoutMs}ms`)), timeoutMs); + }); + + try { + return await Promise.race([promise, timeout]); + } finally { + if (timer) { + clearTimeout(timer); + } + } +}; + +const startRealtimeServer = async ( + handler: RealtimeCallHandler, +): Promise<{ + url: string; + close: () => Promise<void>; +}> => { + const payload = handler.buildTwiMLPayload(makeRequest("/voice/webhook")); + const match = payload.body.match(/wss:\/\/[^/]+(\/[^"]+)/); + if (!match) { + throw new Error("Failed to extract realtime stream path"); + } + + const server = http.createServer(); + server.on("upgrade", (request, socket, head) => { + handler.handleWebSocketUpgrade(request, socket, head); + }); + + await new Promise<void>((resolve) => { + server.listen(0, "127.0.0.1", resolve); + }); + + const address = server.address(); + if (!address || typeof address === "string") { + throw new Error("Failed to resolve test server address"); + } + + return { + url: `ws://127.0.0.1:${address.port}${match[1]}`, + close: async () => { + await new Promise<void>((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }, + }; +}; + +const connectWs = async (url: string): Promise<WebSocket> => { + const ws = new WebSocket(url); + await withTimeout(once(ws, "open") as Promise<[unknown]>); + return ws; +}; + +const waitForClose = async ( + ws: WebSocket, +): Promise<{ + code: number; + reason: string; +}> => { + const [code, reason] = (await withTimeout(once(ws, "close") as Promise<[number, Buffer]>)) ?? []; + return { + code, + reason: Buffer.isBuffer(reason) ? reason.toString("utf8") : String(reason || ""), + }; +}; + describe("RealtimeCallHandler path routing", () => { it("uses the request host and stream path in TwiML", () => { const handler = makeHandler(); @@ -90,3 +175,48 @@ describe("RealtimeCallHandler path routing", () => { ); }); }); + +describe("RealtimeCallHandler websocket hardening", () => { + it("rejects oversized pre-start frames before bridge setup", async () => { + const createBridge = vi.fn(() => makeBridge()); + const processEvent = vi.fn(); + const getCallByProviderCallId = vi.fn(); + const handler = makeHandler(undefined, { + manager: { + processEvent, + getCallByProviderCallId, + }, + realtimeProvider: makeRealtimeProvider(createBridge), + }); + const server = await startRealtimeServer(handler); + + try { + const ws = await connectWs(server.url); + try { + ws.send( + JSON.stringify({ + event: "start", + start: { + streamSid: "MZ-oversized", + callSid: "CA-oversized", + padding: "A".repeat(300 * 1024), + }, + }), + ); + + const closed = await waitForClose(ws); + + expect(closed.code).toBe(1009); + expect(createBridge).not.toHaveBeenCalled(); + expect(processEvent).not.toHaveBeenCalled(); + expect(getCallByProviderCallId).not.toHaveBeenCalled(); + } finally { + if (ws.readyState !== WebSocket.CLOSED && ws.readyState !== WebSocket.CLOSING) { + ws.close(); + } + } + } finally { + await server.close(); + } + }); +});
extensions/voice-call/src/webhook/realtime-handler.ts+38 −11 modified@@ -18,6 +18,7 @@ export type ToolHandlerFn = (args: unknown, callId: string) => Promise<unknown>; const STREAM_TOKEN_TTL_MS = 30_000; const DEFAULT_HOST = "localhost:8443"; +const MAX_REALTIME_MESSAGE_BYTES = 256 * 1024; function normalizePath(pathname: string): string { const trimmed = pathname.trim(); @@ -58,6 +59,17 @@ type CallRegistration = { initialGreetingInstructions?: string; }; +type ActiveRealtimeVoiceBridge = Pick< + RealtimeVoiceBridge, + | "connect" + | "sendAudio" + | "setMediaTimestamp" + | "submitToolResult" + | "acknowledgeMark" + | "close" + | "triggerGreeting" +>; + export class RealtimeCallHandler { private readonly toolHandlers = new Map<string, ToolHandlerFn>(); private readonly pendingStreamTokens = new Map<string, PendingStreamToken>(); @@ -123,9 +135,13 @@ export class RealtimeCallHandler { return; } - const wss = new WebSocketServer({ noServer: true }); + const wss = new WebSocketServer({ + noServer: true, + // Reject oversized realtime frames before JSON parsing or bridge setup runs. + maxPayload: MAX_REALTIME_MESSAGE_BYTES, + }); wss.handleUpgrade(request, socket, head, (ws) => { - let bridge: RealtimeVoiceBridge | null = null; + let bridge: ActiveRealtimeVoiceBridge | null = null; let initialized = false; ws.on("message", (data: Buffer) => { @@ -140,7 +156,11 @@ export class RealtimeCallHandler { const streamSid = typeof startData?.streamSid === "string" ? startData.streamSid : "unknown"; const callSid = typeof startData?.callSid === "string" ? startData.callSid : "unknown"; - bridge = this.handleCall(streamSid, callSid, ws, callerMeta); + const nextBridge = this.handleCall(streamSid, callSid, ws, callerMeta); + if (!nextBridge) { + return; + } + bridge = nextBridge; return; } if (!bridge) { @@ -174,6 +194,10 @@ export class RealtimeCallHandler { ws.on("close", () => { bridge?.close(); }); + + ws.on("error", (error) => { + console.error("[voice-call] realtime WS error:", error); + }); }); } @@ -213,15 +237,14 @@ export class RealtimeCallHandler { callSid: string, ws: WebSocket, callerMeta: Omit<PendingStreamToken, "expiry">, - ): RealtimeVoiceBridge | null { + ): ActiveRealtimeVoiceBridge | null { const registration = this.registerCallInManager(callSid, callerMeta); if (!registration) { ws.close(1008, "Caller rejected by policy"); return null; } const { callId, initialGreetingInstructions } = registration; - let bridge: RealtimeVoiceBridge | null = null; let callEndEmitted = false; const emitCallEnd = (reason: "completed" | "error") => { if (callEndEmitted) { @@ -231,7 +254,8 @@ export class RealtimeCallHandler { this.endCallInManager(callSid, callId, reason); }; - bridge = this.realtimeProvider.createBridge({ + const bridgeRef: { current?: ActiveRealtimeVoiceBridge } = {}; + const bridge = this.realtimeProvider.createBridge({ providerConfig: this.providerConfig, instructions: this.config.instructions, tools: this.config.tools, @@ -286,19 +310,20 @@ export class RealtimeCallHandler { }); }, onToolCall: (toolEvent) => { - if (!bridge) { + const activeBridge = bridgeRef.current; + if (!activeBridge) { return; } void this.executeToolCall( - bridge, + activeBridge, callId, toolEvent.callId || toolEvent.itemId, toolEvent.name, toolEvent.args, ); }, onReady: () => { - bridge?.triggerGreeting?.(initialGreetingInstructions); + bridgeRef.current?.triggerGreeting?.(initialGreetingInstructions); }, onError: (error) => { console.error("[voice-call] realtime voice error:", error.message); @@ -323,9 +348,11 @@ export class RealtimeCallHandler { }, }); + bridgeRef.current = bridge; + bridge.connect().catch((error: Error) => { console.error("[voice-call] Failed to connect realtime bridge:", error); - bridge?.close(); + bridge.close(); emitCallEnd("error"); ws.close(1011, "Failed to connect"); }); @@ -397,7 +424,7 @@ export class RealtimeCallHandler { } private async executeToolCall( - bridge: RealtimeVoiceBridge, + bridge: ActiveRealtimeVoiceBridge, callId: string, bridgeCallId: string, name: string,
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-vw3h-q6xq-jjm5ghsaADVISORY
- github.com/openclaw/openclaw/commit/afadb7dae6738819ad9c7d2597ace0516957d20envdWEB
- github.com/openclaw/openclaw/pull/63890ghsaWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-vw3h-q6xq-jjm5nvdWEB
- www.vulncheck.com/advisories/openclaw-denial-of-service-via-oversized-websocket-frames-in-voice-call-realtime-pathnvd
News mentions
0No linked articles in our index yet.