mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-12 07:20:45 +00:00
fix(voice-call): bind webhook dedupe to verified request identity
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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[] = [];
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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", {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -29,5 +29,6 @@ export function verifyTwilioProviderWebhook(params: {
|
||||
ok: result.ok,
|
||||
reason: result.reason,
|
||||
isReplay: result.isReplay,
|
||||
verifiedRequestKey: result.verifiedRequestKey,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user