Skip to content

Commit ca2cf91

Browse files
committed
feat(webhook): implement SSRF mitigations and enhance private IP checks for webhook delivery
1 parent 746efc1 commit ca2cf91

File tree

2 files changed

+249
-56
lines changed

2 files changed

+249
-56
lines changed

workers/webhook/src/deliverer.ts

Lines changed: 125 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,41 @@ const DELIVERY_TIMEOUT_MS = 10000;
1515
const 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+
/^localhost$/i,
36+
/\.local$/i,
37+
/\.internal$/i,
38+
/\.lan$/i,
39+
/\.localdomain$/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
*/
2254
const PRIVATE_IP_PATTERNS: RegExp[] = [
2355
/^0\./,
@@ -27,24 +59,72 @@ const PRIVATE_IP_PATTERNS: RegExp[] = [
2759
/^172\.(1[6-9]|2\d|3[01])\./,
2860
/^192\.168\./,
2961
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
62+
/^255\.255\.255\.255$/,
63+
/^2(2[4-9]|3\d)\./,
64+
/^192\.0\.2\./,
65+
/^198\.51\.100\./,
66+
/^203\.0\.113\./,
67+
/^198\.1[89]\./,
3068
/^::1$/,
3169
/^::$/,
3270
/^fe80/i,
3371
/^f[cd]/i,
72+
/^ff[0-9a-f]{2}:/i,
73+
/^::ffff:(0\.|10\.|127\.|169\.254\.|172\.(1[6-9]|2\d|3[01])\.|192\.168\.|100\.(6[4-9]|[7-9]\d|1[01]\d|12[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
*/
4282
export 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
*/
49129
export 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();
Lines changed: 124 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,129 @@
11
import { isPrivateIP } from '../src/deliverer';
22

33
describe('isPrivateIP', () => {
4-
it.each([
5-
['127.0.0.1', true],
6-
['127.255.255.255', true],
7-
['10.0.0.1', true],
8-
['10.255.255.255', true],
9-
['0.0.0.0', true],
10-
['172.16.0.1', true],
11-
['172.31.255.255', true],
12-
['192.168.0.1', true],
13-
['192.168.255.255', true],
14-
['169.254.1.1', true],
15-
['169.254.169.254', true],
16-
['100.64.0.1', true],
17-
['100.127.255.255', true],
18-
['::1', true],
19-
['::', true],
20-
['fe80::1', true],
21-
['fc00::1', true],
22-
['fd12:3456::1', true],
23-
])('should block private/reserved IP %s', (ip: string, expected: boolean) => {
24-
expect(isPrivateIP(ip)).toBe(expected);
25-
});
26-
27-
it.each([
28-
['8.8.8.8', false],
29-
['1.1.1.1', false],
30-
['93.184.216.34', false],
31-
['172.32.0.1', false],
32-
['172.15.255.255', false],
33-
['192.169.0.1', false],
34-
['100.128.0.1', false],
35-
['100.63.255.255', false],
36-
['169.255.0.1', false],
37-
['2001:db8::1', false],
38-
])('should allow public IP %s', (ip: string, expected: boolean) => {
39-
expect(isPrivateIP(ip)).toBe(expected);
4+
describe('should block private/reserved IPv4', () => {
5+
it.each([
6+
['127.0.0.1'],
7+
['127.255.255.255'],
8+
['10.0.0.1'],
9+
['10.255.255.255'],
10+
['0.0.0.0'],
11+
['172.16.0.1'],
12+
['172.31.255.255'],
13+
['192.168.0.1'],
14+
['192.168.255.255'],
15+
['169.254.1.1'],
16+
['169.254.169.254'],
17+
['100.64.0.1'],
18+
['100.127.255.255'],
19+
])('%s', (ip) => {
20+
expect(isPrivateIP(ip)).toBe(true);
21+
});
22+
});
23+
24+
describe('should block broadcast and multicast IPv4', () => {
25+
it.each([
26+
['255.255.255.255'],
27+
['224.0.0.1'],
28+
['239.255.255.255'],
29+
['230.1.2.3'],
30+
])('%s', (ip) => {
31+
expect(isPrivateIP(ip)).toBe(true);
32+
});
33+
});
34+
35+
describe('should block documentation and benchmarking IPv4', () => {
36+
it.each([
37+
['192.0.2.1'],
38+
['198.51.100.1'],
39+
['203.0.113.1'],
40+
['198.18.0.1'],
41+
['198.19.255.255'],
42+
])('%s', (ip) => {
43+
expect(isPrivateIP(ip)).toBe(true);
44+
});
45+
});
46+
47+
describe('should block private/reserved IPv6', () => {
48+
it.each([
49+
['::1'],
50+
['::'],
51+
['fe80::1'],
52+
['FE80::abc'],
53+
['fc00::1'],
54+
['fd12:3456::1'],
55+
])('%s', (ip) => {
56+
expect(isPrivateIP(ip)).toBe(true);
57+
});
58+
});
59+
60+
describe('should block IPv6 multicast', () => {
61+
it.each([
62+
['ff02::1'],
63+
['ff05::2'],
64+
['FF0E::1'],
65+
])('%s', (ip) => {
66+
expect(isPrivateIP(ip)).toBe(true);
67+
});
68+
});
69+
70+
describe('should block IPv6 with zone ID', () => {
71+
it.each([
72+
['fe80::1%lo0'],
73+
['fe80::1%eth0'],
74+
['::1%lo0'],
75+
])('%s', (ip) => {
76+
expect(isPrivateIP(ip)).toBe(true);
77+
});
78+
});
79+
80+
describe('should block IPv4-mapped IPv6', () => {
81+
it.each([
82+
['::ffff:127.0.0.1'],
83+
['::ffff:10.0.0.1'],
84+
['::ffff:192.168.1.1'],
85+
['::ffff:172.16.0.1'],
86+
['::ffff:169.254.169.254'],
87+
['::ffff:100.64.0.1'],
88+
['::ffff:0.0.0.0'],
89+
['::FFFF:127.0.0.1'],
90+
])('%s', (ip) => {
91+
expect(isPrivateIP(ip)).toBe(true);
92+
});
93+
});
94+
95+
describe('should allow public IPv4', () => {
96+
it.each([
97+
['8.8.8.8'],
98+
['1.1.1.1'],
99+
['93.184.216.34'],
100+
['172.32.0.1'],
101+
['172.15.255.255'],
102+
['192.169.0.1'],
103+
['100.128.0.1'],
104+
['100.63.255.255'],
105+
['169.255.0.1'],
106+
['223.255.255.255'],
107+
])('%s', (ip) => {
108+
expect(isPrivateIP(ip)).toBe(false);
109+
});
110+
});
111+
112+
describe('should allow public IPv6', () => {
113+
it.each([
114+
['2001:db8::1'],
115+
['2606:4700::1'],
116+
])('%s', (ip) => {
117+
expect(isPrivateIP(ip)).toBe(false);
118+
});
119+
});
120+
121+
describe('should allow public IPv4-mapped IPv6', () => {
122+
it.each([
123+
['::ffff:8.8.8.8'],
124+
['::ffff:93.184.216.34'],
125+
])('%s', (ip) => {
126+
expect(isPrivateIP(ip)).toBe(false);
127+
});
40128
});
41129
});

0 commit comments

Comments
 (0)