@@ -15,9 +15,41 @@ const DELIVERY_TIMEOUT_MS = 10000;
1515const HTTP_ERROR_STATUS = 400 ;
1616
1717/**
18- * Regex patterns matching private/reserved IP ranges.
19- * Covers: 0.x, 10.x, 127.x, 169.254.x, 172.16-31.x, 192.168.x, 100.64-127.x (IPv4)
20- * and ::1, ::, fe80::, fc/fd ULA (IPv6).
18+ * HTTP status codes indicating a redirect
19+ */
20+ const REDIRECT_STATUS_MIN = 300 ;
21+ const REDIRECT_STATUS_MAX = 399 ;
22+
23+ /**
24+ * Only these ports are allowed for webhook delivery
25+ */
26+ const ALLOWED_PORTS : Record < string , number > = {
27+ 'http:' : 80 ,
28+ 'https:' : 443 ,
29+ } ;
30+
31+ /**
32+ * Hostnames blocked regardless of DNS resolution
33+ */
34+ const BLOCKED_HOSTNAMES : RegExp [ ] = [
35+ / ^ l o c a l h o s t $ / i,
36+ / \. l o c a l $ / i,
37+ / \. i n t e r n a l $ / i,
38+ / \. l a n $ / i,
39+ / \. l o c a l d o m a i n $ / i,
40+ ] ;
41+
42+ /**
43+ * Regex patterns matching private/reserved IP ranges:
44+ *
45+ * IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
46+ * 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
47+ * 255.255.255.255 (broadcast), 224-239.x (multicast),
48+ * 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
49+ *
50+ * IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
51+ *
52+ * Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
2153 */
2254const PRIVATE_IP_PATTERNS : RegExp [ ] = [
2355 / ^ 0 \. / ,
@@ -27,24 +59,72 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [
2759 / ^ 1 7 2 \. ( 1 [ 6 - 9 ] | 2 \d | 3 [ 0 1 ] ) \. / ,
2860 / ^ 1 9 2 \. 1 6 8 \. / ,
2961 / ^ 1 0 0 \. ( 6 [ 4 - 9 ] | [ 7 - 9 ] \d | 1 [ 0 1 ] \d | 1 2 [ 0 - 7 ] ) \. / ,
62+ / ^ 2 5 5 \. 2 5 5 \. 2 5 5 \. 2 5 5 $ / ,
63+ / ^ 2 ( 2 [ 4 - 9 ] | 3 \d ) \. / ,
64+ / ^ 1 9 2 \. 0 \. 2 \. / ,
65+ / ^ 1 9 8 \. 5 1 \. 1 0 0 \. / ,
66+ / ^ 2 0 3 \. 0 \. 1 1 3 \. / ,
67+ / ^ 1 9 8 \. 1 [ 8 9 ] \. / ,
3068 / ^ : : 1 $ / ,
3169 / ^ : : $ / ,
3270 / ^ f e 8 0 / i,
3371 / ^ f [ c d ] / i,
72+ / ^ f f [ 0 - 9 a - f ] { 2 } : / i,
73+ / ^ : : f f f f : ( 0 \. | 1 0 \. | 1 2 7 \. | 1 6 9 \. 2 5 4 \. | 1 7 2 \. ( 1 [ 6 - 9 ] | 2 \d | 3 [ 0 1 ] ) \. | 1 9 2 \. 1 6 8 \. | 1 0 0 \. ( 6 [ 4 - 9 ] | [ 7 - 9 ] \d | 1 [ 0 1 ] \d | 1 2 [ 0 - 7 ] ) \. ) / i,
3474] ;
3575
3676/**
3777 * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range.
38- * Blocks loopback, link-local, RFC1918, metadata IPs and IPv6 equivalents .
78+ * Handles plain IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:x.x.x.x) .
3979 *
4080 * @param ip - IP address string (v4 or v6)
4181 */
4282export function isPrivateIP ( ip : string ) : boolean {
43- return PRIVATE_IP_PATTERNS . some ( ( pattern ) => pattern . test ( ip ) ) ;
83+ const bare = ip . split ( '%' ) [ 0 ] ;
84+
85+ return PRIVATE_IP_PATTERNS . some ( ( pattern ) => pattern . test ( bare ) ) ;
4486}
4587
4688/**
47- * Deliverer sends JSON POST requests to external webhook endpoints
89+ * Checks whether a hostname is in the blocked list
90+ *
91+ * @param hostname - hostname to check
92+ */
93+ function isBlockedHostname ( hostname : string ) : boolean {
94+ return BLOCKED_HOSTNAMES . some ( ( pattern ) => pattern . test ( hostname ) ) ;
95+ }
96+
97+ /**
98+ * Resolves hostname to all IPs, validates every one is public,
99+ * and returns the first safe address to pin the request to.
100+ * Throws if any address is private or DNS fails.
101+ *
102+ * @param hostname - hostname to resolve
103+ */
104+ async function resolveAndValidate ( hostname : string ) : Promise < string > {
105+ const results = await dns . promises . lookup ( hostname , { all : true } ) ;
106+
107+ for ( const { address } of results ) {
108+ if ( isPrivateIP ( address ) ) {
109+ throw new Error ( `resolves to private IP ${ address } ` ) ;
110+ }
111+ }
112+
113+ return results [ 0 ] . address ;
114+ }
115+
116+ /**
117+ * Deliverer sends JSON POST requests to external webhook endpoints.
118+ *
119+ * SSRF mitigations:
120+ * - Protocol whitelist (http/https only)
121+ * - Port whitelist (80/443 only)
122+ * - Hostname blocklist (localhost, *.local, *.internal, *.lan)
123+ * - Private IP detection for raw IPs in URL
124+ * - DNS resolution with `all: true` — every A/AAAA record checked
125+ * - Request pinned to resolved IP (prevents DNS rebinding)
126+ * - SNI preserved via `servername` for HTTPS
127+ * - Redirects explicitly rejected (3xx + Location)
48128 */
49129export default class WebhookDeliverer {
50130 /**
@@ -67,7 +147,7 @@ export default class WebhookDeliverer {
67147
68148 /**
69149 * Sends webhook delivery to the endpoint via HTTP POST.
70- * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event) .
150+ * Pins the connection to a validated IP to prevent DNS rebinding .
71151 *
72152 * @param endpoint - URL to POST to
73153 * @param delivery - webhook delivery { type, payload }
@@ -82,24 +162,34 @@ export default class WebhookDeliverer {
82162 return ;
83163 }
84164
85- const hostname = url . hostname ;
165+ const requestedPort = url . port ? Number ( url . port ) : ALLOWED_PORTS [ url . protocol ] ;
86166
87- if ( isPrivateIP ( hostname ) ) {
88- this . logger . log ( 'error' , `Webhook blocked — private IP in URL: ${ endpoint } ` ) ;
167+ if ( requestedPort !== ALLOWED_PORTS [ url . protocol ] ) {
168+ this . logger . log ( 'error' , `Webhook blocked — port ${ requestedPort } not allowed for ${ endpoint } ` ) ;
89169
90170 return ;
91171 }
92172
93- try {
94- const { address } = await dns . promises . lookup ( hostname ) ;
173+ const originalHostname = url . hostname ;
174+
175+ if ( isBlockedHostname ( originalHostname ) ) {
176+ this . logger . log ( 'error' , `Webhook blocked — hostname "${ originalHostname } " is in blocklist` ) ;
177+
178+ return ;
179+ }
180+
181+ if ( isPrivateIP ( originalHostname ) ) {
182+ this . logger . log ( 'error' , `Webhook blocked — private IP in URL: ${ endpoint } ` ) ;
183+
184+ return ;
185+ }
95186
96- if ( isPrivateIP ( address ) ) {
97- this . logger . log ( 'error' , `Webhook blocked — ${ hostname } resolves to private IP ${ address } ` ) ;
187+ let pinnedAddress : string ;
98188
99- return ;
100- }
189+ try {
190+ pinnedAddress = await resolveAndValidate ( originalHostname ) ;
101191 } catch ( e ) {
102- this . logger . log ( 'error' , `Webhook blocked — DNS lookup failed for ${ hostname } : ${ ( e as Error ) . message } ` ) ;
192+ this . logger . log ( 'error' , `Webhook blocked — ${ originalHostname } ${ ( e as Error ) . message } ` ) ;
103193
104194 return ;
105195 }
@@ -108,22 +198,37 @@ export default class WebhookDeliverer {
108198
109199 return new Promise < void > ( ( resolve ) => {
110200 const req = transport . request (
111- url ,
112201 {
202+ hostname : pinnedAddress ,
203+ port : requestedPort ,
204+ path : url . pathname + url . search ,
113205 method : 'POST' ,
114206 headers : {
207+ 'Host' : originalHostname ,
115208 'Content-Type' : 'application/json' ,
116209 'User-Agent' : 'Hawk-Webhook/1.0' ,
117210 'X-Hawk-Notification' : delivery . type ,
118211 'Content-Length' : Buffer . byteLength ( body ) ,
119212 } ,
120213 timeout : DELIVERY_TIMEOUT_MS ,
214+ ...( url . protocol === 'https:'
215+ ? { servername : originalHostname , rejectUnauthorized : true }
216+ : { } ) ,
121217 } ,
122218 ( res ) => {
123219 res . resume ( ) ;
124220
125- if ( res . statusCode && res . statusCode >= HTTP_ERROR_STATUS ) {
126- this . logger . log ( 'error' , `Webhook delivery failed: ${ res . statusCode } ${ res . statusMessage } for ${ endpoint } ` ) ;
221+ const status = res . statusCode || 0 ;
222+
223+ if ( status >= REDIRECT_STATUS_MIN && status <= REDIRECT_STATUS_MAX ) {
224+ this . logger . log ( 'error' , `Webhook blocked — redirect ${ status } to ${ res . headers . location } from ${ endpoint } ` ) ;
225+ resolve ( ) ;
226+
227+ return ;
228+ }
229+
230+ if ( status >= HTTP_ERROR_STATUS ) {
231+ this . logger . log ( 'error' , `Webhook delivery failed: ${ status } ${ res . statusMessage } for ${ endpoint } ` ) ;
127232 }
128233
129234 resolve ( ) ;
0 commit comments