11import https from 'https' ;
22import http from 'http' ;
3- import dns from 'dns' ;
43import { createLogger , format , Logger , transports } from 'winston' ;
54import { WebhookDelivery } from '../types/template' ;
5+ import { HttpStatusCode , MS_IN_SEC } from '../../../lib/utils/consts' ;
66
77/**
88 * Timeout for webhook delivery in milliseconds
99 */
10- const DELIVERY_TIMEOUT_MS = 10000 ;
11-
12- /**
13- * HTTP status code threshold for error responses
14- */
15- const HTTP_ERROR_STATUS = 400 ;
16-
17- /**
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).
53- */
54- const PRIVATE_IP_PATTERNS : RegExp [ ] = [
55- / ^ 0 \. / ,
56- / ^ 1 0 \. / ,
57- / ^ 1 2 7 \. / ,
58- / ^ 1 6 9 \. 2 5 4 \. / ,
59- / ^ 1 7 2 \. ( 1 [ 6 - 9 ] | 2 \d | 3 [ 0 1 ] ) \. / ,
60- / ^ 1 9 2 \. 1 6 8 \. / ,
61- / ^ 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 ] \. / ,
68- / ^ : : 1 $ / ,
69- / ^ : : $ / ,
70- / ^ f e 8 0 / i,
71- / ^ 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,
74- ] ;
75-
76- /**
77- * Checks whether an IPv4 or IPv6 address belongs to a private/reserved range.
78- * Handles plain IPv4, IPv6, and IPv4-mapped IPv6 (::ffff:x.x.x.x).
79- *
80- * @param ip - IP address string (v4 or v6)
81- */
82- export function isPrivateIP ( ip : string ) : boolean {
83- const bare = ip . split ( '%' ) [ 0 ] ;
84-
85- return PRIVATE_IP_PATTERNS . some ( ( pattern ) => pattern . test ( bare ) ) ;
86- }
87-
88- /**
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- }
10+ const DELIVERY_TIMEOUT_MS = MS_IN_SEC * 10 ;
11511
11612/**
11713 * Deliverer sends JSON POST requests to external webhook endpoints.
11814 *
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)
15+ * SSRF validation is performed at the API layer when the endpoint is saved —
16+ * this class trusts the stored URL and only handles delivery.
12817 */
12918export default class WebhookDeliverer {
13019 /**
@@ -147,87 +36,42 @@ export default class WebhookDeliverer {
14736
14837 /**
14938 * Sends webhook delivery to the endpoint via HTTP POST.
150- * Pins the connection to a validated IP to prevent DNS rebinding .
39+ * Adds X-Hawk-Notification header with the notification type (similar to GitHub's X-GitHub-Event) .
15140 *
15241 * @param endpoint - URL to POST to
15342 * @param delivery - webhook delivery { type, payload }
15443 */
15544 public async deliver ( endpoint : string , delivery : WebhookDelivery ) : Promise < void > {
15645 const body = JSON . stringify ( delivery ) ;
15746 const url = new URL ( endpoint ) ;
158-
159- if ( url . protocol !== 'https:' && url . protocol !== 'http:' ) {
160- this . logger . log ( 'error' , `Webhook blocked — unsupported protocol: ${ url . protocol } for ${ endpoint } ` ) ;
161-
162- return ;
163- }
164-
165- const requestedPort = url . port ? Number ( url . port ) : ALLOWED_PORTS [ url . protocol ] ;
166-
167- if ( requestedPort !== ALLOWED_PORTS [ url . protocol ] ) {
168- this . logger . log ( 'error' , `Webhook blocked — port ${ requestedPort } not allowed for ${ endpoint } ` ) ;
169-
170- return ;
171- }
172-
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- }
186-
187- let pinnedAddress : string ;
188-
189- try {
190- pinnedAddress = await resolveAndValidate ( originalHostname ) ;
191- } catch ( e ) {
192- this . logger . log ( 'error' , `Webhook blocked — ${ originalHostname } ${ ( e as Error ) . message } ` ) ;
193-
194- return ;
195- }
196-
19747 const transport = url . protocol === 'https:' ? https : http ;
19848
19949 return new Promise < void > ( ( resolve ) => {
20050 const req = transport . request (
51+ url ,
20152 {
202- hostname : pinnedAddress ,
203- port : requestedPort ,
204- path : url . pathname + url . search ,
20553 method : 'POST' ,
20654 headers : {
207- 'Host' : originalHostname ,
20855 'Content-Type' : 'application/json' ,
20956 'User-Agent' : 'Hawk-Webhook/1.0' ,
21057 'X-Hawk-Notification' : delivery . type ,
21158 'Content-Length' : Buffer . byteLength ( body ) ,
21259 } ,
21360 timeout : DELIVERY_TIMEOUT_MS ,
214- ...( url . protocol === 'https:'
215- ? { servername : originalHostname , rejectUnauthorized : true }
216- : { } ) ,
21761 } ,
21862 ( res ) => {
21963 res . resume ( ) ;
22064
22165 const status = res . statusCode || 0 ;
22266
223- if ( status >= REDIRECT_STATUS_MIN && status <= REDIRECT_STATUS_MAX ) {
67+ if ( status >= HttpStatusCode . MultipleChoices && status <= HttpStatusCode . PermanentRedirect ) {
22468 this . logger . log ( 'error' , `Webhook blocked — redirect ${ status } to ${ res . headers . location } from ${ endpoint } ` ) ;
22569 resolve ( ) ;
22670
22771 return ;
22872 }
22973
230- if ( status >= HTTP_ERROR_STATUS ) {
74+ if ( status >= HttpStatusCode . BadRequest ) {
23175 this . logger . log ( 'error' , `Webhook delivery failed: ${ status } ${ res . statusMessage } for ${ endpoint } ` ) ;
23276 }
23377
0 commit comments