Skip to content

Commit ac6dd3f

Browse files
ihor-sokoliukclaude
andcommitted
refactor(url-security): consolidate blocked IPv4 ranges into one CIDR table (SEC-024)
Addresses Codacy review on PR #147: fold RFC1918/loopback/link-local/unspecified into BLOCKED_V4_CIDRS so every blocked range is enforced by the same integer match and the full blocklist is auditable at a glance, instead of a mix of startsWith, a 172.16-31 regex, and the bitwise loop. isPrivateIpv4 is now just the isIP guard plus the CIDR match. Also add 6to4 relay anycast (192.88.99.0/24, RFC 7526). Behavior-preserving for the folded ranges (added both-sided boundary tests to prove it; existing url-reader DNS tests for 127/10/192.168/172.16/169.254 still assert blocking). SECURITY.md updated with 192.88.99.0/24. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 6eac16f commit ac6dd3f

3 files changed

Lines changed: 43 additions & 13 deletions

File tree

SECURITY.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ The primary security surface areas are:
4545
Private and internal URLs are **blocked by default** in all transport modes. The following are rejected:
4646

4747
- `localhost` and `*.localhost`
48-
- IPv4 loopback (`127.0.0.0/8`), private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), link-local (`169.254.0.0/16`), unspecified (`0.0.0.0/8`), CGNAT (`100.64.0.0/10`), IETF protocol assignments (`192.0.0.0/24`), documentation/test ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), benchmarking (`198.18.0.0/15`), multicast (`224.0.0.0/4`), and reserved/broadcast (`240.0.0.0/4`) ranges
48+
- IPv4 loopback (`127.0.0.0/8`), private (`10.0.0.0/8`, `172.16.0.0/12`, `192.168.0.0/16`), link-local (`169.254.0.0/16`), unspecified (`0.0.0.0/8`), CGNAT (`100.64.0.0/10`), IETF protocol assignments (`192.0.0.0/24`), 6to4 relay anycast (`192.88.99.0/24`), documentation/test ranges (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), benchmarking (`198.18.0.0/15`), multicast (`224.0.0.0/4`), and reserved/broadcast (`240.0.0.0/4`) ranges
4949
- IPv6 loopback (`::1`), unspecified (`::`), ULA (`fc00::/7`), link-local (`fe80::/10`)
5050
- IPv4-mapped IPv6 addresses that resolve to any of the above (e.g. `::ffff:127.0.0.1`)
5151
- Redirects are validated **before** they are followed — a public URL that redirects to a private address is also blocked

__tests__/unit/url-security.test.ts

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,38 @@ async function runTests() {
4949
assert.equal(isPrivateIpv4('100.128.0.5'), false);
5050
}, results);
5151

52+
await testFunction('isPrivateIpv4 blocks RFC1918, loopback, link-local, and unspecified ranges', () => {
53+
// unspecified 0.0.0.0/8
54+
assert.equal(isPrivateIpv4('0.0.0.0'), true);
55+
assert.equal(isPrivateIpv4('0.255.255.255'), true);
56+
// 10.0.0.0/8 boundaries
57+
assert.equal(isPrivateIpv4('10.0.0.1'), true);
58+
assert.equal(isPrivateIpv4('9.255.255.255'), false);
59+
assert.equal(isPrivateIpv4('11.0.0.0'), false);
60+
// loopback 127.0.0.0/8
61+
assert.equal(isPrivateIpv4('127.0.0.1'), true);
62+
assert.equal(isPrivateIpv4('126.255.255.255'), false);
63+
// link-local 169.254.0.0/16 boundaries
64+
assert.equal(isPrivateIpv4('169.254.169.254'), true);
65+
assert.equal(isPrivateIpv4('169.253.255.255'), false);
66+
assert.equal(isPrivateIpv4('169.255.0.0'), false);
67+
// RFC1918 172.16.0.0/12 boundaries
68+
assert.equal(isPrivateIpv4('172.16.0.0'), true);
69+
assert.equal(isPrivateIpv4('172.31.255.255'), true);
70+
assert.equal(isPrivateIpv4('172.15.255.255'), false);
71+
assert.equal(isPrivateIpv4('172.32.0.0'), false);
72+
// RFC1918 192.168.0.0/16 boundaries
73+
assert.equal(isPrivateIpv4('192.168.0.1'), true);
74+
assert.equal(isPrivateIpv4('192.167.255.255'), false);
75+
assert.equal(isPrivateIpv4('192.169.0.0'), false);
76+
}, results);
77+
78+
await testFunction('isPrivateIpv4 blocks 6to4 relay anycast (192.88.99.0/24)', () => {
79+
assert.equal(isPrivateIpv4('192.88.99.1'), true);
80+
assert.equal(isPrivateIpv4('192.88.98.255'), false);
81+
assert.equal(isPrivateIpv4('192.88.100.0'), false);
82+
}, results);
83+
5284
await testFunction('isPrivateIPv6 delegates IPv4-mapped CGNAT addresses to IPv4 check', () => {
5385
assert.equal(isPrivateIPv6('::ffff:100.64.0.1'), true);
5486
}, results);

src/url-security.ts

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -13,11 +13,20 @@ function ipv4ToInt(ip: string): number {
1313
return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
1414
}
1515

16-
// IANA special-purpose IPv4 ranges beyond the existing RFC1918/link-local checks.
16+
// Blocked IPv4 ranges — RFC1918 private space plus IANA special-purpose ranges
17+
// (RFC 6890). Kept as a single CIDR table so every range is enforced by the same
18+
// integer match and the full blocklist is auditable at a glance. Sorted by network.
1719
const BLOCKED_V4_CIDRS: [number, number][] = [
20+
[ipv4ToInt("0.0.0.0"), 8], // "this" network / unspecified
21+
[ipv4ToInt("10.0.0.0"), 8], // RFC1918 private
1822
[ipv4ToInt("100.64.0.0"), 10], // CGNAT (RFC 6598) - Tailscale default, overlays
23+
[ipv4ToInt("127.0.0.0"), 8], // loopback
24+
[ipv4ToInt("169.254.0.0"), 16], // link-local
25+
[ipv4ToInt("172.16.0.0"), 12], // RFC1918 private
1926
[ipv4ToInt("192.0.0.0"), 24], // IETF protocol assignments
2027
[ipv4ToInt("192.0.2.0"), 24], // TEST-NET-1
28+
[ipv4ToInt("192.88.99.0"), 24], // 6to4 relay anycast (RFC 7526, deprecated)
29+
[ipv4ToInt("192.168.0.0"), 16], // RFC1918 private
2130
[ipv4ToInt("198.18.0.0"), 15], // benchmarking (RFC 2544)
2231
[ipv4ToInt("198.51.100.0"), 24], // TEST-NET-2
2332
[ipv4ToInt("203.0.113.0"), 24], // TEST-NET-3
@@ -30,17 +39,6 @@ export function isPrivateIpv4(hostname: string): boolean {
3039
return false;
3140
}
3241

33-
if (
34-
hostname.startsWith("0.") ||
35-
hostname.startsWith("10.") ||
36-
hostname.startsWith("127.") ||
37-
hostname.startsWith("192.168.") ||
38-
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
39-
hostname.startsWith("169.254.")
40-
) {
41-
return true;
42-
}
43-
4442
const ip = ipv4ToInt(hostname);
4543
return BLOCKED_V4_CIDRS.some(([net, bits]) => ((ip ^ net) >>> (32 - bits)) === 0);
4644
}

0 commit comments

Comments
 (0)