Skip to content

Commit 6eac16f

Browse files
ihor-sokoliukclaudecodex
committed
security(url-security): block CGNAT + IANA special-purpose IPv4 ranges in SSRF guard (SEC-024)
isPrivateIpv4 blocked only RFC1918/loopback/link-local/0.0.0.0-8, so with private URLs blocked by default a web_url_read (and the DNS-rebinding lookup hook that validates every resolved answer) let CGNAT 100.64.0.0/10 — Tailscale's default range, plus container overlays and ISP CGNAT — as well as TEST-NETs, benchmarking, IETF protocol assignments, multicast, and reserved/broadcast ranges through. Add a uint32 CIDR table covering the IANA special-purpose ranges (RFC 6890) and OR it into the shared predicate, closing both the literal-hostname (assertUrlAllowed) and DNS-resolved (proxy lookup hook) enforcement paths in one point. IPv4-mapped IPv6 (::ffff:) already delegates here, so it is covered too. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Co-Authored-By: Codex <noreply@openai.com>
1 parent 955135b commit 6eac16f

6 files changed

Lines changed: 112 additions & 5 deletions

File tree

CONFIGURATION.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,7 @@ Opt-in security layer for when you expose the HTTP transport on a network. Defau
164164

165165
## URL Reader Security
166166

167-
`web_url_read` blocks private/internal URLs by default in all transport modes. This includes localhost, loopback addresses, private IPv4 ranges, link-local addresses, `0.0.0.0/8`, IPv6 loopback/ULA/link-local addresses, and IPv4-mapped IPv6 private addresses.
167+
`web_url_read` blocks private/internal URLs by default in all transport modes. This includes localhost, loopback addresses, private IPv4 ranges, link-local addresses, `0.0.0.0/8`, CGNAT (`100.64.0.0/10`), IANA special-purpose IPv4 ranges, IPv6 loopback/ULA/link-local addresses, and IPv4-mapped IPv6 private addresses.
168168

169169
Redirects are also checked before they are followed. A public URL that redirects to a private/internal URL is blocked.
170170

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`), and unspecified (`0.0.0.0/8`) 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`), 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__/run-all.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { runTests as runSearchTests } from './unit/search.test.js';
2020
import { runTests as runSuggestionsTests } from './unit/suggestions.test.js';
2121
import { runTests as runInstanceInfoTests } from './unit/instance-info.test.js';
2222
import { runTests as runUrlReaderTests } from './unit/url-reader.test.js';
23+
import { runTests as runUrlSecurityTests } from './unit/url-security.test.js';
2324
import { runTests as runTlsConfigTests } from './unit/tls-config.test.js';
2425
import { runTests as runHttpServerUnitTests } from './unit/http-server.test.js';
2526
import { runTests as runVersionTests } from './unit/version.test.js';
@@ -49,6 +50,7 @@ const testSuites: TestSuite[] = [
4950
{ name: 'Suggestions', category: 'unit', run: runSuggestionsTests },
5051
{ name: 'Instance Info', category: 'unit', run: runInstanceInfoTests },
5152
{ name: 'URL Reader', category: 'unit', run: runUrlReaderTests },
53+
{ name: 'URL Security', category: 'unit', run: runUrlSecurityTests },
5254
{ name: 'TLS Config', category: 'unit', run: runTlsConfigTests },
5355
{ name: 'HTTP Server', category: 'unit', run: runHttpServerUnitTests },
5456
{ name: 'Version', category: 'unit', run: runVersionTests },

__tests__/unit/url-reader.test.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ const results = createTestResults();
2727
const envManager = new EnvManager();
2828
const require = createRequire(import.meta.url);
2929
const dnsModule = require('node:dns') as typeof import('node:dns');
30-
const TEST_PUBLIC_IP = '203.0.113.10';
30+
const TEST_PUBLIC_IP = '93.184.216.34';
3131

3232
// ─── local test-server helpers ───────────────────────────────────────────────
3333

@@ -1321,6 +1321,7 @@ async function runTests() {
13211321
'lan.example': [{ address: '192.168.1.20', family: 4 }],
13221322
'rfc1918.example': [{ address: '172.16.0.9', family: 4 }],
13231323
'metadata.example': [{ address: '169.254.169.254', family: 4 }],
1324+
'cgnat.example': [{ address: '100.64.0.1', family: 4 }],
13241325
};
13251326
const restoreDns = installDnsLookupMock(privateCases);
13261327

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
#!/usr/bin/env tsx
2+
3+
import { strict as assert } from 'node:assert';
4+
import { fileURLToPath } from 'node:url';
5+
import {
6+
assertUrlAllowed,
7+
isPrivateIPv6,
8+
isPrivateIpv4,
9+
} from '../../src/url-security.js';
10+
import { testFunction, createTestResults, printTestSummary } from '../helpers/test-utils.js';
11+
import { EnvManager } from '../helpers/env-utils.js';
12+
13+
const results = createTestResults();
14+
const envManager = new EnvManager();
15+
16+
async function runTests() {
17+
console.log('🧪 Testing: url-security.ts\n');
18+
19+
await testFunction('isPrivateIpv4 blocks CGNAT boundaries', () => {
20+
assert.equal(isPrivateIpv4('100.64.0.1'), true);
21+
assert.equal(isPrivateIpv4('100.127.255.255'), true);
22+
assert.equal(isPrivateIpv4('100.63.255.255'), false);
23+
assert.equal(isPrivateIpv4('100.128.0.0'), false);
24+
}, results);
25+
26+
await testFunction('isPrivateIpv4 blocks benchmarking boundaries', () => {
27+
assert.equal(isPrivateIpv4('198.18.0.1'), true);
28+
assert.equal(isPrivateIpv4('198.19.255.255'), true);
29+
assert.equal(isPrivateIpv4('198.20.0.0'), false);
30+
}, results);
31+
32+
await testFunction('isPrivateIpv4 blocks multicast and reserved ranges', () => {
33+
assert.equal(isPrivateIpv4('224.0.0.1'), true);
34+
assert.equal(isPrivateIpv4('239.255.255.255'), true);
35+
assert.equal(isPrivateIpv4('240.0.0.1'), true);
36+
assert.equal(isPrivateIpv4('255.255.255.255'), true);
37+
}, results);
38+
39+
await testFunction('isPrivateIpv4 blocks IANA special-purpose documentation ranges', () => {
40+
assert.equal(isPrivateIpv4('192.0.0.1'), true);
41+
assert.equal(isPrivateIpv4('192.0.2.5'), true);
42+
assert.equal(isPrivateIpv4('198.51.100.5'), true);
43+
assert.equal(isPrivateIpv4('203.0.113.5'), true);
44+
}, results);
45+
46+
await testFunction('isPrivateIpv4 allows public control addresses', () => {
47+
assert.equal(isPrivateIpv4('8.8.8.8'), false);
48+
assert.equal(isPrivateIpv4('1.1.1.1'), false);
49+
assert.equal(isPrivateIpv4('100.128.0.5'), false);
50+
}, results);
51+
52+
await testFunction('isPrivateIPv6 delegates IPv4-mapped CGNAT addresses to IPv4 check', () => {
53+
assert.equal(isPrivateIPv6('::ffff:100.64.0.1'), true);
54+
}, results);
55+
56+
await testFunction('assertUrlAllowed blocks CGNAT by default and honors private URL override', () => {
57+
envManager.delete('MCP_HTTP_HARDEN');
58+
envManager.delete('MCP_HTTP_ALLOW_PRIVATE_URLS');
59+
60+
try {
61+
assert.throws(
62+
() => assertUrlAllowed(new URL('http://100.64.0.1/')),
63+
/blocked by security policy/,
64+
);
65+
66+
envManager.set('MCP_HTTP_ALLOW_PRIVATE_URLS', 'true');
67+
assert.doesNotThrow(() => assertUrlAllowed(new URL('http://100.64.0.1/')));
68+
} finally {
69+
envManager.restore();
70+
}
71+
}, results);
72+
73+
printTestSummary(results, 'URL Security Module');
74+
return results;
75+
}
76+
77+
if (process.argv[1] !== undefined && fileURLToPath(import.meta.url) === process.argv[1]) {
78+
runTests().then(results => {
79+
process.exit(results.failed > 0 ? 1 : 0);
80+
}).catch(console.error);
81+
}
82+
83+
export { runTests };

src/url-security.ts

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,19 +9,40 @@ export function isPrivateHostname(hostname: string): boolean {
99
return lower === "localhost" || lower.endsWith(".localhost");
1010
}
1111

12+
function ipv4ToInt(ip: string): number {
13+
return ip.split(".").reduce((acc, octet) => (acc << 8) + Number(octet), 0) >>> 0;
14+
}
15+
16+
// IANA special-purpose IPv4 ranges beyond the existing RFC1918/link-local checks.
17+
const BLOCKED_V4_CIDRS: [number, number][] = [
18+
[ipv4ToInt("100.64.0.0"), 10], // CGNAT (RFC 6598) - Tailscale default, overlays
19+
[ipv4ToInt("192.0.0.0"), 24], // IETF protocol assignments
20+
[ipv4ToInt("192.0.2.0"), 24], // TEST-NET-1
21+
[ipv4ToInt("198.18.0.0"), 15], // benchmarking (RFC 2544)
22+
[ipv4ToInt("198.51.100.0"), 24], // TEST-NET-2
23+
[ipv4ToInt("203.0.113.0"), 24], // TEST-NET-3
24+
[ipv4ToInt("224.0.0.0"), 4], // multicast
25+
[ipv4ToInt("240.0.0.0"), 4], // reserved / 255.255.255.255 broadcast
26+
];
27+
1228
export function isPrivateIpv4(hostname: string): boolean {
1329
if (isIP(hostname) !== 4) {
1430
return false;
1531
}
1632

17-
return (
33+
if (
1834
hostname.startsWith("0.") ||
1935
hostname.startsWith("10.") ||
2036
hostname.startsWith("127.") ||
2137
hostname.startsWith("192.168.") ||
2238
/^172\.(1[6-9]|2\d|3[0-1])\./.test(hostname) ||
2339
hostname.startsWith("169.254.")
24-
);
40+
) {
41+
return true;
42+
}
43+
44+
const ip = ipv4ToInt(hostname);
45+
return BLOCKED_V4_CIDRS.some(([net, bits]) => ((ip ^ net) >>> (32 - bits)) === 0);
2546
}
2647

2748
export function isPrivateIPv6(hostname: string): boolean {

0 commit comments

Comments
 (0)