Skip to content

Commit 38ebc9b

Browse files
committed
Refactor webhook endpoint validation by removing isPrivateIP function and related tests. Integrate private IP validation into ipValidator module for improved code organization.
1 parent 9a7ddf5 commit 38ebc9b

File tree

4 files changed

+176
-175
lines changed

4 files changed

+176
-175
lines changed

src/utils/ipValidator.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Regex patterns matching private/reserved IP ranges:
3+
*
4+
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
5+
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
6+
* 255.255.255.255 (broadcast), 224-239.x (multicast),
7+
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
8+
*
9+
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
10+
*
11+
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
12+
*/
13+
const PRIVATE_IP_PATTERNS: RegExp[] = [
14+
/^0\./,
15+
/^10\./,
16+
/^127\./,
17+
/^169\.254\./,
18+
/^172\.(1[6-9]|2\d|3[01])\./,
19+
/^192\.168\./,
20+
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
21+
/^255\.255\.255\.255$/,
22+
/^2(2[4-9]|3\d)\./,
23+
/^192\.0\.2\./,
24+
/^198\.51\.100\./,
25+
/^203\.0\.113\./,
26+
/^198\.1[89]\./,
27+
/^::1$/,
28+
/^::$/,
29+
/^fe80/i,
30+
/^f[cd]/i,
31+
/^ff[0-9a-f]{2}:/i,
32+
/^::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,
33+
];
34+
35+
/**
36+
* Checks whether an IP address belongs to a private/reserved range.
37+
* Strips zone ID before matching (e.g. fe80::1%lo0).
38+
*
39+
* @param ip - IP address string (v4 or v6)
40+
*/
41+
export function isPrivateIP(ip: string): boolean {
42+
const bare = ip.split('%')[0];
43+
44+
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
45+
}

src/utils/webhookEndpointValidator.ts

Lines changed: 1 addition & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,38 +1,5 @@
11
import dns from 'dns';
2-
3-
/**
4-
* Regex patterns matching private/reserved IP ranges:
5-
*
6-
* IPv4: 0.x (current-network), 10.x, 172.16-31.x, 192.168.x (RFC1918),
7-
* 127.x (loopback), 169.254.x (link-local/metadata), 100.64-127.x (CGN/RFC6598),
8-
* 255.255.255.255 (broadcast), 224-239.x (multicast),
9-
* 192.0.2.x, 198.51.100.x, 203.0.113.x (documentation), 198.18-19.x (benchmarking).
10-
*
11-
* IPv6: ::1, ::, fe80 (link-local), fc/fd (ULA), ff (multicast).
12-
*
13-
* Also handles IPv4-mapped IPv6 (::ffff:A.B.C.D) and zone IDs (fe80::1%lo0).
14-
*/
15-
const PRIVATE_IP_PATTERNS: RegExp[] = [
16-
/^0\./,
17-
/^10\./,
18-
/^127\./,
19-
/^169\.254\./,
20-
/^172\.(1[6-9]|2\d|3[01])\./,
21-
/^192\.168\./,
22-
/^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
23-
/^255\.255\.255\.255$/,
24-
/^2(2[4-9]|3\d)\./,
25-
/^192\.0\.2\./,
26-
/^198\.51\.100\./,
27-
/^203\.0\.113\./,
28-
/^198\.1[89]\./,
29-
/^::1$/,
30-
/^::$/,
31-
/^fe80/i,
32-
/^f[cd]/i,
33-
/^ff[0-9a-f]{2}:/i,
34-
/^::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,
35-
];
2+
import { isPrivateIP } from './ipValidator';
363

374
/**
385
* Hostnames blocked regardless of DNS resolution
@@ -53,18 +20,6 @@ const ALLOWED_PORTS: Record<string, number> = {
5320
'https:': 443,
5421
};
5522

56-
/**
57-
* Checks whether an IP address belongs to a private/reserved range.
58-
* Strips zone ID before matching (e.g. fe80::1%lo0).
59-
*
60-
* @param ip - IP address string (v4 or v6)
61-
*/
62-
export function isPrivateIP(ip: string): boolean {
63-
const bare = ip.split('%')[0];
64-
65-
return PRIVATE_IP_PATTERNS.some((pattern) => pattern.test(bare));
66-
}
67-
6823
/**
6924
* Validates a webhook endpoint URL for SSRF safety.
7025
* Returns null if valid, or an error message string if invalid.

test/utils/ipValidator.test.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
import { isPrivateIP } from '../../src/utils/ipValidator';
2+
3+
describe('isPrivateIP', () => {
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+
});
128+
});
129+
});

test/utils/webhookEndpointValidator.test.ts

Lines changed: 1 addition & 129 deletions
Original file line numberDiff line numberDiff line change
@@ -1,132 +1,4 @@
1-
import { isPrivateIP, validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator';
2-
3-
describe('isPrivateIP', () => {
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-
});
128-
});
129-
});
1+
import { validateWebhookEndpoint } from '../../src/utils/webhookEndpointValidator';
1302

1313
describe('validateWebhookEndpoint', () => {
1324
it('should reject invalid URL', async () => {

0 commit comments

Comments
 (0)