@openclaw/voice-call 2026.5.2 → 2026.5.3-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/api.js +2 -0
- package/dist/call-status-CXldV5o8.js +32 -0
- package/dist/cli-metadata.js +12 -0
- package/dist/config-7w04YpHh.js +548 -0
- package/dist/config-compat-B0me39_4.js +129 -0
- package/dist/guarded-json-api-Btx5EE4w.js +591 -0
- package/dist/http-headers-BrnxBasF.js +10 -0
- package/dist/index.js +1284 -0
- package/dist/mock-CeKvfVEd.js +135 -0
- package/dist/plivo-B-a7KFoT.js +393 -0
- package/dist/realtime-handler-B63CIDP2.js +325 -0
- package/dist/realtime-transcription.runtime-B2h70y2W.js +2 -0
- package/dist/realtime-voice.runtime-Bkh4nvLn.js +2 -0
- package/dist/response-generator-BrcmwDZU.js +182 -0
- package/dist/response-model-CyF5K80p.js +12 -0
- package/dist/runtime-api.js +6 -0
- package/dist/runtime-entry-88ytYAQa.js +3119 -0
- package/dist/runtime-entry.js +2 -0
- package/dist/setup-api.js +37 -0
- package/dist/telnyx-jjBE8boz.js +260 -0
- package/dist/twilio-1OqbcXLL.js +676 -0
- package/dist/voice-mapping-BYDGdWGx.js +40 -0
- package/package.json +14 -6
- package/api.ts +0 -16
- package/cli-metadata.ts +0 -10
- package/config-api.ts +0 -12
- package/index.test.ts +0 -943
- package/index.ts +0 -794
- package/runtime-api.ts +0 -20
- package/runtime-entry.ts +0 -1
- package/setup-api.ts +0 -47
- package/src/allowlist.test.ts +0 -18
- package/src/allowlist.ts +0 -19
- package/src/cli.ts +0 -845
- package/src/config-compat.test.ts +0 -120
- package/src/config-compat.ts +0 -227
- package/src/config.test.ts +0 -479
- package/src/config.ts +0 -808
- package/src/core-bridge.ts +0 -14
- package/src/deep-merge.test.ts +0 -40
- package/src/deep-merge.ts +0 -23
- package/src/gateway-continue-operation.ts +0 -200
- package/src/http-headers.test.ts +0 -16
- package/src/http-headers.ts +0 -15
- package/src/manager/context.ts +0 -42
- package/src/manager/events.test.ts +0 -581
- package/src/manager/events.ts +0 -288
- package/src/manager/lifecycle.ts +0 -53
- package/src/manager/lookup.test.ts +0 -52
- package/src/manager/lookup.ts +0 -35
- package/src/manager/outbound.test.ts +0 -528
- package/src/manager/outbound.ts +0 -486
- package/src/manager/state.ts +0 -48
- package/src/manager/store.ts +0 -106
- package/src/manager/timers.test.ts +0 -129
- package/src/manager/timers.ts +0 -113
- package/src/manager/twiml.test.ts +0 -13
- package/src/manager/twiml.ts +0 -17
- package/src/manager.closed-loop.test.ts +0 -236
- package/src/manager.inbound-allowlist.test.ts +0 -188
- package/src/manager.notify.test.ts +0 -377
- package/src/manager.restore.test.ts +0 -183
- package/src/manager.test-harness.ts +0 -127
- package/src/manager.ts +0 -392
- package/src/media-stream.test.ts +0 -768
- package/src/media-stream.ts +0 -708
- package/src/providers/base.ts +0 -97
- package/src/providers/mock.test.ts +0 -78
- package/src/providers/mock.ts +0 -185
- package/src/providers/plivo.test.ts +0 -93
- package/src/providers/plivo.ts +0 -601
- package/src/providers/shared/call-status.test.ts +0 -24
- package/src/providers/shared/call-status.ts +0 -24
- package/src/providers/shared/guarded-json-api.test.ts +0 -106
- package/src/providers/shared/guarded-json-api.ts +0 -42
- package/src/providers/telnyx.test.ts +0 -340
- package/src/providers/telnyx.ts +0 -394
- package/src/providers/twilio/api.test.ts +0 -145
- package/src/providers/twilio/api.ts +0 -93
- package/src/providers/twilio/twiml-policy.test.ts +0 -84
- package/src/providers/twilio/twiml-policy.ts +0 -87
- package/src/providers/twilio/webhook.ts +0 -34
- package/src/providers/twilio.test.ts +0 -591
- package/src/providers/twilio.ts +0 -861
- package/src/providers/twilio.types.ts +0 -17
- package/src/realtime-defaults.ts +0 -3
- package/src/realtime-fast-context.test.ts +0 -88
- package/src/realtime-fast-context.ts +0 -165
- package/src/realtime-transcription.runtime.ts +0 -4
- package/src/realtime-voice.runtime.ts +0 -5
- package/src/response-generator.test.ts +0 -321
- package/src/response-generator.ts +0 -318
- package/src/response-model.test.ts +0 -71
- package/src/response-model.ts +0 -23
- package/src/runtime.test.ts +0 -536
- package/src/runtime.ts +0 -510
- package/src/telephony-audio.test.ts +0 -61
- package/src/telephony-audio.ts +0 -12
- package/src/telephony-tts.test.ts +0 -196
- package/src/telephony-tts.ts +0 -235
- package/src/test-fixtures.ts +0 -73
- package/src/tts-provider-voice.test.ts +0 -34
- package/src/tts-provider-voice.ts +0 -21
- package/src/tunnel.test.ts +0 -166
- package/src/tunnel.ts +0 -314
- package/src/types.ts +0 -291
- package/src/utils.test.ts +0 -17
- package/src/utils.ts +0 -14
- package/src/voice-mapping.test.ts +0 -34
- package/src/voice-mapping.ts +0 -68
- package/src/webhook/realtime-handler.test.ts +0 -598
- package/src/webhook/realtime-handler.ts +0 -485
- package/src/webhook/stale-call-reaper.test.ts +0 -88
- package/src/webhook/stale-call-reaper.ts +0 -38
- package/src/webhook/tailscale.test.ts +0 -214
- package/src/webhook/tailscale.ts +0 -129
- package/src/webhook-exposure.test.ts +0 -33
- package/src/webhook-exposure.ts +0 -84
- package/src/webhook-security.test.ts +0 -770
- package/src/webhook-security.ts +0 -994
- package/src/webhook.hangup-once.lifecycle.test.ts +0 -135
- package/src/webhook.test.ts +0 -1470
- package/src/webhook.ts +0 -908
- package/src/webhook.types.ts +0 -5
- package/src/websocket-test-support.ts +0 -72
- package/tsconfig.json +0 -16
package/src/webhook-security.ts
DELETED
|
@@ -1,994 +0,0 @@
|
|
|
1
|
-
import crypto from "node:crypto";
|
|
2
|
-
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
|
3
|
-
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
|
|
4
|
-
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
|
5
|
-
import { getHeader } from "./http-headers.js";
|
|
6
|
-
import type { WebhookContext } from "./types.js";
|
|
7
|
-
|
|
8
|
-
const REPLAY_WINDOW_MS = 10 * 60 * 1000;
|
|
9
|
-
const REPLAY_CACHE_MAX_ENTRIES = 10_000;
|
|
10
|
-
const REPLAY_CACHE_PRUNE_INTERVAL = 64;
|
|
11
|
-
|
|
12
|
-
type ReplayCache = {
|
|
13
|
-
seenUntil: Map<string, number>;
|
|
14
|
-
calls: number;
|
|
15
|
-
};
|
|
16
|
-
|
|
17
|
-
const twilioReplayCache: ReplayCache = {
|
|
18
|
-
seenUntil: new Map<string, number>(),
|
|
19
|
-
calls: 0,
|
|
20
|
-
};
|
|
21
|
-
|
|
22
|
-
const plivoReplayCache: ReplayCache = {
|
|
23
|
-
seenUntil: new Map<string, number>(),
|
|
24
|
-
calls: 0,
|
|
25
|
-
};
|
|
26
|
-
|
|
27
|
-
const telnyxReplayCache: ReplayCache = {
|
|
28
|
-
seenUntil: new Map<string, number>(),
|
|
29
|
-
calls: 0,
|
|
30
|
-
};
|
|
31
|
-
|
|
32
|
-
function sha256Hex(input: string): string {
|
|
33
|
-
return crypto.createHash("sha256").update(input).digest("hex");
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function createSkippedVerificationReplayKey(provider: string, ctx: WebhookContext): string {
|
|
37
|
-
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
function pruneReplayCache(cache: ReplayCache, now: number): void {
|
|
41
|
-
for (const [key, expiresAt] of cache.seenUntil) {
|
|
42
|
-
if (expiresAt <= now) {
|
|
43
|
-
cache.seenUntil.delete(key);
|
|
44
|
-
}
|
|
45
|
-
}
|
|
46
|
-
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
47
|
-
const oldest = cache.seenUntil.keys().next().value;
|
|
48
|
-
if (!oldest) {
|
|
49
|
-
break;
|
|
50
|
-
}
|
|
51
|
-
cache.seenUntil.delete(oldest);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
function markReplay(cache: ReplayCache, replayKey: string): boolean {
|
|
56
|
-
const now = Date.now();
|
|
57
|
-
cache.calls += 1;
|
|
58
|
-
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) {
|
|
59
|
-
pruneReplayCache(cache, now);
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
const existing = cache.seenUntil.get(replayKey);
|
|
63
|
-
if (existing && existing > now) {
|
|
64
|
-
return true;
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
cache.seenUntil.set(replayKey, now + REPLAY_WINDOW_MS);
|
|
68
|
-
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
|
|
69
|
-
pruneReplayCache(cache, now);
|
|
70
|
-
}
|
|
71
|
-
return false;
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
/**
|
|
75
|
-
* Validate Twilio webhook signature using HMAC-SHA1.
|
|
76
|
-
*
|
|
77
|
-
* Twilio signs requests by concatenating the URL with sorted POST params,
|
|
78
|
-
* then computing HMAC-SHA1 with the auth token.
|
|
79
|
-
*
|
|
80
|
-
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
|
|
81
|
-
*/
|
|
82
|
-
function validateTwilioSignature(
|
|
83
|
-
authToken: string,
|
|
84
|
-
signature: string | undefined,
|
|
85
|
-
url: string,
|
|
86
|
-
params: URLSearchParams,
|
|
87
|
-
): boolean {
|
|
88
|
-
if (!signature) {
|
|
89
|
-
return false;
|
|
90
|
-
}
|
|
91
|
-
|
|
92
|
-
const dataToSign = buildTwilioDataToSign(url, params);
|
|
93
|
-
|
|
94
|
-
// HMAC-SHA1 with auth token, then base64 encode
|
|
95
|
-
const expectedSignature = crypto
|
|
96
|
-
.createHmac("sha1", authToken)
|
|
97
|
-
.update(dataToSign)
|
|
98
|
-
.digest("base64");
|
|
99
|
-
|
|
100
|
-
// Use timing-safe comparison to prevent timing attacks
|
|
101
|
-
return timingSafeEqual(signature, expectedSignature);
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
function buildTwilioDataToSign(url: string, params: URLSearchParams): string {
|
|
105
|
-
let dataToSign = url;
|
|
106
|
-
const sortedParams = Array.from(params.entries()).toSorted((a, b) =>
|
|
107
|
-
a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0,
|
|
108
|
-
);
|
|
109
|
-
for (const [key, value] of sortedParams) {
|
|
110
|
-
dataToSign += key + value;
|
|
111
|
-
}
|
|
112
|
-
return dataToSign;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function buildCanonicalTwilioParamString(params: URLSearchParams): string {
|
|
116
|
-
return Array.from(params.entries())
|
|
117
|
-
.toSorted((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0))
|
|
118
|
-
.map(([key, value]) => `${key}=${value}`)
|
|
119
|
-
.join("&");
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
/**
|
|
123
|
-
* Timing-safe string comparison to prevent timing attacks.
|
|
124
|
-
*/
|
|
125
|
-
function timingSafeEqual(a: string, b: string): boolean {
|
|
126
|
-
return safeEqualSecret(a, b);
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Configuration for secure URL reconstruction.
|
|
131
|
-
*/
|
|
132
|
-
interface WebhookUrlOptions {
|
|
133
|
-
/**
|
|
134
|
-
* Whitelist of allowed hostnames. If provided, only these hosts will be
|
|
135
|
-
* accepted from forwarding headers. This prevents host header injection attacks.
|
|
136
|
-
*
|
|
137
|
-
* SECURITY: You must provide this OR set trustForwardingHeaders=true to use
|
|
138
|
-
* X-Forwarded-Host headers. Without either, forwarding headers are ignored.
|
|
139
|
-
*/
|
|
140
|
-
allowedHosts?: string[];
|
|
141
|
-
/**
|
|
142
|
-
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
|
143
|
-
* WARNING: Only set this to true if you trust your proxy configuration
|
|
144
|
-
* and understand the security implications.
|
|
145
|
-
*
|
|
146
|
-
* @default false
|
|
147
|
-
*/
|
|
148
|
-
trustForwardingHeaders?: boolean;
|
|
149
|
-
/**
|
|
150
|
-
* List of trusted proxy IP addresses. X-Forwarded-* headers will only be
|
|
151
|
-
* trusted if the request comes from one of these IPs.
|
|
152
|
-
* Requires remoteIP to be set for validation.
|
|
153
|
-
*/
|
|
154
|
-
trustedProxyIPs?: string[];
|
|
155
|
-
/**
|
|
156
|
-
* The IP address of the incoming request (for proxy validation).
|
|
157
|
-
*/
|
|
158
|
-
remoteIP?: string;
|
|
159
|
-
}
|
|
160
|
-
|
|
161
|
-
/**
|
|
162
|
-
* Validate that a hostname matches RFC 1123 format.
|
|
163
|
-
* Prevents injection of malformed hostnames.
|
|
164
|
-
*/
|
|
165
|
-
function isValidHostname(hostname: string): boolean {
|
|
166
|
-
if (!hostname || hostname.length > 253) {
|
|
167
|
-
return false;
|
|
168
|
-
}
|
|
169
|
-
// RFC 1123 hostname: alphanumeric, hyphens, dots
|
|
170
|
-
// Also allow ngrok/tunnel subdomains
|
|
171
|
-
const hostnameRegex =
|
|
172
|
-
/^([a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?\.)*[a-zA-Z0-9]([a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/;
|
|
173
|
-
return hostnameRegex.test(hostname);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
/**
|
|
177
|
-
* Safely extract hostname from a host header value.
|
|
178
|
-
* Handles IPv6 addresses and prevents injection via malformed values.
|
|
179
|
-
*/
|
|
180
|
-
function extractHostname(hostHeader: string): string | null {
|
|
181
|
-
if (!hostHeader) {
|
|
182
|
-
return null;
|
|
183
|
-
}
|
|
184
|
-
|
|
185
|
-
let hostname: string;
|
|
186
|
-
|
|
187
|
-
// Handle IPv6 addresses: [::1]:8080
|
|
188
|
-
if (hostHeader.startsWith("[")) {
|
|
189
|
-
const endBracket = hostHeader.indexOf("]");
|
|
190
|
-
if (endBracket === -1) {
|
|
191
|
-
return null; // Malformed IPv6
|
|
192
|
-
}
|
|
193
|
-
hostname = hostHeader.slice(1, endBracket);
|
|
194
|
-
return normalizeLowercaseStringOrEmpty(hostname);
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
// Handle IPv4/domain with optional port
|
|
198
|
-
// Check for @ which could indicate user info injection attempt
|
|
199
|
-
if (hostHeader.includes("@")) {
|
|
200
|
-
return null; // Reject potential injection: attacker.com:80@legitimate.com
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
hostname = hostHeader.split(":")[0];
|
|
204
|
-
|
|
205
|
-
// Validate the extracted hostname
|
|
206
|
-
if (!isValidHostname(hostname)) {
|
|
207
|
-
return null;
|
|
208
|
-
}
|
|
209
|
-
|
|
210
|
-
return normalizeLowercaseStringOrEmpty(hostname);
|
|
211
|
-
}
|
|
212
|
-
|
|
213
|
-
function extractHostnameFromHeader(headerValue: string): string | null {
|
|
214
|
-
const first = headerValue.split(",")[0]?.trim();
|
|
215
|
-
if (!first) {
|
|
216
|
-
return null;
|
|
217
|
-
}
|
|
218
|
-
return extractHostname(first);
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null {
|
|
222
|
-
if (!allowedHosts || allowedHosts.length === 0) {
|
|
223
|
-
return null;
|
|
224
|
-
}
|
|
225
|
-
const normalized = new Set<string>();
|
|
226
|
-
for (const host of allowedHosts) {
|
|
227
|
-
const extracted = extractHostname(host.trim());
|
|
228
|
-
if (extracted) {
|
|
229
|
-
normalized.add(extracted);
|
|
230
|
-
}
|
|
231
|
-
}
|
|
232
|
-
return normalized.size > 0 ? normalized : null;
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
/**
|
|
236
|
-
* Reconstruct the public webhook URL from request headers.
|
|
237
|
-
*
|
|
238
|
-
* SECURITY: This function validates host headers to prevent host header
|
|
239
|
-
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
|
|
240
|
-
* always provide allowedHosts to whitelist valid hostnames.
|
|
241
|
-
*
|
|
242
|
-
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
|
|
243
|
-
* used by Twilio differs from the local request URL. We use standard
|
|
244
|
-
* forwarding headers to reconstruct it.
|
|
245
|
-
*
|
|
246
|
-
* Priority order:
|
|
247
|
-
* 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
|
|
248
|
-
* 2. X-Original-Host (nginx)
|
|
249
|
-
* 3. Ngrok-Forwarded-Host (ngrok specific)
|
|
250
|
-
* 4. Host header (direct connection)
|
|
251
|
-
*/
|
|
252
|
-
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string {
|
|
253
|
-
const { headers } = ctx;
|
|
254
|
-
|
|
255
|
-
// SECURITY: Only trust forwarding headers if explicitly configured.
|
|
256
|
-
// Either allowedHosts must be set (for whitelist validation) or
|
|
257
|
-
// trustForwardingHeaders must be true (explicit opt-in to trust).
|
|
258
|
-
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
|
|
259
|
-
const hasAllowedHosts = allowedHosts !== null;
|
|
260
|
-
const explicitlyTrusted = options?.trustForwardingHeaders === true;
|
|
261
|
-
|
|
262
|
-
// Also check trusted proxy IPs if configured
|
|
263
|
-
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
|
|
264
|
-
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
|
|
265
|
-
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
|
|
266
|
-
const fromTrustedProxy =
|
|
267
|
-
!hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
|
|
268
|
-
|
|
269
|
-
// Only trust forwarding headers if: (has whitelist OR explicitly trusted) AND from trusted proxy
|
|
270
|
-
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
|
|
271
|
-
|
|
272
|
-
const isAllowedForwardedHost = (host: string): boolean => !allowedHosts || allowedHosts.has(host);
|
|
273
|
-
|
|
274
|
-
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
|
|
275
|
-
let proto = "https";
|
|
276
|
-
if (shouldTrustForwardingHeaders) {
|
|
277
|
-
const forwardedProto = getHeader(headers, "x-forwarded-proto");
|
|
278
|
-
if (forwardedProto === "http" || forwardedProto === "https") {
|
|
279
|
-
proto = forwardedProto;
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
// Determine host - with security validation
|
|
284
|
-
let host: string | null = null;
|
|
285
|
-
|
|
286
|
-
if (shouldTrustForwardingHeaders) {
|
|
287
|
-
// Try forwarding headers in priority order
|
|
288
|
-
const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
|
|
289
|
-
|
|
290
|
-
for (const headerName of forwardingHeaders) {
|
|
291
|
-
const headerValue = getHeader(headers, headerName);
|
|
292
|
-
if (headerValue) {
|
|
293
|
-
const extracted = extractHostnameFromHeader(headerValue);
|
|
294
|
-
if (extracted && isAllowedForwardedHost(extracted)) {
|
|
295
|
-
host = extracted;
|
|
296
|
-
break;
|
|
297
|
-
}
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
// Fallback to Host header if no valid forwarding header found
|
|
303
|
-
if (!host) {
|
|
304
|
-
const hostHeader = getHeader(headers, "host");
|
|
305
|
-
if (hostHeader) {
|
|
306
|
-
const extracted = extractHostnameFromHeader(hostHeader);
|
|
307
|
-
if (extracted) {
|
|
308
|
-
host = extracted;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// Last resort: try to extract from ctx.url
|
|
314
|
-
if (!host) {
|
|
315
|
-
try {
|
|
316
|
-
const parsed = new URL(ctx.url);
|
|
317
|
-
const extracted = extractHostname(parsed.host);
|
|
318
|
-
if (extracted) {
|
|
319
|
-
host = extracted;
|
|
320
|
-
}
|
|
321
|
-
} catch {
|
|
322
|
-
// URL parsing failed - use empty string (will result in invalid URL)
|
|
323
|
-
host = "";
|
|
324
|
-
}
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (!host) {
|
|
328
|
-
host = "";
|
|
329
|
-
}
|
|
330
|
-
|
|
331
|
-
// Extract path from the context URL (fallback to "/" on parse failure)
|
|
332
|
-
let path = "/";
|
|
333
|
-
try {
|
|
334
|
-
const parsed = new URL(ctx.url);
|
|
335
|
-
path = parsed.pathname + parsed.search;
|
|
336
|
-
} catch {
|
|
337
|
-
// URL parsing failed
|
|
338
|
-
}
|
|
339
|
-
|
|
340
|
-
return `${proto}://${host}${path}`;
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
function buildTwilioVerificationUrl(
|
|
344
|
-
ctx: WebhookContext,
|
|
345
|
-
publicUrl?: string,
|
|
346
|
-
urlOptions?: WebhookUrlOptions,
|
|
347
|
-
): string {
|
|
348
|
-
if (!publicUrl) {
|
|
349
|
-
return reconstructWebhookUrl(ctx, urlOptions);
|
|
350
|
-
}
|
|
351
|
-
|
|
352
|
-
try {
|
|
353
|
-
const base = new URL(publicUrl);
|
|
354
|
-
const requestUrl = new URL(ctx.url);
|
|
355
|
-
base.pathname = requestUrl.pathname;
|
|
356
|
-
base.search = requestUrl.search;
|
|
357
|
-
return base.toString();
|
|
358
|
-
} catch {
|
|
359
|
-
return publicUrl;
|
|
360
|
-
}
|
|
361
|
-
}
|
|
362
|
-
|
|
363
|
-
function isLoopbackAddress(address?: string): boolean {
|
|
364
|
-
if (!address) {
|
|
365
|
-
return false;
|
|
366
|
-
}
|
|
367
|
-
if (address === "127.0.0.1" || address === "::1") {
|
|
368
|
-
return true;
|
|
369
|
-
}
|
|
370
|
-
if (address.startsWith("::ffff:127.")) {
|
|
371
|
-
return true;
|
|
372
|
-
}
|
|
373
|
-
return false;
|
|
374
|
-
}
|
|
375
|
-
|
|
376
|
-
function stripPortFromUrl(url: string): string {
|
|
377
|
-
try {
|
|
378
|
-
const parsed = new URL(url);
|
|
379
|
-
if (!parsed.port) {
|
|
380
|
-
return url;
|
|
381
|
-
}
|
|
382
|
-
parsed.port = "";
|
|
383
|
-
return parsed.toString();
|
|
384
|
-
} catch {
|
|
385
|
-
return url;
|
|
386
|
-
}
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
function setPortOnUrl(url: string, port: string): string {
|
|
390
|
-
try {
|
|
391
|
-
const parsed = new URL(url);
|
|
392
|
-
parsed.port = port;
|
|
393
|
-
return parsed.toString();
|
|
394
|
-
} catch {
|
|
395
|
-
return url;
|
|
396
|
-
}
|
|
397
|
-
}
|
|
398
|
-
|
|
399
|
-
function extractPortFromHostHeader(hostHeader?: string): string | undefined {
|
|
400
|
-
if (!hostHeader) {
|
|
401
|
-
return undefined;
|
|
402
|
-
}
|
|
403
|
-
try {
|
|
404
|
-
const parsed = new URL(`https://${hostHeader}`);
|
|
405
|
-
return parsed.port || undefined;
|
|
406
|
-
} catch {
|
|
407
|
-
return undefined;
|
|
408
|
-
}
|
|
409
|
-
}
|
|
410
|
-
|
|
411
|
-
/**
|
|
412
|
-
* Result of Twilio webhook verification with detailed info.
|
|
413
|
-
*/
|
|
414
|
-
interface TwilioVerificationResult {
|
|
415
|
-
ok: boolean;
|
|
416
|
-
reason?: string;
|
|
417
|
-
/** The URL that was used for verification (for debugging) */
|
|
418
|
-
verificationUrl?: string;
|
|
419
|
-
/** Whether we're running behind ngrok free tier */
|
|
420
|
-
isNgrokFreeTier?: boolean;
|
|
421
|
-
/** Request is cryptographically valid but was already processed recently. */
|
|
422
|
-
isReplay?: boolean;
|
|
423
|
-
/** Stable request identity derived from signed Twilio material. */
|
|
424
|
-
verifiedRequestKey?: string;
|
|
425
|
-
}
|
|
426
|
-
|
|
427
|
-
interface TelnyxVerificationResult {
|
|
428
|
-
ok: boolean;
|
|
429
|
-
reason?: string;
|
|
430
|
-
/** Request is cryptographically valid but was already processed recently. */
|
|
431
|
-
isReplay?: boolean;
|
|
432
|
-
/** Stable request identity derived from signed Telnyx material. */
|
|
433
|
-
verifiedRequestKey?: string;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
|
-
function createTwilioReplayKey(params: {
|
|
437
|
-
verificationUrl: string;
|
|
438
|
-
signature: string;
|
|
439
|
-
requestParams: URLSearchParams;
|
|
440
|
-
}): string {
|
|
441
|
-
const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
|
|
442
|
-
return `twilio:req:${sha256Hex(
|
|
443
|
-
`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`,
|
|
444
|
-
)}`;
|
|
445
|
-
}
|
|
446
|
-
|
|
447
|
-
function decodeBase64OrBase64Url(input: string): Buffer {
|
|
448
|
-
// Telnyx docs say Base64; some tooling emits Base64URL. Accept both.
|
|
449
|
-
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
|
|
450
|
-
const padLen = (4 - (normalized.length % 4)) % 4;
|
|
451
|
-
const padded = normalized + "=".repeat(padLen);
|
|
452
|
-
return Buffer.from(padded, "base64");
|
|
453
|
-
}
|
|
454
|
-
|
|
455
|
-
function base64UrlEncode(buf: Buffer): string {
|
|
456
|
-
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
|
|
457
|
-
}
|
|
458
|
-
|
|
459
|
-
function importEd25519PublicKey(publicKey: string): crypto.KeyObject | string {
|
|
460
|
-
const trimmed = publicKey.trim();
|
|
461
|
-
|
|
462
|
-
// PEM (spki) support.
|
|
463
|
-
if (trimmed.startsWith("-----BEGIN")) {
|
|
464
|
-
return trimmed;
|
|
465
|
-
}
|
|
466
|
-
|
|
467
|
-
// Base64-encoded raw Ed25519 key (32 bytes) or Base64-encoded DER SPKI key.
|
|
468
|
-
const decoded = decodeBase64OrBase64Url(trimmed);
|
|
469
|
-
if (decoded.length === 32) {
|
|
470
|
-
// JWK is the easiest portable way to import raw Ed25519 keys in Node crypto.
|
|
471
|
-
return crypto.createPublicKey({
|
|
472
|
-
key: { kty: "OKP", crv: "Ed25519", x: base64UrlEncode(decoded) },
|
|
473
|
-
format: "jwk",
|
|
474
|
-
});
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
return crypto.createPublicKey({
|
|
478
|
-
key: decoded,
|
|
479
|
-
format: "der",
|
|
480
|
-
type: "spki",
|
|
481
|
-
});
|
|
482
|
-
}
|
|
483
|
-
|
|
484
|
-
/**
|
|
485
|
-
* Verify Telnyx webhook signature using Ed25519.
|
|
486
|
-
*
|
|
487
|
-
* Telnyx signs `timestamp|payload` and provides:
|
|
488
|
-
* - `telnyx-signature-ed25519` (Base64 signature)
|
|
489
|
-
* - `telnyx-timestamp` (Unix seconds)
|
|
490
|
-
*/
|
|
491
|
-
export function verifyTelnyxWebhook(
|
|
492
|
-
ctx: WebhookContext,
|
|
493
|
-
publicKey: string | undefined,
|
|
494
|
-
options?: {
|
|
495
|
-
/** Skip verification entirely (only for development) */
|
|
496
|
-
skipVerification?: boolean;
|
|
497
|
-
/** Maximum allowed clock skew (ms). Defaults to 5 minutes. */
|
|
498
|
-
maxSkewMs?: number;
|
|
499
|
-
},
|
|
500
|
-
): TelnyxVerificationResult {
|
|
501
|
-
if (options?.skipVerification) {
|
|
502
|
-
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
|
|
503
|
-
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
|
504
|
-
return {
|
|
505
|
-
ok: true,
|
|
506
|
-
reason: "verification skipped (dev mode)",
|
|
507
|
-
isReplay,
|
|
508
|
-
verifiedRequestKey: replayKey,
|
|
509
|
-
};
|
|
510
|
-
}
|
|
511
|
-
|
|
512
|
-
if (!publicKey) {
|
|
513
|
-
return { ok: false, reason: "Missing telnyx.publicKey (configure to verify webhooks)" };
|
|
514
|
-
}
|
|
515
|
-
|
|
516
|
-
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
|
|
517
|
-
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
|
|
518
|
-
|
|
519
|
-
if (!signature || !timestamp) {
|
|
520
|
-
return { ok: false, reason: "Missing signature or timestamp header" };
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
const eventTimeSec = Number.parseInt(timestamp, 10);
|
|
524
|
-
if (!Number.isFinite(eventTimeSec)) {
|
|
525
|
-
return { ok: false, reason: "Invalid timestamp header" };
|
|
526
|
-
}
|
|
527
|
-
|
|
528
|
-
try {
|
|
529
|
-
const signedPayload = `${timestamp}|${ctx.rawBody}`;
|
|
530
|
-
const signatureBuffer = decodeBase64OrBase64Url(signature);
|
|
531
|
-
// Canonicalize equivalent Base64/Base64URL encodings before replay hashing.
|
|
532
|
-
const canonicalSignature = signatureBuffer.toString("base64");
|
|
533
|
-
const key = importEd25519PublicKey(publicKey);
|
|
534
|
-
|
|
535
|
-
const isValid = crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer);
|
|
536
|
-
if (!isValid) {
|
|
537
|
-
return { ok: false, reason: "Invalid signature" };
|
|
538
|
-
}
|
|
539
|
-
|
|
540
|
-
const maxSkewMs = options?.maxSkewMs ?? 5 * 60 * 1000;
|
|
541
|
-
const eventTimeMs = eventTimeSec * 1000;
|
|
542
|
-
const now = Date.now();
|
|
543
|
-
if (Math.abs(now - eventTimeMs) > maxSkewMs) {
|
|
544
|
-
return { ok: false, reason: "Timestamp too old" };
|
|
545
|
-
}
|
|
546
|
-
|
|
547
|
-
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
|
|
548
|
-
const isReplay = markReplay(telnyxReplayCache, replayKey);
|
|
549
|
-
return { ok: true, isReplay, verifiedRequestKey: replayKey };
|
|
550
|
-
} catch (err) {
|
|
551
|
-
return {
|
|
552
|
-
ok: false,
|
|
553
|
-
reason: `Verification error: ${formatErrorMessage(err)}`,
|
|
554
|
-
};
|
|
555
|
-
}
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
/**
|
|
559
|
-
* Verify Twilio webhook with full context and detailed result.
|
|
560
|
-
*/
|
|
561
|
-
export function verifyTwilioWebhook(
|
|
562
|
-
ctx: WebhookContext,
|
|
563
|
-
authToken: string,
|
|
564
|
-
options?: {
|
|
565
|
-
/** Override the public URL (e.g., from config) */
|
|
566
|
-
publicUrl?: string;
|
|
567
|
-
/**
|
|
568
|
-
* Allow ngrok free tier compatibility mode (loopback only).
|
|
569
|
-
*
|
|
570
|
-
* IMPORTANT: This does NOT bypass signature verification.
|
|
571
|
-
* It only enables trusting forwarded headers on loopback so we can
|
|
572
|
-
* reconstruct the public ngrok URL that Twilio used for signing.
|
|
573
|
-
*/
|
|
574
|
-
allowNgrokFreeTierLoopbackBypass?: boolean;
|
|
575
|
-
/** Skip verification entirely (only for development) */
|
|
576
|
-
skipVerification?: boolean;
|
|
577
|
-
/**
|
|
578
|
-
* Whitelist of allowed hostnames for host header validation.
|
|
579
|
-
* Prevents host header injection attacks.
|
|
580
|
-
*/
|
|
581
|
-
allowedHosts?: string[];
|
|
582
|
-
/**
|
|
583
|
-
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
|
584
|
-
* WARNING: Only enable if you trust your proxy configuration.
|
|
585
|
-
* @default false
|
|
586
|
-
*/
|
|
587
|
-
trustForwardingHeaders?: boolean;
|
|
588
|
-
/**
|
|
589
|
-
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
|
|
590
|
-
* be trusted from these IPs.
|
|
591
|
-
*/
|
|
592
|
-
trustedProxyIPs?: string[];
|
|
593
|
-
/**
|
|
594
|
-
* The remote IP address of the request (for proxy validation).
|
|
595
|
-
*/
|
|
596
|
-
remoteIP?: string;
|
|
597
|
-
},
|
|
598
|
-
): TwilioVerificationResult {
|
|
599
|
-
// Allow skipping verification for development/testing
|
|
600
|
-
if (options?.skipVerification) {
|
|
601
|
-
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
|
|
602
|
-
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
603
|
-
return {
|
|
604
|
-
ok: true,
|
|
605
|
-
reason: "verification skipped (dev mode)",
|
|
606
|
-
isReplay,
|
|
607
|
-
verifiedRequestKey: replayKey,
|
|
608
|
-
};
|
|
609
|
-
}
|
|
610
|
-
|
|
611
|
-
const signature = getHeader(ctx.headers, "x-twilio-signature");
|
|
612
|
-
|
|
613
|
-
if (!signature) {
|
|
614
|
-
return { ok: false, reason: "Missing X-Twilio-Signature header" };
|
|
615
|
-
}
|
|
616
|
-
|
|
617
|
-
const isLoopback = isLoopbackAddress(options?.remoteIP ?? ctx.remoteAddress);
|
|
618
|
-
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
|
|
619
|
-
|
|
620
|
-
// Reconstruct the URL Twilio used
|
|
621
|
-
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
|
|
622
|
-
allowedHosts: options?.allowedHosts,
|
|
623
|
-
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
|
|
624
|
-
trustedProxyIPs: options?.trustedProxyIPs,
|
|
625
|
-
remoteIP: options?.remoteIP,
|
|
626
|
-
});
|
|
627
|
-
|
|
628
|
-
// Parse the body as URL-encoded params
|
|
629
|
-
const params = new URLSearchParams(ctx.rawBody);
|
|
630
|
-
|
|
631
|
-
const isValid = validateTwilioSignature(authToken, signature, verificationUrl, params);
|
|
632
|
-
|
|
633
|
-
if (isValid) {
|
|
634
|
-
const replayKey = createTwilioReplayKey({
|
|
635
|
-
verificationUrl,
|
|
636
|
-
signature,
|
|
637
|
-
requestParams: params,
|
|
638
|
-
});
|
|
639
|
-
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
640
|
-
return { ok: true, verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
641
|
-
}
|
|
642
|
-
|
|
643
|
-
// Twilio webhook signatures can differ in whether port is included.
|
|
644
|
-
// Retry a small, deterministic set of URL variants before failing closed.
|
|
645
|
-
const variants = new Set<string>();
|
|
646
|
-
variants.add(verificationUrl);
|
|
647
|
-
variants.add(stripPortFromUrl(verificationUrl));
|
|
648
|
-
|
|
649
|
-
if (options?.publicUrl) {
|
|
650
|
-
try {
|
|
651
|
-
const publicPort = new URL(options.publicUrl).port;
|
|
652
|
-
if (publicPort) {
|
|
653
|
-
variants.add(setPortOnUrl(verificationUrl, publicPort));
|
|
654
|
-
}
|
|
655
|
-
} catch {
|
|
656
|
-
// ignore invalid publicUrl; primary verification already used best effort
|
|
657
|
-
}
|
|
658
|
-
}
|
|
659
|
-
|
|
660
|
-
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
|
|
661
|
-
if (hostHeaderPort) {
|
|
662
|
-
variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
|
|
663
|
-
}
|
|
664
|
-
|
|
665
|
-
for (const candidateUrl of variants) {
|
|
666
|
-
if (candidateUrl === verificationUrl) {
|
|
667
|
-
continue;
|
|
668
|
-
}
|
|
669
|
-
const isValidCandidate = validateTwilioSignature(authToken, signature, candidateUrl, params);
|
|
670
|
-
if (!isValidCandidate) {
|
|
671
|
-
continue;
|
|
672
|
-
}
|
|
673
|
-
const replayKey = createTwilioReplayKey({
|
|
674
|
-
verificationUrl: candidateUrl,
|
|
675
|
-
signature,
|
|
676
|
-
requestParams: params,
|
|
677
|
-
});
|
|
678
|
-
const isReplay = markReplay(twilioReplayCache, replayKey);
|
|
679
|
-
return { ok: true, verificationUrl: candidateUrl, isReplay, verifiedRequestKey: replayKey };
|
|
680
|
-
}
|
|
681
|
-
|
|
682
|
-
// Check if this is ngrok free tier - the URL might have different format
|
|
683
|
-
const isNgrokFreeTier =
|
|
684
|
-
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
|
|
685
|
-
|
|
686
|
-
return {
|
|
687
|
-
ok: false,
|
|
688
|
-
reason: `Invalid signature for URL: ${verificationUrl}`,
|
|
689
|
-
verificationUrl,
|
|
690
|
-
isNgrokFreeTier,
|
|
691
|
-
};
|
|
692
|
-
}
|
|
693
|
-
|
|
694
|
-
// -----------------------------------------------------------------------------
|
|
695
|
-
// Plivo webhook verification
|
|
696
|
-
// -----------------------------------------------------------------------------
|
|
697
|
-
|
|
698
|
-
/**
|
|
699
|
-
* Result of Plivo webhook verification with detailed info.
|
|
700
|
-
*/
|
|
701
|
-
interface PlivoVerificationResult {
|
|
702
|
-
ok: boolean;
|
|
703
|
-
reason?: string;
|
|
704
|
-
verificationUrl?: string;
|
|
705
|
-
/** Signature version used for verification */
|
|
706
|
-
version?: "v3" | "v2";
|
|
707
|
-
/** Request is cryptographically valid but was already processed recently. */
|
|
708
|
-
isReplay?: boolean;
|
|
709
|
-
/** Stable request identity derived from signed Plivo material. */
|
|
710
|
-
verifiedRequestKey?: string;
|
|
711
|
-
}
|
|
712
|
-
|
|
713
|
-
function normalizeSignatureBase64(input: string): string {
|
|
714
|
-
// Canonicalize base64 to match Plivo SDK behavior (decode then re-encode).
|
|
715
|
-
return Buffer.from(input, "base64").toString("base64");
|
|
716
|
-
}
|
|
717
|
-
|
|
718
|
-
function getBaseUrlNoQuery(url: string): string {
|
|
719
|
-
const u = new URL(url);
|
|
720
|
-
return `${u.protocol}//${u.host}${u.pathname}`;
|
|
721
|
-
}
|
|
722
|
-
|
|
723
|
-
function createPlivoV2ReplayKey(url: string, nonce: string): string {
|
|
724
|
-
return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
|
|
725
|
-
}
|
|
726
|
-
|
|
727
|
-
function createPlivoV3ReplayKey(params: {
|
|
728
|
-
method: "GET" | "POST";
|
|
729
|
-
url: string;
|
|
730
|
-
postParams: PlivoParamMap;
|
|
731
|
-
nonce: string;
|
|
732
|
-
}): string {
|
|
733
|
-
const baseUrl = constructPlivoV3BaseUrl({
|
|
734
|
-
method: params.method,
|
|
735
|
-
url: params.url,
|
|
736
|
-
postParams: params.postParams,
|
|
737
|
-
});
|
|
738
|
-
return `plivo:v3:${sha256Hex(`${baseUrl}\n${params.nonce}`)}`;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function timingSafeEqualString(a: string, b: string): boolean {
|
|
742
|
-
return safeEqualSecret(a, b);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function validatePlivoV2Signature(params: {
|
|
746
|
-
authToken: string;
|
|
747
|
-
signature: string;
|
|
748
|
-
nonce: string;
|
|
749
|
-
url: string;
|
|
750
|
-
}): boolean {
|
|
751
|
-
const baseUrl = getBaseUrlNoQuery(params.url);
|
|
752
|
-
const digest = crypto
|
|
753
|
-
.createHmac("sha256", params.authToken)
|
|
754
|
-
.update(baseUrl + params.nonce)
|
|
755
|
-
.digest("base64");
|
|
756
|
-
const expected = normalizeSignatureBase64(digest);
|
|
757
|
-
const provided = normalizeSignatureBase64(params.signature);
|
|
758
|
-
return timingSafeEqualString(expected, provided);
|
|
759
|
-
}
|
|
760
|
-
|
|
761
|
-
type PlivoParamMap = Record<string, string[]>;
|
|
762
|
-
|
|
763
|
-
function toParamMapFromSearchParams(sp: URLSearchParams): PlivoParamMap {
|
|
764
|
-
const map: PlivoParamMap = {};
|
|
765
|
-
for (const [key, value] of sp.entries()) {
|
|
766
|
-
if (!map[key]) {
|
|
767
|
-
map[key] = [];
|
|
768
|
-
}
|
|
769
|
-
map[key].push(value);
|
|
770
|
-
}
|
|
771
|
-
return map;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function sortedQueryString(params: PlivoParamMap): string {
|
|
775
|
-
const parts: string[] = [];
|
|
776
|
-
for (const key of Object.keys(params).toSorted()) {
|
|
777
|
-
const values = [...params[key]].toSorted();
|
|
778
|
-
for (const value of values) {
|
|
779
|
-
parts.push(`${key}=${value}`);
|
|
780
|
-
}
|
|
781
|
-
}
|
|
782
|
-
return parts.join("&");
|
|
783
|
-
}
|
|
784
|
-
|
|
785
|
-
function sortedParamsString(params: PlivoParamMap): string {
|
|
786
|
-
const parts: string[] = [];
|
|
787
|
-
for (const key of Object.keys(params).toSorted()) {
|
|
788
|
-
const values = [...params[key]].toSorted();
|
|
789
|
-
for (const value of values) {
|
|
790
|
-
parts.push(`${key}${value}`);
|
|
791
|
-
}
|
|
792
|
-
}
|
|
793
|
-
return parts.join("");
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
function constructPlivoV3BaseUrl(params: {
|
|
797
|
-
method: "GET" | "POST";
|
|
798
|
-
url: string;
|
|
799
|
-
postParams: PlivoParamMap;
|
|
800
|
-
}): string {
|
|
801
|
-
const hasPostParams = Object.keys(params.postParams).length > 0;
|
|
802
|
-
const u = new URL(params.url);
|
|
803
|
-
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
|
|
804
|
-
|
|
805
|
-
const queryMap = toParamMapFromSearchParams(u.searchParams);
|
|
806
|
-
const queryString = sortedQueryString(queryMap);
|
|
807
|
-
|
|
808
|
-
// In the Plivo V3 algorithm, the query portion is always sorted, and if we
|
|
809
|
-
// have POST params we add a '.' separator after the query string.
|
|
810
|
-
let baseUrl = baseNoQuery;
|
|
811
|
-
if (queryString.length > 0 || hasPostParams) {
|
|
812
|
-
baseUrl = `${baseNoQuery}?${queryString}`;
|
|
813
|
-
}
|
|
814
|
-
if (queryString.length > 0 && hasPostParams) {
|
|
815
|
-
baseUrl = `${baseUrl}.`;
|
|
816
|
-
}
|
|
817
|
-
|
|
818
|
-
if (params.method === "GET") {
|
|
819
|
-
return baseUrl;
|
|
820
|
-
}
|
|
821
|
-
|
|
822
|
-
return baseUrl + sortedParamsString(params.postParams);
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function validatePlivoV3Signature(params: {
|
|
826
|
-
authToken: string;
|
|
827
|
-
signatureHeader: string;
|
|
828
|
-
nonce: string;
|
|
829
|
-
method: "GET" | "POST";
|
|
830
|
-
url: string;
|
|
831
|
-
postParams: PlivoParamMap;
|
|
832
|
-
}): boolean {
|
|
833
|
-
const baseUrl = constructPlivoV3BaseUrl({
|
|
834
|
-
method: params.method,
|
|
835
|
-
url: params.url,
|
|
836
|
-
postParams: params.postParams,
|
|
837
|
-
});
|
|
838
|
-
|
|
839
|
-
const hmacBase = `${baseUrl}.${params.nonce}`;
|
|
840
|
-
const digest = crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64");
|
|
841
|
-
const expected = normalizeSignatureBase64(digest);
|
|
842
|
-
|
|
843
|
-
// Header can contain multiple signatures separated by commas.
|
|
844
|
-
const provided = params.signatureHeader
|
|
845
|
-
.split(",")
|
|
846
|
-
.map((s) => s.trim())
|
|
847
|
-
.filter(Boolean)
|
|
848
|
-
.map((s) => normalizeSignatureBase64(s));
|
|
849
|
-
|
|
850
|
-
for (const sig of provided) {
|
|
851
|
-
if (timingSafeEqualString(expected, sig)) {
|
|
852
|
-
return true;
|
|
853
|
-
}
|
|
854
|
-
}
|
|
855
|
-
return false;
|
|
856
|
-
}
|
|
857
|
-
|
|
858
|
-
/**
|
|
859
|
-
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
|
|
860
|
-
*
|
|
861
|
-
* Header names (case-insensitive; Node provides lower-case keys):
|
|
862
|
-
* - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
|
|
863
|
-
* - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
|
|
864
|
-
*/
|
|
865
|
-
export function verifyPlivoWebhook(
|
|
866
|
-
ctx: WebhookContext,
|
|
867
|
-
authToken: string,
|
|
868
|
-
options?: {
|
|
869
|
-
/** Override the public URL origin (host) used for verification */
|
|
870
|
-
publicUrl?: string;
|
|
871
|
-
/** Skip verification entirely (only for development) */
|
|
872
|
-
skipVerification?: boolean;
|
|
873
|
-
/**
|
|
874
|
-
* Whitelist of allowed hostnames for host header validation.
|
|
875
|
-
* Prevents host header injection attacks.
|
|
876
|
-
*/
|
|
877
|
-
allowedHosts?: string[];
|
|
878
|
-
/**
|
|
879
|
-
* Explicitly trust X-Forwarded-* headers without a whitelist.
|
|
880
|
-
* WARNING: Only enable if you trust your proxy configuration.
|
|
881
|
-
* @default false
|
|
882
|
-
*/
|
|
883
|
-
trustForwardingHeaders?: boolean;
|
|
884
|
-
/**
|
|
885
|
-
* List of trusted proxy IP addresses. X-Forwarded-* headers will only
|
|
886
|
-
* be trusted from these IPs.
|
|
887
|
-
*/
|
|
888
|
-
trustedProxyIPs?: string[];
|
|
889
|
-
/**
|
|
890
|
-
* The remote IP address of the request (for proxy validation).
|
|
891
|
-
*/
|
|
892
|
-
remoteIP?: string;
|
|
893
|
-
},
|
|
894
|
-
): PlivoVerificationResult {
|
|
895
|
-
if (options?.skipVerification) {
|
|
896
|
-
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
|
|
897
|
-
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
898
|
-
return {
|
|
899
|
-
ok: true,
|
|
900
|
-
reason: "verification skipped (dev mode)",
|
|
901
|
-
isReplay,
|
|
902
|
-
verifiedRequestKey: replayKey,
|
|
903
|
-
};
|
|
904
|
-
}
|
|
905
|
-
|
|
906
|
-
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
|
|
907
|
-
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
|
|
908
|
-
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
|
|
909
|
-
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
|
|
910
|
-
|
|
911
|
-
const reconstructed = reconstructWebhookUrl(ctx, {
|
|
912
|
-
allowedHosts: options?.allowedHosts,
|
|
913
|
-
trustForwardingHeaders: options?.trustForwardingHeaders,
|
|
914
|
-
trustedProxyIPs: options?.trustedProxyIPs,
|
|
915
|
-
remoteIP: options?.remoteIP,
|
|
916
|
-
});
|
|
917
|
-
let verificationUrl = reconstructed;
|
|
918
|
-
if (options?.publicUrl) {
|
|
919
|
-
try {
|
|
920
|
-
const req = new URL(reconstructed);
|
|
921
|
-
const base = new URL(options.publicUrl);
|
|
922
|
-
base.pathname = req.pathname;
|
|
923
|
-
base.search = req.search;
|
|
924
|
-
verificationUrl = base.toString();
|
|
925
|
-
} catch {
|
|
926
|
-
verificationUrl = reconstructed;
|
|
927
|
-
}
|
|
928
|
-
}
|
|
929
|
-
|
|
930
|
-
if (signatureV3 && nonceV3) {
|
|
931
|
-
const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
|
|
932
|
-
|
|
933
|
-
if (!method) {
|
|
934
|
-
return {
|
|
935
|
-
ok: false,
|
|
936
|
-
version: "v3",
|
|
937
|
-
verificationUrl,
|
|
938
|
-
reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`,
|
|
939
|
-
};
|
|
940
|
-
}
|
|
941
|
-
|
|
942
|
-
const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
|
|
943
|
-
const ok = validatePlivoV3Signature({
|
|
944
|
-
authToken,
|
|
945
|
-
signatureHeader: signatureV3,
|
|
946
|
-
nonce: nonceV3,
|
|
947
|
-
method,
|
|
948
|
-
url: verificationUrl,
|
|
949
|
-
postParams,
|
|
950
|
-
});
|
|
951
|
-
if (!ok) {
|
|
952
|
-
return {
|
|
953
|
-
ok: false,
|
|
954
|
-
version: "v3",
|
|
955
|
-
verificationUrl,
|
|
956
|
-
reason: "Invalid Plivo V3 signature",
|
|
957
|
-
};
|
|
958
|
-
}
|
|
959
|
-
const replayKey = createPlivoV3ReplayKey({
|
|
960
|
-
method,
|
|
961
|
-
url: verificationUrl,
|
|
962
|
-
postParams,
|
|
963
|
-
nonce: nonceV3,
|
|
964
|
-
});
|
|
965
|
-
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
966
|
-
return { ok: true, version: "v3", verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
if (signatureV2 && nonceV2) {
|
|
970
|
-
const ok = validatePlivoV2Signature({
|
|
971
|
-
authToken,
|
|
972
|
-
signature: signatureV2,
|
|
973
|
-
nonce: nonceV2,
|
|
974
|
-
url: verificationUrl,
|
|
975
|
-
});
|
|
976
|
-
if (!ok) {
|
|
977
|
-
return {
|
|
978
|
-
ok: false,
|
|
979
|
-
version: "v2",
|
|
980
|
-
verificationUrl,
|
|
981
|
-
reason: "Invalid Plivo V2 signature",
|
|
982
|
-
};
|
|
983
|
-
}
|
|
984
|
-
const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
|
|
985
|
-
const isReplay = markReplay(plivoReplayCache, replayKey);
|
|
986
|
-
return { ok: true, version: "v2", verificationUrl, isReplay, verifiedRequestKey: replayKey };
|
|
987
|
-
}
|
|
988
|
-
|
|
989
|
-
return {
|
|
990
|
-
ok: false,
|
|
991
|
-
reason: "Missing Plivo signature headers (V3 or V2)",
|
|
992
|
-
verificationUrl,
|
|
993
|
-
};
|
|
994
|
-
}
|