diff --git a/CHANGELOG.md b/CHANGELOG.md index bc15ed16012..798a08d6993 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.openclaw.ai - Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Node exec approvals: require structured `commandArgv` approvals for `host=node`, enforce versioned `systemRunBindingV1` matching for argv/cwd/session/agent/env context with fail-closed behavior on missing/mismatched bindings, and add `GIT_EXTERNAL_DIFF` to blocked host env keys. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Security/Microsoft Teams media fetch: route Graph message/hosted-content/attachment fetches and auth-scope fallback attachment downloads through shared SSRF-guarded fetch paths, and centralize hostname-suffix allowlist policy helpers in the plugin SDK to remove channel/plugin drift. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. +- Security/Voice Call (Twilio): bind webhook replay + manager dedupe identity to authenticated request material, remove unsigned `i-twilio-idempotency-token` trust from replay/dedupe keys, and thread verified request identity through provider parse flow to harden cross-provider event dedupe. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. - Microsoft Teams/File uploads: acknowledge `fileConsent/invoke` immediately (`invokeResponse` before upload + file card send) so Teams no longer shows false "Something went wrong" timeout banners while upload completion continues asynchronously; includes updated async regression coverage. Landed from contributor PR #27641 by @scz2011. - Security/Plugin channel HTTP auth: normalize protected `/api/channels` path checks against canonicalized request paths (case + percent-decoding + slash normalization), resolve encoded dot-segment traversal variants, and fail closed on malformed `%`-encoded channel prefixes so alternate-path variants cannot bypass gateway auth. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. - Security/Exec approvals forwarding: prefer turn-source channel/account/thread metadata when resolving approval delivery targets so stale session routes do not misroute approval prompts. diff --git a/extensions/voice-call/src/providers/base.ts b/extensions/voice-call/src/providers/base.ts index 63a9a047181..2d76cc15a7e 100644 --- a/extensions/voice-call/src/providers/base.ts +++ b/extensions/voice-call/src/providers/base.ts @@ -4,6 +4,7 @@ import type { InitiateCallResult, PlayTtsInput, ProviderName, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -36,7 +37,7 @@ export interface VoiceCallProvider { * Parse provider-specific webhook payload into normalized events. * Returns events and optional response to send back to provider. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult; + parseWebhookEvent(ctx: WebhookContext, options?: WebhookParseOptions): ProviderWebhookParseResult; /** * Initiate an outbound call. diff --git a/extensions/voice-call/src/providers/mock.ts b/extensions/voice-call/src/providers/mock.ts index bc6a52efa71..6602d6e71f9 100644 --- a/extensions/voice-call/src/providers/mock.ts +++ b/extensions/voice-call/src/providers/mock.ts @@ -6,6 +6,7 @@ import type { InitiateCallResult, NormalizedEvent, PlayTtsInput, + WebhookParseOptions, ProviderWebhookParseResult, StartListeningInput, StopListeningInput, @@ -28,7 +29,10 @@ export class MockProvider implements VoiceCallProvider { return { ok: true }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + _options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const events: NormalizedEvent[] = []; diff --git a/extensions/voice-call/src/providers/plivo.test.ts b/extensions/voice-call/src/providers/plivo.test.ts index 1f46e2d47a5..7652c3777cd 100644 --- a/extensions/voice-call/src/providers/plivo.test.ts +++ b/extensions/voice-call/src/providers/plivo.test.ts @@ -24,4 +24,26 @@ describe("PlivoProvider", () => { expect(result.providerResponseBody).toContain(" { + const provider = new PlivoProvider({ + authId: "MA000000000000000000", + authToken: "test-token", + }); + + const result = provider.parseWebhookEvent( + { + headers: { host: "example.com", "x-plivo-signature-v3-nonce": "nonce-1" }, + rawBody: + "CallUUID=call-uuid&CallStatus=in-progress&Direction=outbound&From=%2B15550000000&To=%2B15550000001&Event=StartApp", + url: "https://example.com/voice/webhook?provider=plivo&flow=answer&callId=internal-call-id", + method: "POST", + query: { provider: "plivo", flow: "answer", callId: "internal-call-id" }, + }, + { verifiedRequestKey: "plivo:v3:verified" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("plivo:v3:verified"); + }); }); diff --git a/extensions/voice-call/src/providers/plivo.ts b/extensions/voice-call/src/providers/plivo.ts index 5b5311acc73..aab766e1282 100644 --- a/extensions/voice-call/src/providers/plivo.ts +++ b/extensions/voice-call/src/providers/plivo.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { PlivoConfig, WebhookSecurityConfig } from "../config.js"; import type { HangupCallInput, @@ -10,6 +11,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml } from "../voice-mapping.js"; @@ -60,6 +62,7 @@ export class PlivoProvider implements VoiceCallProvider { private readonly authToken: string; private readonly baseUrl: string; private readonly options: PlivoProviderOptions; + private readonly apiHost: string; // Best-effort mapping between create-call request UUID and call UUID. private requestUuidToCallUuid = new Map(); @@ -82,6 +85,7 @@ export class PlivoProvider implements VoiceCallProvider { this.authId = config.authId; this.authToken = config.authToken; this.baseUrl = `https://api.plivo.com/v1/Account/${this.authId}`; + this.apiHost = new URL(this.baseUrl).hostname; this.options = options; } @@ -92,25 +96,33 @@ export class PlivoProvider implements VoiceCallProvider { allowNotFound?: boolean; }): Promise { const { method, endpoint, body, allowNotFound } = params; - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method, - headers: { - Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, - "Content-Type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `${this.baseUrl}${endpoint}`, + init: { + method, + headers: { + Authorization: `Basic ${Buffer.from(`${this.authId}:${this.authToken}`).toString("base64")}`, + "Content-Type": "application/json", + }, + body: body ? JSON.stringify(body) : undefined, }, - body: body ? JSON.stringify(body) : undefined, + policy: { allowedHostnames: [this.apiHost] }, + auditContext: "voice-call.plivo.api", }); - - if (!response.ok) { - if (allowNotFound && response.status === 404) { - return undefined as T; + try { + if (!response.ok) { + if (allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`Plivo API error: ${response.status} ${errorText}`); } - const errorText = await response.text(); - throw new Error(`Plivo API error: ${response.status} ${errorText}`); - } - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } } verifyWebhook(ctx: WebhookContext): WebhookVerificationResult { @@ -127,10 +139,18 @@ export class PlivoProvider implements VoiceCallProvider { console.warn(`[plivo] Webhook verification failed: ${result.reason}`); } - return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const parsed = this.parseBody(ctx.rawBody); @@ -196,7 +216,7 @@ export class PlivoProvider implements VoiceCallProvider { // Normal events. const callIdFromQuery = this.getCallIdFromQuery(ctx); - const dedupeKey = createPlivoRequestDedupeKey(ctx); + const dedupeKey = options?.verifiedRequestKey ?? createPlivoRequestDedupeKey(ctx); const event = this.normalizeEvent(parsed, callIdFromQuery, dedupeKey); return { diff --git a/extensions/voice-call/src/providers/telnyx.test.ts b/extensions/voice-call/src/providers/telnyx.test.ts index 7fcd756b943..c083070229f 100644 --- a/extensions/voice-call/src/providers/telnyx.test.ts +++ b/extensions/voice-call/src/providers/telnyx.test.ts @@ -133,7 +133,34 @@ describe("TelnyxProvider.verifyWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); +}); + +describe("TelnyxProvider.parseWebhookEvent", () => { + it("uses verified request key for manager dedupe", () => { + const provider = new TelnyxProvider({ + apiKey: "KEY123", + connectionId: "CONN456", + publicKey: undefined, + }); + const result = provider.parseWebhookEvent( + createCtx({ + rawBody: JSON.stringify({ + data: { + id: "evt-123", + event_type: "call.initiated", + payload: { call_control_id: "call-1" }, + }, + }), + }), + { verifiedRequestKey: "telnyx:req:abc" }, + ); + + expect(result.events).toHaveLength(1); + expect(result.events[0]?.dedupeKey).toBe("telnyx:req:abc"); }); }); diff --git a/extensions/voice-call/src/providers/telnyx.ts b/extensions/voice-call/src/providers/telnyx.ts index e81844f1f65..8dbca63746b 100644 --- a/extensions/voice-call/src/providers/telnyx.ts +++ b/extensions/voice-call/src/providers/telnyx.ts @@ -1,4 +1,5 @@ import crypto from "node:crypto"; +import { fetchWithSsrFGuard } from "openclaw/plugin-sdk"; import type { TelnyxConfig } from "../config.js"; import type { EndReason, @@ -11,6 +12,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { verifyTelnyxWebhook } from "../webhook-security.js"; @@ -35,6 +37,7 @@ export class TelnyxProvider implements VoiceCallProvider { private readonly publicKey: string | undefined; private readonly options: TelnyxProviderOptions; private readonly baseUrl = "https://api.telnyx.com/v2"; + private readonly apiHost = "api.telnyx.com"; constructor(config: TelnyxConfig, options: TelnyxProviderOptions = {}) { if (!config.apiKey) { @@ -58,25 +61,33 @@ export class TelnyxProvider implements VoiceCallProvider { body: Record, options?: { allowNotFound?: boolean }, ): Promise { - const response = await fetch(`${this.baseUrl}${endpoint}`, { - method: "POST", - headers: { - Authorization: `Bearer ${this.apiKey}`, - "Content-Type": "application/json", + const { response, release } = await fetchWithSsrFGuard({ + url: `${this.baseUrl}${endpoint}`, + init: { + method: "POST", + headers: { + Authorization: `Bearer ${this.apiKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify(body), }, - body: JSON.stringify(body), + policy: { allowedHostnames: [this.apiHost] }, + auditContext: "voice-call.telnyx.api", }); - - if (!response.ok) { - if (options?.allowNotFound && response.status === 404) { - return undefined as T; + try { + if (!response.ok) { + if (options?.allowNotFound && response.status === 404) { + return undefined as T; + } + const errorText = await response.text(); + throw new Error(`Telnyx API error: ${response.status} ${errorText}`); } - const errorText = await response.text(); - throw new Error(`Telnyx API error: ${response.status} ${errorText}`); - } - const text = await response.text(); - return text ? (JSON.parse(text) as T) : (undefined as T); + const text = await response.text(); + return text ? (JSON.parse(text) as T) : (undefined as T); + } finally { + await release(); + } } /** @@ -87,13 +98,21 @@ export class TelnyxProvider implements VoiceCallProvider { skipVerification: this.options.skipVerification, }); - return { ok: result.ok, reason: result.reason, isReplay: result.isReplay }; + return { + ok: result.ok, + reason: result.reason, + isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, + }; } /** * Parse Telnyx webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const payload = JSON.parse(ctx.rawBody); const data = payload.data; @@ -102,7 +121,7 @@ export class TelnyxProvider implements VoiceCallProvider { return { events: [], statusCode: 200 }; } - const event = this.normalizeEvent(data); + const event = this.normalizeEvent(data, options?.verifiedRequestKey); return { events: event ? [event] : [], statusCode: 200, @@ -115,7 +134,7 @@ export class TelnyxProvider implements VoiceCallProvider { /** * Convert Telnyx event to normalized event format. */ - private normalizeEvent(data: TelnyxEvent): NormalizedEvent | null { + private normalizeEvent(data: TelnyxEvent, dedupeKey?: string): NormalizedEvent | null { // Decode client_state from Base64 (we encode it in initiateCall) let callId = ""; if (data.payload?.client_state) { @@ -132,6 +151,7 @@ export class TelnyxProvider implements VoiceCallProvider { const baseEvent = { id: data.id || crypto.randomUUID(), + dedupeKey, callId, providerCallId: data.payload?.call_control_id, timestamp: Date.now(), diff --git a/extensions/voice-call/src/providers/twilio.test.ts b/extensions/voice-call/src/providers/twilio.test.ts index 0d5c6de03d0..92cbe0fec32 100644 --- a/extensions/voice-call/src/providers/twilio.test.ts +++ b/extensions/voice-call/src/providers/twilio.test.ts @@ -60,7 +60,7 @@ describe("TwilioProvider", () => { expect(result.providerResponseBody).toContain(""); }); - it("uses a stable dedupeKey for identical request payloads", () => { + it("uses a stable fallback dedupeKey for identical request payloads", () => { const provider = createProvider(); const rawBody = "CallSid=CA789&Direction=inbound&SpeechResult=hello"; const ctxA = { @@ -78,10 +78,31 @@ describe("TwilioProvider", () => { expect(eventA).toBeDefined(); expect(eventB).toBeDefined(); expect(eventA?.id).not.toBe(eventB?.id); - expect(eventA?.dedupeKey).toBe("twilio:idempotency:idem-123"); + expect(eventA?.dedupeKey).toContain("twilio:fallback:"); expect(eventA?.dedupeKey).toBe(eventB?.dedupeKey); }); + it("uses verified request key for dedupe and ignores idempotency header changes", () => { + const provider = createProvider(); + const rawBody = "CallSid=CA790&Direction=inbound&SpeechResult=hello"; + const ctxA = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-a" }, + }; + const ctxB = { + ...createContext(rawBody, { callId: "call-1", turnToken: "turn-1" }), + headers: { "i-twilio-idempotency-token": "idem-b" }, + }; + + const eventA = provider.parseWebhookEvent(ctxA, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + const eventB = provider.parseWebhookEvent(ctxB, { verifiedRequestKey: "twilio:req:abc" }) + .events[0]; + + expect(eventA?.dedupeKey).toBe("twilio:req:abc"); + expect(eventB?.dedupeKey).toBe("twilio:req:abc"); + }); + it("keeps turnToken from query on speech events", () => { const provider = createProvider(); const ctx = createContext("CallSid=CA222&Direction=inbound&SpeechResult=hello", { diff --git a/extensions/voice-call/src/providers/twilio.ts b/extensions/voice-call/src/providers/twilio.ts index c1dbf6c7f4f..91862f47769 100644 --- a/extensions/voice-call/src/providers/twilio.ts +++ b/extensions/voice-call/src/providers/twilio.ts @@ -13,6 +13,7 @@ import type { StartListeningInput, StopListeningInput, WebhookContext, + WebhookParseOptions, WebhookVerificationResult, } from "../types.js"; import { escapeXml, mapVoiceToPolly } from "../voice-mapping.js"; @@ -31,19 +32,24 @@ function getHeader( return value; } -function createTwilioRequestDedupeKey(ctx: WebhookContext): string { - const idempotencyToken = getHeader(ctx.headers, "i-twilio-idempotency-token"); - if (idempotencyToken) { - return `twilio:idempotency:${idempotencyToken}`; +function createTwilioRequestDedupeKey(ctx: WebhookContext, verifiedRequestKey?: string): string { + if (verifiedRequestKey) { + return verifiedRequestKey; } const signature = getHeader(ctx.headers, "x-twilio-signature") ?? ""; + const params = new URLSearchParams(ctx.rawBody); + const callSid = params.get("CallSid") ?? ""; + const callStatus = params.get("CallStatus") ?? ""; + const direction = params.get("Direction") ?? ""; const callId = typeof ctx.query?.callId === "string" ? ctx.query.callId.trim() : ""; const flow = typeof ctx.query?.flow === "string" ? ctx.query.flow.trim() : ""; const turnToken = typeof ctx.query?.turnToken === "string" ? ctx.query.turnToken.trim() : ""; return `twilio:fallback:${crypto .createHash("sha256") - .update(`${signature}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`) + .update( + `${signature}\n${callSid}\n${callStatus}\n${direction}\n${callId}\n${flow}\n${turnToken}\n${ctx.rawBody}`, + ) .digest("hex")}`; } @@ -232,7 +238,10 @@ export class TwilioProvider implements VoiceCallProvider { /** * Parse Twilio webhook event into normalized format. */ - parseWebhookEvent(ctx: WebhookContext): ProviderWebhookParseResult { + parseWebhookEvent( + ctx: WebhookContext, + options?: WebhookParseOptions, + ): ProviderWebhookParseResult { try { const params = new URLSearchParams(ctx.rawBody); const callIdFromQuery = @@ -243,7 +252,7 @@ export class TwilioProvider implements VoiceCallProvider { typeof ctx.query?.turnToken === "string" && ctx.query.turnToken.trim() ? ctx.query.turnToken.trim() : undefined; - const dedupeKey = createTwilioRequestDedupeKey(ctx); + const dedupeKey = createTwilioRequestDedupeKey(ctx, options?.verifiedRequestKey); const event = this.normalizeEvent(params, { callIdOverride: callIdFromQuery, dedupeKey, diff --git a/extensions/voice-call/src/providers/twilio/webhook.ts b/extensions/voice-call/src/providers/twilio/webhook.ts index 072e7f4f399..4b38050959b 100644 --- a/extensions/voice-call/src/providers/twilio/webhook.ts +++ b/extensions/voice-call/src/providers/twilio/webhook.ts @@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: { ok: result.ok, reason: result.reason, isReplay: result.isReplay, + verifiedRequestKey: result.verifiedRequestKey, }; } diff --git a/extensions/voice-call/src/types.ts b/extensions/voice-call/src/types.ts index 835b8ad8a1d..6806b7cc728 100644 --- a/extensions/voice-call/src/types.ts +++ b/extensions/voice-call/src/types.ts @@ -177,6 +177,13 @@ export type WebhookVerificationResult = { reason?: string; /** Signature is valid, but request was seen before within replay window. */ isReplay?: boolean; + /** Stable key derived from authenticated request material. */ + verifiedRequestKey?: string; +}; + +export type WebhookParseOptions = { + /** Stable request key from verifyWebhook. */ + verifiedRequestKey?: string; }; export type WebhookContext = { diff --git a/extensions/voice-call/src/webhook-security.test.ts b/extensions/voice-call/src/webhook-security.test.ts index e85838a1383..504c9b09e11 100644 --- a/extensions/voice-call/src/webhook-security.test.ts +++ b/extensions/voice-call/src/webhook-security.test.ts @@ -198,8 +198,10 @@ describe("verifyPlivoWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); }); @@ -229,8 +231,10 @@ describe("verifyTelnyxWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); }); @@ -304,8 +308,58 @@ describe("verifyTwilioWebhook", () => { expect(first.ok).toBe(true); expect(first.isReplay).toBeFalsy(); + expect(first.verifiedRequestKey).toBeTruthy(); expect(second.ok).toBe(true); expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); + }); + + it("treats changed idempotency header as replay for identical signed requests", () => { + const authToken = "test-auth-token"; + const publicUrl = "https://example.com/voice/webhook"; + const urlWithQuery = `${publicUrl}?callId=abc`; + const postBody = "CallSid=CS778&CallStatus=completed&From=%2B15550000000"; + const signature = twilioSignature({ authToken, url: urlWithQuery, postBody }); + + const first = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-a", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + const second = verifyTwilioWebhook( + { + headers: { + host: "example.com", + "x-forwarded-proto": "https", + "x-twilio-signature": signature, + "i-twilio-idempotency-token": "idem-replay-b", + }, + rawBody: postBody, + url: "http://local/voice/webhook?callId=abc", + method: "POST", + query: { callId: "abc" }, + }, + authToken, + { publicUrl }, + ); + + expect(first.ok).toBe(true); + expect(first.isReplay).toBe(false); + expect(first.verifiedRequestKey).toBeTruthy(); + expect(second.ok).toBe(true); + expect(second.isReplay).toBe(true); + expect(second.verifiedRequestKey).toBe(first.verifiedRequestKey); }); it("rejects invalid signatures even when attacker injects forwarded host", () => { diff --git a/extensions/voice-call/src/webhook-security.ts b/extensions/voice-call/src/webhook-security.ts index d190ed8f9ff..60f37e822e6 100644 --- a/extensions/voice-call/src/webhook-security.ts +++ b/extensions/voice-call/src/webhook-security.ts @@ -81,17 +81,7 @@ export function validateTwilioSignature( return false; } - // Build the string to sign: URL + sorted params (key+value pairs) - let dataToSign = url; - - // Sort params alphabetically and append key+value - const sortedParams = Array.from(params.entries()).toSorted((a, b) => - a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, - ); - - for (const [key, value] of sortedParams) { - dataToSign += key + value; - } + const dataToSign = buildTwilioDataToSign(url, params); // HMAC-SHA1 with auth token, then base64 encode const expectedSignature = crypto @@ -103,6 +93,24 @@ export function validateTwilioSignature( return timingSafeEqual(signature, expectedSignature); } +function buildTwilioDataToSign(url: string, params: URLSearchParams): string { + let dataToSign = url; + const sortedParams = Array.from(params.entries()).toSorted((a, b) => + a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0, + ); + for (const [key, value] of sortedParams) { + dataToSign += key + value; + } + return dataToSign; +} + +function buildCanonicalTwilioParamString(params: URLSearchParams): string { + return Array.from(params.entries()) + .toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)) + .map(([key, value]) => `${key}=${value}`) + .join("&"); +} + /** * Timing-safe string comparison to prevent timing attacks. */ @@ -392,6 +400,8 @@ export interface TwilioVerificationResult { isNgrokFreeTier?: boolean; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Twilio material. */ + verifiedRequestKey?: string; } export interface TelnyxVerificationResult { @@ -399,19 +409,18 @@ export interface TelnyxVerificationResult { reason?: string; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Telnyx material. */ + verifiedRequestKey?: string; } function createTwilioReplayKey(params: { - ctx: WebhookContext; - signature: string; verificationUrl: string; + signature: string; + requestParams: URLSearchParams; }): string { - const idempotencyToken = getHeader(params.ctx.headers, "i-twilio-idempotency-token"); - if (idempotencyToken) { - return `twilio:idempotency:${idempotencyToken}`; - } - return `twilio:fallback:${sha256Hex( - `${params.verificationUrl}\n${params.signature}\n${params.ctx.rawBody}`, + const canonicalParams = buildCanonicalTwilioParamString(params.requestParams); + return `twilio:req:${sha256Hex( + `${params.verificationUrl}\n${canonicalParams}\n${params.signature}`, )}`; } @@ -508,7 +517,7 @@ export function verifyTelnyxWebhook( const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${signature}\n${ctx.rawBody}`)}`; const isReplay = markReplay(telnyxReplayCache, replayKey); - return { ok: true, isReplay }; + return { ok: true, isReplay, verifiedRequestKey: replayKey }; } catch (err) { return { ok: false, @@ -583,13 +592,16 @@ export function verifyTwilioWebhook( // Parse the body as URL-encoded params const params = new URLSearchParams(ctx.rawBody); - // Validate signature const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params); if (isValid) { - const replayKey = createTwilioReplayKey({ ctx, signature, verificationUrl }); + const replayKey = createTwilioReplayKey({ + verificationUrl, + signature, + requestParams: params, + }); const isReplay = markReplay(twilioReplayCache, replayKey); - return { ok: true, verificationUrl, isReplay }; + return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey }; } // Check if this is ngrok free tier - the URL might have different format @@ -619,6 +631,8 @@ export interface PlivoVerificationResult { version?: "v3" | "v2"; /** Request is cryptographically valid but was already processed recently. */ isReplay?: boolean; + /** Stable request identity derived from signed Plivo material. */ + verifiedRequestKey?: string; } function normalizeSignatureBase64(input: string): string { @@ -849,7 +863,7 @@ export function verifyPlivoWebhook( } const replayKey = `plivo:v3:${sha256Hex(`${verificationUrl}\n${nonceV3}`)}`; const isReplay = markReplay(plivoReplayCache, replayKey); - return { ok: true, version: "v3", verificationUrl, isReplay }; + return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } if (signatureV2 && nonceV2) { @@ -869,7 +883,7 @@ export function verifyPlivoWebhook( } const replayKey = `plivo:v2:${sha256Hex(`${verificationUrl}\n${nonceV2}`)}`; const isReplay = markReplay(plivoReplayCache, replayKey); - return { ok: true, version: "v2", verificationUrl, isReplay }; + return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey }; } return { diff --git a/extensions/voice-call/src/webhook.test.ts b/extensions/voice-call/src/webhook.test.ts index 8dcf3346342..1efccf629ee 100644 --- a/extensions/voice-call/src/webhook.test.ts +++ b/extensions/voice-call/src/webhook.test.ts @@ -165,4 +165,56 @@ describe("VoiceCallWebhookServer replay handling", () => { await server.stop(); } }); + + it("passes verified request key from verifyWebhook into parseWebhookEvent", async () => { + const parseWebhookEvent = vi.fn((_ctx: unknown, options?: { verifiedRequestKey?: string }) => ({ + events: [ + { + id: "evt-verified", + dedupeKey: options?.verifiedRequestKey, + type: "call.speech" as const, + callId: "call-1", + providerCallId: "provider-call-1", + timestamp: Date.now(), + transcript: "hello", + isFinal: true, + }, + ], + statusCode: 200, + })); + const verifiedProvider: VoiceCallProvider = { + ...provider, + verifyWebhook: () => ({ ok: true, verifiedRequestKey: "verified:req:123" }), + parseWebhookEvent, + }; + const { manager, processEvent } = createManager([]); + const config = createConfig({ serve: { port: 0, bind: "127.0.0.1", path: "/voice/webhook" } }); + const server = new VoiceCallWebhookServer(config, manager, verifiedProvider); + + try { + const baseUrl = await server.start(); + const address = ( + server as unknown as { server?: { address?: () => unknown } } + ).server?.address?.(); + const requestUrl = new URL(baseUrl); + if (address && typeof address === "object" && "port" in address && address.port) { + requestUrl.port = String(address.port); + } + const response = await fetch(requestUrl.toString(), { + method: "POST", + headers: { "content-type": "application/x-www-form-urlencoded" }, + body: "CallSid=CA123&SpeechResult=hello", + }); + + expect(response.status).toBe(200); + expect(parseWebhookEvent).toHaveBeenCalledTimes(1); + expect(parseWebhookEvent.mock.calls[0]?.[1]).toEqual({ + verifiedRequestKey: "verified:req:123", + }); + expect(processEvent).toHaveBeenCalledTimes(1); + expect(processEvent.mock.calls[0]?.[0]?.dedupeKey).toBe("verified:req:123"); + } finally { + await server.stop(); + } + }); }); diff --git a/extensions/voice-call/src/webhook.ts b/extensions/voice-call/src/webhook.ts index 4b778e3a8d7..420faab8126 100644 --- a/extensions/voice-call/src/webhook.ts +++ b/extensions/voice-call/src/webhook.ts @@ -343,7 +343,9 @@ export class VoiceCallWebhookServer { } // Parse events - const result = this.provider.parseWebhookEvent(ctx); + const result = this.provider.parseWebhookEvent(ctx, { + verifiedRequestKey: verification.verifiedRequestKey, + }); // Process each event if (verification.isReplay) {