11import { createHmac } from "crypto" ;
22import { lookup } from "dns/promises" ;
3+ import { isIP } from "net" ;
34import { BlockList , isIP } from "net" ;
45import { withRetry } from "./retry" ;
56import { logger } from "./logger" ;
@@ -15,6 +16,112 @@ export interface WebhookConfig {
1516
1617const webhooks = new Map < string , WebhookConfig > ( ) ;
1718
19+ /**
20+ * Validate that a webhook URL is a well-formed http/https URL whose resolved
21+ * hostname does not point at private, loopback, link-local, or metadata
22+ * address ranges. This is a defense-in-depth SSRF guard: even though the
23+ * webhook endpoint is admin-only, we never want the server to make outbound
24+ * requests to internal infrastructure on behalf of a caller.
25+ *
26+ * Returns the normalized URL string on success, or throws an Error with a
27+ * human-readable message describing why the URL was rejected.
28+ */
29+ export async function validateWebhookUrl ( rawUrl : string ) : Promise < string > {
30+ let parsed : URL ;
31+ try {
32+ parsed = new URL ( rawUrl ) ;
33+ } catch {
34+ throw new Error ( "url must be a valid http/https URL" ) ;
35+ }
36+
37+ if ( parsed . protocol !== "http:" && parsed . protocol !== "https:" ) {
38+ throw new Error ( "url must be a valid http/https URL" ) ;
39+ }
40+
41+ if ( ! parsed . hostname ) {
42+ throw new Error ( "url must include a hostname" ) ;
43+ }
44+
45+ // Resolve the hostname to every address it currently maps to and reject if
46+ // any of them is in a forbidden range. We check all addresses because a
47+ // hostname could resolve to a mix of public and private IPs.
48+ let addresses : string [ ] ;
49+ try {
50+ const result = await lookup ( parsed . hostname , { all : true } ) ;
51+ addresses = result . map ( ( entry ) => entry . address ) ;
52+ } catch {
53+ throw new Error ( "url hostname could not be resolved" ) ;
54+ }
55+
56+ if ( addresses . length === 0 ) {
57+ throw new Error ( "url hostname could not be resolved" ) ;
58+ }
59+
60+ for ( const address of addresses ) {
61+ if ( isForbiddenAddress ( address ) ) {
62+ throw new Error ( "url must not point to a private, loopback, link-local, or metadata address" ) ;
63+ }
64+ }
65+
66+ return parsed . toString ( ) ;
67+ }
68+
69+ function ipv4ToNumber ( ip : string ) : number {
70+ const parts = ip . split ( "." ) . map ( Number ) ;
71+ return ( ( parts [ 0 ] << 24 ) | ( parts [ 1 ] << 16 ) | ( parts [ 2 ] << 8 ) | parts [ 3 ] ) >>> 0 ;
72+ }
73+
74+ function ipv4InRange ( ipNum : number , network : string , prefix : number ) : boolean {
75+ const networkNum = ipv4ToNumber ( network ) ;
76+ const mask = prefix === 0 ? 0 : ~ ( ( 1 << ( 32 - prefix ) ) - 1 ) >>> 0 ;
77+ return ( ipNum & mask ) === ( networkNum & mask ) ;
78+ }
79+
80+ function isForbiddenIPv4 ( ip : string ) : boolean {
81+ const num = ipv4ToNumber ( ip ) ;
82+ const ranges : Array < [ string , number ] > = [
83+ [ "0.0.0.0" , 8 ] , // "this" network
84+ [ "10.0.0.0" , 8 ] , // private
85+ [ "100.64.0.0" , 10 ] , // CGNAT
86+ [ "127.0.0.0" , 8 ] , // loopback
87+ [ "169.254.0.0" , 16 ] , // link-local (incl. cloud metadata)
88+ [ "172.16.0.0" , 12 ] , // private
89+ [ "192.0.0.0" , 24 ] , // IETF protocol assignments
90+ [ "192.0.2.0" , 24 ] , // TEST-NET-1
91+ [ "192.168.0.0" , 16 ] , // private
92+ [ "198.18.0.0" , 15 ] , // benchmarking
93+ [ "198.51.100.0" , 24 ] , // TEST-NET-2
94+ [ "203.0.113.0" , 24 ] , // TEST-NET-3
95+ [ "224.0.0.0" , 4 ] , // multicast
96+ [ "240.0.0.0" , 4 ] , // reserved
97+ ] ;
98+ return ranges . some ( ( [ network , prefix ] ) => ipv4InRange ( num , network , prefix ) ) ;
99+ }
100+
101+ function isForbiddenIPv6 ( ip : string ) : boolean {
102+ const lower = ip . toLowerCase ( ) ;
103+ // IPv4-mapped IPv6 addresses (::ffff:a.b.c.d) — check the embedded IPv4.
104+ const mappedMatch = lower . match ( / ^ : : f f f f : ( \d + \. \d + \. \d + \. \d + ) $ / ) ;
105+ if ( mappedMatch ) {
106+ return isForbiddenIPv4 ( mappedMatch [ 1 ] ) ;
107+ }
108+ // Normalize the common "::" forms for prefix matching.
109+ if ( lower === "::" || lower === "::1" ) return true ; // unspecified / loopback
110+ if ( lower . startsWith ( "fc" ) || lower . startsWith ( "fd" ) ) return true ; // unique local fc00::/7
111+ if ( lower . startsWith ( "fe8" ) || lower . startsWith ( "fe9" ) || lower . startsWith ( "fea" ) || lower . startsWith ( "feb" ) ) return true ; // link-local fe80::/10
112+ if ( lower . startsWith ( "fec" ) || lower . startsWith ( "fed" ) || lower . startsWith ( "fee" ) || lower . startsWith ( "fef" ) ) return true ; // site-local fec0::/10
113+ if ( lower . startsWith ( "ff" ) ) return true ; // multicast ff00::/8
114+ if ( lower . startsWith ( "2001:db8" ) ) return true ; // documentation 2001:db8::/32
115+ return false ;
116+ }
117+
118+ function isForbiddenAddress ( address : string ) : boolean {
119+ const family = isIP ( address ) ;
120+ if ( family === 4 ) return isForbiddenIPv4 ( address ) ;
121+ if ( family === 6 ) return isForbiddenIPv6 ( address ) ;
122+ // Unknown address family — treat as forbidden to be safe.
123+ return true ;
124+ }
18125const BLOCKED_IPS = new BlockList ( ) ;
19126BLOCKED_IPS . addSubnet ( "0.0.0.0" , 8 ) ;
20127BLOCKED_IPS . addSubnet ( "10.0.0.0" , 8 ) ;
@@ -114,6 +221,7 @@ export async function validateWebhookUrl(rawUrl: string): Promise<URL> {
114221}
115222
116223async function deliverOnce ( url : string , body : string , signature : string ) : Promise < void > {
224+ // Re-validate at delivery time in case DNS changed after registration.
117225 // Re-validate immediately before sending to avoid DNS rebinding attacks after registration.
118226 await validateWebhookUrl ( url ) ;
119227 const response = await fetch ( url , {
0 commit comments