openclaw - 💡(How to fix) Fix [Bug]: Replayed Twilio webhook mints a fresh realtime stream token [2 pull requests]

Official PRs (…)
ON THIS PAGE

Recommended Tools

×6

Utilities matched from this issue’s tags and category — try them while you read without losing context.

GitHub issue graph ai analysis

Paste a GitHub issue URL. We fetch that issue, discover linked issues from bodies/comments/timeline, collect linked pull requests, and produce a structured English report.

The report is written in English Markdown for sharing and archival.

Helpful · Quick feedback

Loading…

Error Message

console.warn([voice-call] Webhook verification failed: ${verification.reason}); console.warn("[voice-call] Webhook verification succeeded without request identity key"); console.warn("[voice-call] Replay detected; skipping event side effects");

Root Cause

This crosses the webhook/auth boundary described by SECURITY.md: webhook-driven payloads are untrusted content, and current reports must show a reproducible boundary bypass with demonstrated impact. The issue is not covered by the Out of Scope section because it does not rely on prompt injection, trusted local state, public exposure, operator-selected dangerous flags, or adversarial operators sharing one gateway host/config; it lets a network caller who can replay one valid signed Twilio webhook bypass the replay guard and obtain a fresh OpenClaw realtime media-stream credential.

Fix Action

Fixed

Code Example

const verification = this.provider.verifyWebhook(ctx);
if (!verification.ok) {
  console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
  return { statusCode: 401, body: "Unauthorized" };
}
if (!verification.verifiedRequestKey) {
  console.warn("[voice-call] Webhook verification succeeded without request identity key");
  return { statusCode: 401, body: "Unauthorized" };
}

const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);

---

const realtimeParams = this.getRealtimeTwimlParams(ctx);
if (realtimeParams) {
  const direction = realtimeParams.get("Direction");
  const isInboundRealtimeRequest = !direction || direction === "inbound";
  if (isInboundRealtimeRequest && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
    console.log("[voice-call] Realtime inbound call rejected before stream setup");
    return buildRealtimeRejectedTwiML();
  }
  console.log(
    `[voice-call] Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`,
  );
  return this.realtimeHandler!.buildTwiMLPayload(req, realtimeParams);
}

---

const direction = params.get("Direction");
const isSupportedDirection =
  !direction || direction === "inbound" || direction.startsWith("outbound");
if (!isSupportedDirection) {
  return null;
}

---

// Replays must return the same TwiML body so Twilio retries reconnect cleanly.
// The one-time token still changes, but the behavior stays identical.
return !params.get("SpeechResult") && !params.get("Digits") ? params : null;

---

if (verification.isReplay) {
  console.warn("[voice-call] Replay detected; skipping event side effects");
} else {
  this.processParsedEvents(parsed.events);
}

---

buildTwiMLPayload(req: http.IncomingMessage, params?: URLSearchParams): WebhookResponsePayload {
  const rawDirection = params?.get("Direction");
  const previousOrigin = this.publicOrigin;
  if (!previousOrigin) {
    this.publicOrigin = req.headers.host ?? DEFAULT_HOST;
  }
  try {
    const { streamUrl } = this.issueStreamSession({
      providerName: "twilio",
      from: params?.get("From") ?? undefined,

---

issueStreamSession(request: StreamSessionRequest = {}): StreamSession {
  const token = this.issueStreamToken({
    providerName: request.providerName ?? "twilio",
    callId: request.callId,
    from: request.from,
    to: request.to,
    direction: request.direction,
  });
  const host = this.publicOrigin || DEFAULT_HOST;
  const streamUrl = `wss://${host}${this.getStreamPathPattern()}/${token}`;
  return { token, streamUrl };
}

---

private issueStreamToken(meta: Omit<PendingStreamToken, "expiry"> = {}): string {
  const token = randomUUID();
  this.pendingStreamTokens.set(token, { expiry: Date.now() + STREAM_TOKEN_TTL_MS, ...meta });
  for (const [candidate, entry] of this.pendingStreamTokens) {
    if (Date.now() > entry.expiry) {
      this.pendingStreamTokens.delete(candidate);

---

private consumeStreamToken(token: string): Omit<PendingStreamToken, "expiry"> | null {
  const entry = this.pendingStreamTokens.get(token);
  if (!entry) {
    return null;
  }
  this.pendingStreamTokens.delete(token);
  if (Date.now() > entry.expiry) {
    return null;
  }
RAW_BUFFERClick to expand / collapse

Severity Assessment

CVSS Assessment

Metricv3.1v4.0
Score9.4 / 10.09.3 / 10.0
SeverityCriticalCritical
VectorCVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:LCVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:L/SC:N/SI:N/SA:N
CalculatorCVSS v3.1 CalculatorCVSS v4.0 Calculator

Threat Model Alignment

Classification: security-specific

This crosses the webhook/auth boundary described by SECURITY.md: webhook-driven payloads are untrusted content, and current reports must show a reproducible boundary bypass with demonstrated impact. The issue is not covered by the Out of Scope section because it does not rely on prompt injection, trusted local state, public exposure, operator-selected dangerous flags, or adversarial operators sharing one gateway host/config; it lets a network caller who can replay one valid signed Twilio webhook bypass the replay guard and obtain a fresh OpenClaw realtime media-stream credential.

Impact

A replayed, already-seen Twilio webhook can still reach the realtime TwiML branch before the replay result is enforced. OpenClaw returns a new one-time wss://.../voice/stream/realtime/<token> URL, and connecting to that URL registers/answers a realtime voice call under the signed webhook's caller metadata.

The originally documented inbound case is valid. The same vulnerable shortcut also covers Twilio realtime TwiML fetches whose Direction is outbound-api or outbound-dial, because getRealtimeTwimlParams accepts outbound directions before the replay guard runs.

Affected Component

File: extensions/voice-call/src/webhook.ts:709

const verification = this.provider.verifyWebhook(ctx);
if (!verification.ok) {
  console.warn(`[voice-call] Webhook verification failed: ${verification.reason}`);
  return { statusCode: 401, body: "Unauthorized" };
}
if (!verification.verifiedRequestKey) {
  console.warn("[voice-call] Webhook verification succeeded without request identity key");
  return { statusCode: 401, body: "Unauthorized" };
}

const initialTwiML = this.provider.consumeInitialTwiML?.(ctx);

File: extensions/voice-call/src/webhook.ts:732

const realtimeParams = this.getRealtimeTwimlParams(ctx);
if (realtimeParams) {
  const direction = realtimeParams.get("Direction");
  const isInboundRealtimeRequest = !direction || direction === "inbound";
  if (isInboundRealtimeRequest && !this.shouldAcceptRealtimeInboundRequest(realtimeParams)) {
    console.log("[voice-call] Realtime inbound call rejected before stream setup");
    return buildRealtimeRejectedTwiML();
  }
  console.log(
    `[voice-call] Serving realtime TwiML for Twilio call ${realtimeParams.get("CallSid") ?? "unknown"} (direction=${direction ?? "unknown"})`,
  );
  return this.realtimeHandler!.buildTwiMLPayload(req, realtimeParams);
}

File: extensions/voice-call/src/webhook.ts:814

const direction = params.get("Direction");
const isSupportedDirection =
  !direction || direction === "inbound" || direction.startsWith("outbound");
if (!isSupportedDirection) {
  return null;
}

File: extensions/voice-call/src/webhook.ts:829

// Replays must return the same TwiML body so Twilio retries reconnect cleanly.
// The one-time token still changes, but the behavior stays identical.
return !params.get("SpeechResult") && !params.get("Digits") ? params : null;

File: extensions/voice-call/src/webhook.ts:750

if (verification.isReplay) {
  console.warn("[voice-call] Replay detected; skipping event side effects");
} else {
  this.processParsedEvents(parsed.events);
}

File: extensions/voice-call/src/webhook/realtime-handler.ts:340

buildTwiMLPayload(req: http.IncomingMessage, params?: URLSearchParams): WebhookResponsePayload {
  const rawDirection = params?.get("Direction");
  const previousOrigin = this.publicOrigin;
  if (!previousOrigin) {
    this.publicOrigin = req.headers.host ?? DEFAULT_HOST;
  }
  try {
    const { streamUrl } = this.issueStreamSession({
      providerName: "twilio",
      from: params?.get("From") ?? undefined,

File: extensions/voice-call/src/webhook/realtime-handler.ts:490

issueStreamSession(request: StreamSessionRequest = {}): StreamSession {
  const token = this.issueStreamToken({
    providerName: request.providerName ?? "twilio",
    callId: request.callId,
    from: request.from,
    to: request.to,
    direction: request.direction,
  });
  const host = this.publicOrigin || DEFAULT_HOST;
  const streamUrl = `wss://${host}${this.getStreamPathPattern()}/${token}`;
  return { token, streamUrl };
}

File: extensions/voice-call/src/webhook/realtime-handler.ts:503

private issueStreamToken(meta: Omit<PendingStreamToken, "expiry"> = {}): string {
  const token = randomUUID();
  this.pendingStreamTokens.set(token, { expiry: Date.now() + STREAM_TOKEN_TTL_MS, ...meta });
  for (const [candidate, entry] of this.pendingStreamTokens) {
    if (Date.now() > entry.expiry) {
      this.pendingStreamTokens.delete(candidate);

File: extensions/voice-call/src/webhook/realtime-handler.ts:514

private consumeStreamToken(token: string): Omit<PendingStreamToken, "expiry"> | null {
  const entry = this.pendingStreamTokens.get(token);
  if (!entry) {
    return null;
  }
  this.pendingStreamTokens.delete(token);
  if (Date.now() > entry.expiry) {
    return null;
  }

Technical Reproduction

  1. Run OpenClaw at commit 7c7fb7df678b1cb7c3b76564a591ad2b3ba0de23 with the voice-call extension configured for Twilio realtime webhooks, a known authToken, and an inbound policy that accepts the test From number.
  2. Generate a normal Twilio-style POST body for a non-terminal inbound webhook, for example CallSid=CAreplay1&CallStatus=in-progress&Direction=inbound&From=%2B15551234567&To=%2B15557654321, and compute X-Twilio-Signature for the exact webhook URL using the configured Twilio auth token.
  3. Send that signed request once to the OpenClaw voice-call webhook URL. Then send the exact same method, URL, headers, and body a second time while the provider replay cache still marks the request as seen.
  4. The replayed request is still accepted by verifyWebhook; instead of being stopped by verification.isReplay, it enters the realtime TwiML branch and returns HTTP 200 with a fresh <Stream url="wss://.../voice/stream/realtime/<new-token>" />.
  5. Connect to the returned stream URL before STREAM_TOKEN_TTL_MS expires. consumeStreamToken accepts the fresh token, and registerCallInManager emits call.initiated / call.answered for the replayed CallSid.
  6. The same replay-before-realtime-token ordering also applies to non-terminal Twilio realtime TwiML fetches with Direction=outbound-api or Direction=outbound-dial, because getRealtimeTwimlParams treats outbound* directions as supported and returns before replay enforcement.

Demonstrated Impact

verifyWebhook already computes replay state and webhook.ts:750 skips side effects for replayed parsed events, but that guard only runs after the realtime TwiML branch. getRealtimeTwimlParams explicitly returns parameters for webhook bodies without SpeechResult or Digits, and the comment at webhook.ts:829 says replays still receive the same TwiML behavior while the one-time token changes. That makes the replay check incomplete: a stale signed Twilio request can be replayed to mint a new OpenClaw stream credential.

The credential is consumed by the realtime WebSocket path and creates or answers a voice-call session using the original webhook's From, To, direction, and provider call metadata, so the attacker crosses from possession of one captured signed webhook into a fresh realtime media stream/session capability. Existing controls do not prevent this because verification.isReplay is not checked before consumeInitialTwiML or buildTwiMLPayload; the inbound allowlist only filters caller identity and does not reject an allowlisted replay, while outbound realtime TwiML fetches bypass that inbound-only policy branch entirely.

Existing tests confirm the vulnerable behavior rather than mitigating it: extensions/voice-call/src/webhook.test.ts:723 asserts that replayed inbound Twilio webhooks return realtime TwiML, and extensions/voice-call/src/webhook.test.ts:775 asserts that outbound Twilio realtime TwiML fetches take the same shortcut path.

Environment

Tested against local OpenClaw source target commit:7c7fb7df678b1cb7c3b76564a591ad2b3ba0de23 at commit 7c7fb7df678b1cb7c3b76564a591ad2b3ba0de23. The vulnerable path is present in extensions/voice-call/src/webhook.ts and extensions/voice-call/src/webhook/realtime-handler.ts on this revision. This is a current-main source finding; it does not claim released-version impact or depend on a shipped tag/published artifact.

Remediation Advice

Enforce replay rejection before any TwiML response path that mints or exposes stream credentials. If provider retry compatibility requires an idempotent TwiML response, cache and return the original tokenless response body without issuing a new stream token or registering new realtime call state for a replayed request.

A root-cause fix should make verification.isReplay authoritative before consumeInitialTwiML and buildTwiMLPayload whenever the response path can create a new capability. At minimum, replayed realtime TwiML requests should not call buildTwiMLPayload, because that function always issues a new pending stream token.

<!-- submission-marker:CR-rvf-twilio-replay-mints-realtime-stream-token -->

Vote matrix · Quick signals

Works
Did the solution work? Tap to confirm.
Easy Fix
Was it a quick fix?
Time Saver
Did it save you time?
Blocking
Was it severely blocking?
Common Issue
Are others likely hitting this too?
Flaky / Intermittent
Is it intermittent?
Verified / Reproducible
Can you reproduce it reliably?
Loading…

Still need to ship something?

×6

Another batch ranked right after the header list — different links, same matching logic.

Back to top recommendations

TRENDING