diff --git a/src/tools/privateHost.test.ts b/src/tools/privateHost.test.ts new file mode 100644 index 0000000..6209127 --- /dev/null +++ b/src/tools/privateHost.test.ts @@ -0,0 +1,70 @@ +// Private/internal-host classification test cases for the SSRF gate. +// +// Only the pure literal-classification path is covered here — privateHostReason +// falls back to a real DNS lookup for names, which a unit test must not do. + +import { describe, expect, it } from 'vitest'; +import { parseHTTPURL, privateHostReason } from './privateHost.js'; + +describe('privateHostReason (IP literals)', () => { + const cases: Array<{ host: string; want: boolean; note?: string }> = [ + // loopback / RFC1918 / link-local + { host: '127.0.0.1', want: true }, + { host: '10.1.2.3', want: true }, + { host: '172.16.0.1', want: true }, + { host: '192.168.1.1', want: true }, + { host: '169.254.169.254', want: true, note: 'cloud metadata' }, + { host: '0.0.0.0', want: true }, + + // RFC 6598 shared address space (100.64.0.0/10) + { host: '100.64.0.1', want: true, note: 'lower bound' }, + { host: '100.100.100.100', want: true, note: 'tailscale MagicDNS resolver' }, + { host: '100.127.255.255', want: true, note: 'upper bound' }, + + // adjacent public space that must NOT be flagged + { host: '100.63.255.255', want: false, note: 'just below 100.64/10' }, + { host: '100.128.0.1', want: false, note: 'just above 100.64/10' }, + { host: '8.8.8.8', want: false }, + { host: '172.32.0.1', want: false, note: 'just above RFC1918 172.16/12' }, + { host: '172.15.0.1', want: false, note: 'just below RFC1918 172.16/12' }, + + // IPv6 + { host: '::1', want: true }, + { host: 'fe80::1', want: true }, + { host: 'fd00::1', want: true }, + { host: '::ffff:127.0.0.1', want: true, note: 'IPv4-mapped loopback' }, + { host: '2606:4700:4700::1111', want: false }, + ]; + + for (const { host, want, note } of cases) { + const label = note ? `${host} (${note})` : host; + it(`${want ? 'flags' : 'allows'} ${label}`, async () => { + const reason = await privateHostReason(host); + expect(Boolean(reason)).toBe(want); + }); + } + + it('normalizes a trailing FQDN dot', async () => { + expect(await privateHostReason('localhost.')).toBeTruthy(); + }); + + it('normalizes bracketed IPv6', async () => { + expect(await privateHostReason('[::1]')).toBeTruthy(); + }); +}); + +describe('parseHTTPURL', () => { + it('accepts http and https', () => { + expect(parseHTTPURL('http://example.com/').protocol).toBe('http:'); + expect(parseHTTPURL('https://example.com/').protocol).toBe('https:'); + }); + + it('rejects non-http(s) schemes', () => { + expect(() => parseHTTPURL('file:///etc/passwd')).toThrow(/unsupported URL scheme/); + expect(() => parseHTTPURL('gopher://example.com/')).toThrow(/unsupported URL scheme/); + }); + + it('rejects a malformed URL', () => { + expect(() => parseHTTPURL('not a url')).toThrow(/invalid URL/); + }); +}); diff --git a/src/tools/privateHost.ts b/src/tools/privateHost.ts index a67be88..c4e696d 100644 --- a/src/tools/privateHost.ts +++ b/src/tools/privateHost.ts @@ -104,6 +104,12 @@ function privateIPv4Reason(host: string): string { if (a === 169 && b === 254) return 'link-local/metadata IPv4'; if (a === 172 && b >= 16 && b <= 31) return 'RFC1918 private IPv4'; if (a === 192 && b === 168) return 'RFC1918 private IPv4'; + // RFC 6598 shared address space (100.64.0.0/10). Not publicly routable, and in + // practice this is where real internal infrastructure lives: Tailscale/WireGuard + // overlays (incl. the 100.100.100.100 MagicDNS resolver), carrier-grade NAT, and + // several cloud providers' internal service endpoints. Without this a web_fetch + // at a tailnet host is reached silently, with no gate prompt. + if (a === 100 && b !== undefined && b >= 64 && b <= 127) return 'RFC6598 shared address space IPv4'; if (a === 0) return 'this-network IPv4'; return ''; }