openclaw
Version:
Multi-channel AI gateway with extensible messaging integrations
601 lines (600 loc) • 20.6 kB
JavaScript
import { a as normalizeLowercaseStringOrEmpty } from "./string-coerce-mnp54Vah.js";
import { C as resolveExpiresAtMsFromDurationMs, m as isFutureDateTimestampMs } from "./number-coercion-CJQ8TR--.js";
import { i as formatErrorMessage } from "./errors-BXgSefBE.js";
import { l as normalizeStringEntries } from "./string-normalization-WNUDCpXX.js";
import { t as safeEqualSecret } from "./secret-equal-DRsL8lKD.js";
import { i as isLoopbackHost } from "./net-DTe7AQiu.js";
import { r as fetchWithSsrFGuard } from "./fetch-guard-BttkNCLm.js";
import "./error-runtime-C8vbtAJt.js";
import "./number-runtime-DBLVDypr.js";
import "./security-runtime-CQm7DD1u.js";
import "./string-coerce-runtime-CEGJWkQ_.js";
import "./gateway-runtime-BMyBmMxe.js";
import "./api-CS4KHy5x.js";
import { t as getHeader } from "./http-headers-BKIHh6IS.js";
import crypto from "node:crypto";
//#region extensions/voice-call/src/webhook-security.ts
const REPLAY_WINDOW_MS = 600 * 1e3;
const REPLAY_CACHE_MAX_ENTRIES = 1e4;
const REPLAY_CACHE_PRUNE_INTERVAL = 64;
const twilioReplayCache = {
seenUntil: /* @__PURE__ */ new Map(),
calls: 0
};
const plivoReplayCache = {
seenUntil: /* @__PURE__ */ new Map(),
calls: 0
};
const telnyxReplayCache = {
seenUntil: /* @__PURE__ */ new Map(),
calls: 0
};
function sha256Hex(input) {
return crypto.createHash("sha256").update(input).digest("hex");
}
function createSkippedVerificationReplayKey(provider, ctx) {
return `${provider}:skip:${sha256Hex(`${ctx.method}\n${ctx.url}\n${ctx.rawBody}`)}`;
}
function pruneReplayCache(cache, now) {
for (const [key, expiresAt] of cache.seenUntil) if (!isFutureDateTimestampMs(expiresAt, { nowMs: now })) cache.seenUntil.delete(key);
while (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) {
const oldest = cache.seenUntil.keys().next().value;
if (!oldest) break;
cache.seenUntil.delete(oldest);
}
}
function markReplay(cache, replayKey) {
const now = Date.now();
cache.calls += 1;
if (cache.calls % REPLAY_CACHE_PRUNE_INTERVAL === 0) pruneReplayCache(cache, now);
const existing = cache.seenUntil.get(replayKey);
if (existing !== void 0 && isFutureDateTimestampMs(existing, { nowMs: now })) return true;
const expiresAt = resolveExpiresAtMsFromDurationMs(REPLAY_WINDOW_MS, { nowMs: now });
if (expiresAt !== void 0) cache.seenUntil.set(replayKey, expiresAt);
if (cache.seenUntil.size > REPLAY_CACHE_MAX_ENTRIES) pruneReplayCache(cache, now);
return false;
}
/**
* Validate Twilio webhook signature using HMAC-SHA1.
*
* Twilio signs requests by concatenating the URL with sorted POST params,
* then computing HMAC-SHA1 with the auth token.
*
* @see https://www.twilio.com/docs/usage/webhooks/webhooks-security
*/
function validateTwilioSignature(authToken, signature, url, params) {
if (!signature) return false;
const dataToSign = buildTwilioDataToSign(url, params);
return timingSafeEqual$1(signature, crypto.createHmac("sha1", authToken).update(dataToSign).digest("base64"));
}
function buildTwilioDataToSign(url, params) {
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) {
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.
*/
function timingSafeEqual$1(a, b) {
return safeEqualSecret(a, b);
}
/**
* Validate that a hostname matches RFC 1123 format.
* Prevents injection of malformed hostnames.
*/
function isValidHostname(hostname) {
if (!hostname || hostname.length > 253) return false;
return /^([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])?$/.test(hostname);
}
/**
* Safely extract hostname from a host header value.
* Handles IPv6 addresses and prevents injection via malformed values.
*/
function extractHostname(hostHeader) {
if (!hostHeader) return null;
let hostname;
if (hostHeader.startsWith("[")) {
const endBracket = hostHeader.indexOf("]");
if (endBracket === -1) return null;
hostname = hostHeader.slice(1, endBracket);
return normalizeLowercaseStringOrEmpty(hostname);
}
if (hostHeader.includes("@")) return null;
hostname = hostHeader.split(":")[0];
if (!isValidHostname(hostname)) return null;
return normalizeLowercaseStringOrEmpty(hostname);
}
function extractHostnameFromHeader(headerValue) {
const first = headerValue.split(",")[0]?.trim();
if (!first) return null;
return extractHostname(first);
}
function normalizeAllowedHosts(allowedHosts) {
if (!allowedHosts || allowedHosts.length === 0) return null;
const normalized = /* @__PURE__ */ new Set();
for (const host of allowedHosts) {
const extracted = extractHostname(host.trim());
if (extracted) normalized.add(extracted);
}
return normalized.size > 0 ? normalized : null;
}
/**
* Reconstruct the public webhook URL from request headers.
*
* SECURITY: This function validates host headers to prevent host header
* injection attacks. When using forwarding headers (X-Forwarded-Host, etc.),
* always provide allowedHosts to whitelist valid hostnames.
*
* When behind a reverse proxy (Tailscale, nginx, ngrok), the original URL
* used by Twilio differs from the local request URL. We use standard
* forwarding headers to reconstruct it.
*
* Priority order:
* 1. X-Forwarded-Proto + X-Forwarded-Host (standard proxy headers)
* 2. X-Original-Host (nginx)
* 3. Ngrok-Forwarded-Host (ngrok specific)
* 4. Host header (direct connection)
*/
function reconstructWebhookUrl(ctx, options) {
const { headers } = ctx;
const allowedHosts = normalizeAllowedHosts(options?.allowedHosts);
const hasAllowedHosts = allowedHosts !== null;
const explicitlyTrusted = options?.trustForwardingHeaders === true;
const trustedProxyIPs = options?.trustedProxyIPs?.filter(Boolean) ?? [];
const hasTrustedProxyIPs = trustedProxyIPs.length > 0;
const remoteIP = options?.remoteIP ?? ctx.remoteAddress;
const fromTrustedProxy = !hasTrustedProxyIPs || (remoteIP ? trustedProxyIPs.includes(remoteIP) : false);
const shouldTrustForwardingHeaders = (hasAllowedHosts || explicitlyTrusted) && fromTrustedProxy;
const isAllowedForwardedHost = (host) => !allowedHosts || allowedHosts.has(host);
let proto = "https";
if (shouldTrustForwardingHeaders) {
const forwardedProto = getHeader(headers, "x-forwarded-proto");
if (forwardedProto === "http" || forwardedProto === "https") proto = forwardedProto;
}
let host = null;
if (shouldTrustForwardingHeaders) for (const headerName of [
"x-forwarded-host",
"x-original-host",
"ngrok-forwarded-host"
]) {
const headerValue = getHeader(headers, headerName);
if (headerValue) {
const extracted = extractHostnameFromHeader(headerValue);
if (extracted && isAllowedForwardedHost(extracted)) {
host = extracted;
break;
}
}
}
if (!host) {
const hostHeader = getHeader(headers, "host");
if (hostHeader) {
const extracted = extractHostnameFromHeader(hostHeader);
if (extracted) host = extracted;
}
}
if (!host) try {
const extracted = extractHostname(new URL(ctx.url).host);
if (extracted) host = extracted;
} catch {
host = "";
}
if (!host) host = "";
let path = "/";
try {
const parsed = new URL(ctx.url);
path = parsed.pathname + parsed.search;
} catch {}
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(ctx, publicUrl, urlOptions) {
if (!publicUrl) return reconstructWebhookUrl(ctx, urlOptions);
try {
const base = new URL(publicUrl);
const requestUrl = new URL(ctx.url);
base.pathname = requestUrl.pathname;
base.search = requestUrl.search;
return base.toString();
} catch {
return publicUrl;
}
}
function stripPortFromUrl(url) {
try {
const parsed = new URL(url);
if (!parsed.port) return url;
parsed.port = "";
return parsed.toString();
} catch {
return url;
}
}
function setPortOnUrl(url, port) {
try {
const parsed = new URL(url);
parsed.port = port;
return parsed.toString();
} catch {
return url;
}
}
function extractPortFromHostHeader(hostHeader) {
if (!hostHeader) return;
try {
return new URL(`https://${hostHeader}`).port || void 0;
} catch {
return;
}
}
function createTwilioReplayKey(params) {
const canonicalParams = buildCanonicalTwilioParamString(params.requestParams);
return `twilio:req:${sha256Hex(`${params.verificationUrl}\n${canonicalParams}\n${params.signature}`)}`;
}
function decodeBase64OrBase64Url(input) {
const normalized = input.replace(/-/g, "+").replace(/_/g, "/");
const padLen = (4 - normalized.length % 4) % 4;
const padded = normalized + "=".repeat(padLen);
return Buffer.from(padded, "base64");
}
function base64UrlEncode(buf) {
return buf.toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/g, "");
}
function importEd25519PublicKey(publicKey) {
const trimmed = publicKey.trim();
if (trimmed.startsWith("-----BEGIN")) return trimmed;
const decoded = decodeBase64OrBase64Url(trimmed);
if (decoded.length === 32) return crypto.createPublicKey({
key: {
kty: "OKP",
crv: "Ed25519",
x: base64UrlEncode(decoded)
},
format: "jwk"
});
return crypto.createPublicKey({
key: decoded,
format: "der",
type: "spki"
});
}
/**
* Verify Telnyx webhook signature using Ed25519.
*
* Telnyx signs `timestamp|payload` and provides:
* - `telnyx-signature-ed25519` (Base64 signature)
* - `telnyx-timestamp` (Unix seconds)
*/
function verifyTelnyxWebhook(ctx, publicKey, options) {
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("telnyx", ctx);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay: markReplay(telnyxReplayCache, replayKey),
verifiedRequestKey: replayKey
};
}
if (!publicKey) return {
ok: false,
reason: "Missing telnyx.publicKey (configure to verify webhooks)"
};
const signature = getHeader(ctx.headers, "telnyx-signature-ed25519");
const timestamp = getHeader(ctx.headers, "telnyx-timestamp");
if (!signature || !timestamp) return {
ok: false,
reason: "Missing signature or timestamp header"
};
const eventTimeSec = Number.parseInt(timestamp, 10);
if (!Number.isFinite(eventTimeSec)) return {
ok: false,
reason: "Invalid timestamp header"
};
try {
const signedPayload = `${timestamp}|${ctx.rawBody}`;
const signatureBuffer = decodeBase64OrBase64Url(signature);
const canonicalSignature = signatureBuffer.toString("base64");
const key = importEd25519PublicKey(publicKey);
if (!crypto.verify(null, Buffer.from(signedPayload), key, signatureBuffer)) return {
ok: false,
reason: "Invalid signature"
};
const maxSkewMs = options?.maxSkewMs ?? 300 * 1e3;
const eventTimeMs = eventTimeSec * 1e3;
if (Math.abs(Date.now() - eventTimeMs) > maxSkewMs) return {
ok: false,
reason: "Timestamp too old"
};
const replayKey = `telnyx:${sha256Hex(`${timestamp}\n${canonicalSignature}\n${ctx.rawBody}`)}`;
return {
ok: true,
isReplay: markReplay(telnyxReplayCache, replayKey),
verifiedRequestKey: replayKey
};
} catch (err) {
return {
ok: false,
reason: `Verification error: ${formatErrorMessage(err)}`
};
}
}
/**
* Verify Twilio webhook with full context and detailed result.
*/
function verifyTwilioWebhook(ctx, authToken, options) {
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("twilio", ctx);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay: markReplay(twilioReplayCache, replayKey),
verifiedRequestKey: replayKey
};
}
const signature = getHeader(ctx.headers, "x-twilio-signature");
if (!signature) return {
ok: false,
reason: "Missing X-Twilio-Signature header"
};
const isLoopback = isLoopbackHost(options?.remoteIP ?? ctx.remoteAddress ?? "");
const allowLoopbackForwarding = options?.allowNgrokFreeTierLoopbackBypass && isLoopback;
const verificationUrl = buildTwilioVerificationUrl(ctx, options?.publicUrl, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders || allowLoopbackForwarding,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP
});
const params = new URLSearchParams(ctx.rawBody);
if (validateTwilioSignature(authToken, signature, verificationUrl, params)) {
const replayKey = createTwilioReplayKey({
verificationUrl,
signature,
requestParams: params
});
return {
ok: true,
verificationUrl,
isReplay: markReplay(twilioReplayCache, replayKey),
verifiedRequestKey: replayKey
};
}
const variants = /* @__PURE__ */ new Set();
variants.add(verificationUrl);
variants.add(stripPortFromUrl(verificationUrl));
if (options?.publicUrl) try {
const publicPort = new URL(options.publicUrl).port;
if (publicPort) variants.add(setPortOnUrl(verificationUrl, publicPort));
} catch {}
const hostHeaderPort = extractPortFromHostHeader(getHeader(ctx.headers, "host"));
if (hostHeaderPort) variants.add(setPortOnUrl(verificationUrl, hostHeaderPort));
for (const candidateUrl of variants) {
if (candidateUrl === verificationUrl) continue;
if (!validateTwilioSignature(authToken, signature, candidateUrl, params)) continue;
const replayKey = createTwilioReplayKey({
verificationUrl: candidateUrl,
signature,
requestParams: params
});
return {
ok: true,
verificationUrl: candidateUrl,
isReplay: markReplay(twilioReplayCache, replayKey),
verifiedRequestKey: replayKey
};
}
const isNgrokFreeTier = verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
return {
ok: false,
reason: `Invalid signature for URL: ${verificationUrl}`,
verificationUrl,
isNgrokFreeTier
};
}
function normalizeSignatureBase64(input) {
return Buffer.from(input, "base64").toString("base64");
}
function getBaseUrlNoQuery(url) {
const u = new URL(url);
return `${u.protocol}//${u.host}${u.pathname}`;
}
function createPlivoV2ReplayKey(url, nonce) {
return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
}
function createPlivoV3ReplayKey(params) {
return `plivo:v3:${sha256Hex(`${constructPlivoV3BaseUrl({
method: params.method,
url: params.url,
postParams: params.postParams
})}\n${params.nonce}`)}`;
}
function timingSafeEqualString(a, b) {
return safeEqualSecret(a, b);
}
function validatePlivoV2Signature(params) {
const baseUrl = getBaseUrlNoQuery(params.url);
return timingSafeEqualString(normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(baseUrl + params.nonce).digest("base64")), normalizeSignatureBase64(params.signature));
}
function toParamMapFromSearchParams(sp) {
const map = {};
for (const [key, value] of sp.entries()) {
if (!map[key]) map[key] = [];
map[key].push(value);
}
return map;
}
function sortedQueryString(params) {
const parts = [];
for (const key of Object.keys(params).toSorted()) {
const values = [...params[key]].toSorted();
for (const value of values) parts.push(`${key}=${value}`);
}
return parts.join("&");
}
function sortedParamsString(params) {
const parts = [];
for (const key of Object.keys(params).toSorted()) {
const values = [...params[key]].toSorted();
for (const value of values) parts.push(`${key}${value}`);
}
return parts.join("");
}
function constructPlivoV3BaseUrl(params) {
const hasPostParams = Object.keys(params.postParams).length > 0;
const u = new URL(params.url);
const baseNoQuery = `${u.protocol}//${u.host}${u.pathname}`;
const queryString = sortedQueryString(toParamMapFromSearchParams(u.searchParams));
let baseUrl = baseNoQuery;
if (queryString.length > 0 || hasPostParams) baseUrl = `${baseNoQuery}?${queryString}`;
if (queryString.length > 0 && hasPostParams) baseUrl = `${baseUrl}.`;
if (params.method === "GET") return baseUrl;
return baseUrl + sortedParamsString(params.postParams);
}
function validatePlivoV3Signature(params) {
const hmacBase = `${constructPlivoV3BaseUrl({
method: params.method,
url: params.url,
postParams: params.postParams
})}.${params.nonce}`;
const expected = normalizeSignatureBase64(crypto.createHmac("sha256", params.authToken).update(hmacBase).digest("base64"));
const provided = normalizeStringEntries(params.signatureHeader.split(",")).map((s) => normalizeSignatureBase64(s));
for (const sig of provided) if (timingSafeEqualString(expected, sig)) return true;
return false;
}
/**
* Verify Plivo webhooks using V3 signature if present; fall back to V2.
*
* Header names (case-insensitive; Node provides lower-case keys):
* - V3: X-Plivo-Signature-V3 / X-Plivo-Signature-V3-Nonce
* - V2: X-Plivo-Signature-V2 / X-Plivo-Signature-V2-Nonce
*/
function verifyPlivoWebhook(ctx, authToken, options) {
if (options?.skipVerification) {
const replayKey = createSkippedVerificationReplayKey("plivo", ctx);
return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay: markReplay(plivoReplayCache, replayKey),
verifiedRequestKey: replayKey
};
}
const signatureV3 = getHeader(ctx.headers, "x-plivo-signature-v3");
const nonceV3 = getHeader(ctx.headers, "x-plivo-signature-v3-nonce");
const signatureV2 = getHeader(ctx.headers, "x-plivo-signature-v2");
const nonceV2 = getHeader(ctx.headers, "x-plivo-signature-v2-nonce");
const reconstructed = reconstructWebhookUrl(ctx, {
allowedHosts: options?.allowedHosts,
trustForwardingHeaders: options?.trustForwardingHeaders,
trustedProxyIPs: options?.trustedProxyIPs,
remoteIP: options?.remoteIP
});
let verificationUrl = reconstructed;
if (options?.publicUrl) try {
const req = new URL(reconstructed);
const base = new URL(options.publicUrl);
base.pathname = req.pathname;
base.search = req.search;
verificationUrl = base.toString();
} catch {
verificationUrl = reconstructed;
}
if (signatureV3 && nonceV3) {
const method = ctx.method === "GET" || ctx.method === "POST" ? ctx.method : null;
if (!method) return {
ok: false,
version: "v3",
verificationUrl,
reason: `Unsupported HTTP method for Plivo V3 signature: ${ctx.method}`
};
const postParams = toParamMapFromSearchParams(new URLSearchParams(ctx.rawBody));
if (!validatePlivoV3Signature({
authToken,
signatureHeader: signatureV3,
nonce: nonceV3,
method,
url: verificationUrl,
postParams
})) return {
ok: false,
version: "v3",
verificationUrl,
reason: "Invalid Plivo V3 signature"
};
const replayKey = createPlivoV3ReplayKey({
method,
url: verificationUrl,
postParams,
nonce: nonceV3
});
const isReplay = markReplay(plivoReplayCache, replayKey);
return {
ok: true,
version: "v3",
verificationUrl,
isReplay,
verifiedRequestKey: replayKey
};
}
if (signatureV2 && nonceV2) {
if (!validatePlivoV2Signature({
authToken,
signature: signatureV2,
nonce: nonceV2,
url: verificationUrl
})) return {
ok: false,
version: "v2",
verificationUrl,
reason: "Invalid Plivo V2 signature"
};
const replayKey = createPlivoV2ReplayKey(verificationUrl, nonceV2);
const isReplay = markReplay(plivoReplayCache, replayKey);
return {
ok: true,
version: "v2",
verificationUrl,
isReplay,
verifiedRequestKey: replayKey
};
}
return {
ok: false,
reason: "Missing Plivo signature headers (V3 or V2)",
verificationUrl
};
}
//#endregion
//#region extensions/voice-call/src/providers/shared/guarded-json-api.ts
/** Send a provider JSON request through the SSRF guard and parse bounded JSON responses. */
async function guardedJsonApiRequest(params) {
const { response, release } = await fetchWithSsrFGuard({
url: params.url,
init: {
method: params.method,
headers: params.headers,
body: params.body ? JSON.stringify(params.body) : void 0
},
policy: { allowedHostnames: params.allowedHostnames },
auditContext: params.auditContext
});
try {
if (!response.ok) {
if (params.allowNotFound && response.status === 404) return;
const errorText = await response.text();
throw new Error(`${params.errorPrefix}: ${response.status} ${errorText}`);
}
const text = await response.text();
if (!text) return;
try {
return JSON.parse(text);
} catch {
throw new Error(`${params.errorPrefix}: malformed JSON response`);
}
} finally {
await release();
}
}
//#endregion
export { verifyTwilioWebhook as a, verifyTelnyxWebhook as i, reconstructWebhookUrl as n, verifyPlivoWebhook as r, guardedJsonApiRequest as t };