fix(voice-call): bind webhook dedupe to verified request identity

This commit is contained in:
Peter Steinberger
2026-02-26 21:43:42 +01:00
parent 5a453eacbd
commit 1aadf26f9a
15 changed files with 329 additions and 74 deletions

View File

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

View File

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

View File

@@ -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[] = [];

View File

@@ -24,4 +24,26 @@ describe("PlivoProvider", () => {
expect(result.providerResponseBody).toContain("<Wait");
expect(result.providerResponseBody).toContain('length="300"');
});
it("uses verified request key when provided", () => {
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");
});
});

View File

@@ -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<string, string>();
@@ -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<T> {
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 {

View File

@@ -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");
});
});

View File

@@ -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<string, unknown>,
options?: { allowNotFound?: boolean },
): Promise<T> {
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(),

View File

@@ -60,7 +60,7 @@ describe("TwilioProvider", () => {
expect(result.providerResponseBody).toContain("<Connect>");
});
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", {

View File

@@ -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,

View File

@@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: {
ok: result.ok,
reason: result.reason,
isReplay: result.isReplay,
verifiedRequestKey: result.verifiedRequestKey,
};
}

View File

@@ -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 = {

View File

@@ -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", () => {

View File

@@ -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 {

View File

@@ -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();
}
});
});

View File

@@ -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) {