VYPR
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.

PackageAffected versionsPatched versions
openclawnpm
>= 2026.4.9, < 2026.4.102026.4.10

Affected products

1

Patches

1
afadb7dae673

fix(voice-call): reject oversized realtime WebSocket frames

https://github.com/openclaw/openclawMichael AppelApr 10, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.