import crypto from "node:crypto"; import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime"; import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime"; import { getHeader } from "./http-headers.js"; import type { WebhookContext } from "./types.js";
/** * Configuration for secure URL reconstruction.
*/
export interface WebhookUrlOptions { /** * Whitelist of allowed hostnames. If provided, only these hosts will be * accepted from forwarding headers. This prevents host header injection attacks. * * SECURITY: You must provide this OR set trustForwardingHeaders=true to use * X-Forwarded-Host headers. Without either, forwarding headers are ignored.
*/
allowedHosts?: string[]; /** * Explicitly trust X-Forwarded-* headers without a whitelist. * WARNING: Only set this to true if you trust your proxy configuration * and understand the security implications. * * @default false
*/
trustForwardingHeaders?: boolean; /** * List of trusted proxy IP addresses. X-Forwarded-* headers will only be * trusted if the request comes from one of these IPs. * Requires remoteIP to be set for validation.
*/
trustedProxyIPs?: string[]; /** * The IP address of the incoming request (for proxy validation).
*/
remoteIP?: string;
}
/** * Validate that a hostname matches RFC 1123 format. * Prevents injection of malformed hostnames.
*/ function isValidHostname(hostname: string): boolean { if (!hostname || hostname.length > 253) { returnfalse;
} // RFC 1123 hostname: alphanumeric, hyphens, dots // Also allow ngrok/tunnel subdomains const hostnameRegex =
/^([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])?$/; return hostnameRegex.test(hostname);
}
/** * Safely extract hostname from a host header value. * Handles IPv6 addresses and prevents injection via malformed values.
*/ function extractHostname(hostHeader: string): string | null { if (!hostHeader) { returnnull;
}
// Handle IPv4/domain with optional port // Check for @ which could indicate user info injection attempt if (hostHeader.includes("@")) { returnnull; // Reject potential injection: attacker.com:80@legitimate.com
}
hostname = hostHeader.split(":")[0];
// Validate the extracted hostname if (!isValidHostname(hostname)) { returnnull;
}
function extractHostnameFromHeader(headerValue: string): string | null { const first = headerValue.split(",")[0]?.trim(); if (!first) { returnnull;
} return extractHostname(first);
}
function normalizeAllowedHosts(allowedHosts?: string[]): Set<string> | null { if (!allowedHosts || allowedHosts.length === 0) { returnnull;
} const normalized = new Set<string>(); 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)
*/
export function reconstructWebhookUrl(ctx: WebhookContext, options?: WebhookUrlOptions): string { const { headers } = ctx;
// SECURITY: Only trust forwarding headers if explicitly configured. // Either allowedHosts must be set (for whitelist validation) or // trustForwardingHeaders must be true (explicit opt-in to trust). const allowedHosts = normalizeAllowedHosts(options?.allowedHosts); const hasAllowedHosts = allowedHosts !== null; const explicitlyTrusted = options?.trustForwardingHeaders === true;
// Determine protocol - only trust X-Forwarded-Proto from trusted proxies
let proto = "https"; if (shouldTrustForwardingHeaders) { const forwardedProto = getHeader(headers, "x-forwarded-proto"); if (forwardedProto === "http" || forwardedProto === "https") {
proto = forwardedProto;
}
}
// Determine host - with security validation
let host: string | null = null;
if (shouldTrustForwardingHeaders) { // Try forwarding headers in priority order const forwardingHeaders = ["x-forwarded-host", "x-original-host", "ngrok-forwarded-host"];
for (const headerName of forwardingHeaders) { const headerValue = getHeader(headers, headerName); if (headerValue) { const extracted = extractHostnameFromHeader(headerValue); if (extracted && isAllowedForwardedHost(extracted)) {
host = extracted; break;
}
}
}
}
// Fallback to Host header if no valid forwarding header found if (!host) { const hostHeader = getHeader(headers, "host"); if (hostHeader) { const extracted = extractHostnameFromHeader(hostHeader); if (extracted) {
host = extracted;
}
}
}
// Last resort: try to extract from ctx.url if (!host) { try { const parsed = new URL(ctx.url); const extracted = extractHostname(parsed.host); if (extracted) {
host = extracted;
}
} catch { // URL parsing failed - use empty string (will result in invalid URL)
host = "";
}
}
if (!host) {
host = "";
}
// Extract path from the context URL (fallback to "/" on parse failure)
let path = "/"; try { const parsed = new URL(ctx.url);
path = parsed.pathname + parsed.search;
} catch { // URL parsing failed
}
return `${proto}://${host}${path}`;
}
function buildTwilioVerificationUrl(
ctx: WebhookContext,
publicUrl?: string,
urlOptions?: WebhookUrlOptions,
): string { 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 isLoopbackAddress(address?: string): boolean { if (!address) { returnfalse;
} if (address === "127.0.0.1" || address === "::1") { returntrue;
} if (address.startsWith("::ffff:127.")) { returntrue;
} returnfalse;
}
function stripPortFromUrl(url: string): string { try { const parsed = new URL(url); if (!parsed.port) { return url;
}
parsed.port = ""; return parsed.toString();
} catch { return url;
}
}
/** * Verify Twilio webhook with full context and detailed result.
*/
export function verifyTwilioWebhook(
ctx: WebhookContext,
authToken: string,
options?: { /** Override the public URL (e.g., from config) */
publicUrl?: string; /** * Allow ngrok free tier compatibility mode (loopback only). * * IMPORTANT: This does NOT bypass signature verification. * It only enables trusting forwarded headers on loopback so we can * reconstruct the public ngrok URL that Twilio used for signing.
*/
allowNgrokFreeTierLoopbackBypass?: boolean; /** Skip verification entirely (only for development) */
skipVerification?: boolean; /** * Whitelist of allowed hostnames for host header validation. * Prevents host header injection attacks.
*/
allowedHosts?: string[]; /** * Explicitly trust X-Forwarded-* headers without a whitelist. * WARNING: Only enable if you trust your proxy configuration. * @default false
*/
trustForwardingHeaders?: boolean; /** * List of trusted proxy IP addresses. X-Forwarded-* headers will only * be trusted from these IPs.
*/
trustedProxyIPs?: string[]; /** * The remote IP address of the request (for proxy validation).
*/
remoteIP?: string;
},
): TwilioVerificationResult { // Allow skipping verification for development/testing if (options?.skipVerification) { const replayKey = createSkippedVerificationReplayKey("twilio", ctx); const isReplay = markReplay(twilioReplayCache, replayKey); return {
ok: true,
reason: "verification skipped (dev mode)",
isReplay,
verifiedRequestKey: replayKey,
};
}
// Twilio webhook signatures can differ in whether port is included. // Retry a small, deterministic set of URL variants before failing closed. const variants = new Set<string>();
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 { // ignore invalid publicUrl; primary verification already used best effort
}
}
// Check if this is ngrok free tier - the URL might have different format const isNgrokFreeTier =
verificationUrl.includes(".ngrok-free.app") || verificationUrl.includes(".ngrok.io");
/** * Result of Plivo webhook verification with detailed info.
*/
export interface PlivoVerificationResult {
ok: boolean;
reason?: string;
verificationUrl?: string; /** Signature version used for verification */
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 { // Canonicalize base64 to match Plivo SDK behavior (decode then re-encode). return Buffer.from(input, "base64").toString("base64");
}
function getBaseUrlNoQuery(url: string): string { const u = new URL(url); return `${u.protocol}//${u.host}${u.pathname}`;
}
function createPlivoV2ReplayKey(url: string, nonce: string): string { return `plivo:v2:${sha256Hex(`${getBaseUrlNoQuery(url)}\n${nonce}`)}`;
}
// In the Plivo V3 algorithm, the query portion is always sorted, and if we // have POST params we add a '.' separator after the query string.
let baseUrl = baseNoQuery; if (queryString.length > 0 || hasPostParams) {
baseUrl = `${baseNoQuery}?${queryString}`;
} if (queryString.length > 0 && hasPostParams) {
baseUrl = `${baseUrl}.`;
}
Die Informationen auf dieser Webseite wurden
nach bestem Wissen sorgfältig zusammengestellt. Es wird jedoch weder Vollständigkeit, noch Richtigkeit,
noch Qualität der bereit gestellten Informationen zugesichert.
Bemerkung:
Die farbliche Syntaxdarstellung und die Messung sind noch experimentell.