/** * DC-068: Fleet hostname SSRF hardening * * Tests for the fleet validation helpers (isPrivateOrReservedIPv4/IPv6, * isValidHostnameSyntax, validateFleetHost) and resolveAndCheckAddress. * Covers: * - IPv4 private/reserved range detection (loopback, link-local, RFC 1918, * CGNAT, multicast, broadcast, documentation) * - IPv6 private/reserved range detection (loopback, link-local, ULA, * multicast, IPv4-mapped) * - RFC 1123 hostname syntax check * - Port bounds (1..65535), port 22 rejection, missing/invalid port * - Tag validation (max 20, each 1..50, no control chars) * - Name validation (1..100, no control chars) * - End-to-end validateFleetHost for all rejection and acceptance paths * - resolveAndCheckAddress: literal IP paths, DNS-resolution success path * with mocked dns.lookup, DNS-resolution failure path, and the * allow-private opt-in * * The DNS path is unit-tested by replacing `dns.promises.lookup` on the * module instance with a mock that returns a fake A record. */ const { validateFleetHost, resolveAndCheckAddress, isPrivateOrReservedIPv4, isPrivateOrReservedIPv6, isValidHostnameSyntax, } = require('../src/utilities/fleet-validation'); describe('DC-068: isPrivateOrReservedIPv4', () => { const cases = [ // [ip, expectedIsPrivate, expectedLabelSubstring-or-null] ['127.0.0.1', true, 'loopback'], ['127.255.255.1', true, 'loopback'], ['169.254.0.1', true, 'link-local'], ['169.254.169.254',true, 'link-local'], // AWS/GCP/Azure metadata ['10.0.0.1', true, 'RFC 1918'], ['172.16.0.1', true, 'RFC 1918'], ['172.31.255.1', true, 'RFC 1918'], ['172.32.0.1', false, null], ['192.168.1.1', true, 'RFC 1918'], ['100.64.0.1', true, 'CGNAT'], ['100.127.255.1', true, 'CGNAT'], ['100.128.0.1', false, null], ['224.0.0.1', true, 'multicast'], ['239.255.255.255',true, 'multicast'], ['255.255.255.255',true, 'broadcast'], ['0.0.0.0', true, 'reserved'], ['192.0.2.1', true, 'TEST-NET-1'], ['198.51.100.1', true, 'TEST-NET-2'], ['203.0.113.1', true, 'TEST-NET-3'], ['198.18.0.1', true, 'benchmark'], ['198.19.255.1', true, 'benchmark'], ['240.0.0.1', true, 'reserved'], ['8.8.8.8', false, null], ['1.1.1.1', false, null], ['93.184.216.34', false, null], ]; for (const [ip, wantPrivate, wantLabel] of cases) { it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => { const r = isPrivateOrReservedIPv4(ip); expect(r.isPrivate).toBe(wantPrivate); if (wantLabel) expect(r.label).toContain(wantLabel); else expect(r.label).toBeNull(); }); } it('returns isPrivate=false for non-strings', () => { expect(isPrivateOrReservedIPv4(null).isPrivate).toBe(false); expect(isPrivateOrReservedIPv4(undefined).isPrivate).toBe(false); expect(isPrivateOrReservedIPv4(42).isPrivate).toBe(false); }); it('returns isPrivate=false for malformed IPv4', () => { expect(isPrivateOrReservedIPv4('1.2.3').isPrivate).toBe(false); expect(isPrivateOrReservedIPv4('1.2.3.4.5').isPrivate).toBe(false); expect(isPrivateOrReservedIPv4('256.0.0.0').isPrivate).toBe(false); expect(isPrivateOrReservedIPv4('1.2.3.999').isPrivate).toBe(false); }); }); describe('DC-068: isPrivateOrReservedIPv6', () => { const cases = [ ['::1', true, 'IPv6 loopback'], ['::', true, 'IPv6 unspecified'], ['fe80::1', true, 'link-local'], ['feb0::1', true, 'link-local'], ['fc00::1', true, 'unique-local'], ['fd00::1', true, 'unique-local'], ['ff00::1', true, 'multicast'], ['::ffff:127.0.0.1',true, 'IPv4-mapped'], ['::ffff:8.8.8.8',false, null], ['2001:4860:4860::8888',false, null], // Google IPv6 ['2606:4700:4700::1111',false, null], // Cloudflare IPv6 ]; for (const [ip, wantPrivate, wantLabel] of cases) { it(`flags "${ip}" as ${wantPrivate ? 'private' : 'public'}${wantLabel ? ' (' + wantLabel + ')' : ''}`, () => { const r = isPrivateOrReservedIPv6(ip); expect(r.isPrivate).toBe(wantPrivate); if (wantLabel) expect(r.label).toContain(wantLabel); else expect(r.label).toBeNull(); }); } }); describe('DC-068: isValidHostnameSyntax', () => { const accept = [ 'example.com', 'sub.example.com', 'a-b.example.com', 'host1', 'a', 'a'.repeat(63) + '.com', // 63-char label is the max 'very-long-host-name-with-many-segments.sub.example.com', 'host-with-trailing-dot.', // trailing dot is legal 'EXAMPLE.com', // case-insensitive '123.example.com', // numeric labels allowed ]; for (const h of accept) { it(`accepts "${h}"`, () => { expect(isValidHostnameSyntax(h)).toBe(true); }); } const reject = [ '', '.', '..', 'a..b', // empty label '-a.com', // label can't start with hyphen 'a-.com', // label can't end with hyphen 'a b.com', // space not allowed '_underscore.com', // underscore not allowed (strict RFC 1123) 'a/b.com', // slash not allowed 'a$b.com', // dollar not allowed 'a.com/' + 'x'.repeat(255), // 255-char label exceeds 63 'host.with.' + 'a-63-chars-'.repeat(8) + '.com', // total > 253 chars ]; for (const h of reject) { it(`rejects "${h}"`, () => { expect(isValidHostnameSyntax(h)).toBe(false); }); } }); describe('DC-068: validateFleetHost', () => { const valid = (extra = {}) => ({ name: 'Test Host', hostname: 'fleet.example.com', port: 3001, tags: ['prod'], ...extra, }); it('accepts a clean public-DNS host', () => { const r = validateFleetHost(valid()); expect(r.ok).toBe(true); expect(r.normalized.name).toBe('Test Host'); expect(r.normalized.hostname).toBe('fleet.example.com'); expect(r.normalized.port).toBe(3001); }); it('normalises hostname to lowercase and trims name', () => { const r = validateFleetHost({ ...valid(), name: ' Spaced ', hostname: 'FLEET.Example.COM' }); expect(r.ok).toBe(true); expect(r.normalized.name).toBe('Spaced'); expect(r.normalized.hostname).toBe('fleet.example.com'); }); it('accepts a public IPv4 literal', () => { const r = validateFleetHost({ ...valid(), hostname: '8.8.8.8' }); expect(r.ok).toBe(true); }); it('accepts a public IPv6 literal', () => { const r = validateFleetHost({ ...valid(), hostname: '2001:4860:4860::8888' }); expect(r.ok).toBe(true); }); // ── Name rejection paths ── it('rejects missing name with INVALID_NAME', () => { const r = validateFleetHost({ ...valid(), name: undefined }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_NAME'); }); it('rejects empty name', () => { const r = validateFleetHost({ ...valid(), name: '' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_NAME'); }); it('rejects name >100 chars', () => { const r = validateFleetHost({ ...valid(), name: 'x'.repeat(101) }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_NAME'); }); it('rejects name with control characters', () => { expect(validateFleetHost({ ...valid(), name: 'evil\nname' }).code).toBe('INVALID_NAME'); expect(validateFleetHost({ ...valid(), name: 'evil\rname' }).code).toBe('INVALID_NAME'); expect(validateFleetHost({ ...valid(), name: 'evil\x00name' }).code).toBe('INVALID_NAME'); }); // ── Hostname rejection paths ── it('rejects missing hostname with INVALID_HOSTNAME', () => { const r = validateFleetHost({ ...valid(), hostname: undefined }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects empty hostname', () => { const r = validateFleetHost({ ...valid(), hostname: '' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects garbage hostname', () => { const r = validateFleetHost({ ...valid(), hostname: 'not a valid host' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects hostname with scheme prefix (url injection)', () => { const r = validateFleetHost({ ...valid(), hostname: 'http://evil.com' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects hostname with @ (URL-credential injection)', () => { const r = validateFleetHost({ ...valid(), hostname: 'evil@host.com' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); // ── IPv4 private-range rejection paths (literal input) ── const privateV4 = [ ['127.0.0.1', 'loopback'], ['169.254.169.254', 'link-local'], ['10.0.0.1', 'RFC 1918'], ['192.168.1.1', 'RFC 1918'], ['100.64.0.1', 'CGNAT'], // Tailscale ['255.255.255.255', 'broadcast'], ['0.0.0.0', 'reserved'], ]; for (const [ip, label] of privateV4) { it(`rejects private IPv4 literal ${ip} (${label})`, () => { const r = validateFleetHost({ ...valid(), hostname: ip }); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); expect(r.message).toContain(label); }); } // ── IPv6 private-range rejection paths ── const privateV6 = [ ['::1', 'IPv6 loopback'], ['fe80::1', 'IPv6 link-local'], ['fc00::1', 'IPv6 unique-local'], ['fd00::abcd', 'IPv6 unique-local'], ['::ffff:127.0.0.1', 'IPv4-mapped'], // contains BOTH colon AND dot ]; for (const [ip, label] of privateV6) { it(`rejects private IPv6 literal ${ip} (${label})`, () => { const r = validateFleetHost({ ...valid(), hostname: ip }); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV6'); expect(r.message).toContain(label); }); } // ── Port rejection paths ── it('rejects port < 1', () => { const r = validateFleetHost({ ...valid(), port: 0 }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_PORT'); }); it('rejects port > 65535', () => { const r = validateFleetHost({ ...valid(), port: 65536 }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_PORT'); }); it('rejects non-integer port', () => { expect(validateFleetHost({ ...valid(), port: 'three' }).code).toBe('INVALID_PORT'); expect(validateFleetHost({ ...valid(), port: 3001.5 }).code).toBe('INVALID_PORT'); }); it('rejects port 22 (SSH collision)', () => { const r = validateFleetHost({ ...valid(), port: 22 }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_PORT'); expect(r.message).toMatch(/22.*reserved|reserved.*22/); }); it('accepts port 1, 1023, 1024, 65535', () => { expect(validateFleetHost({ ...valid(), port: 1 }).ok).toBe(true); expect(validateFleetHost({ ...valid(), port: 1023 }).ok).toBe(true); expect(validateFleetHost({ ...valid(), port: 1024 }).ok).toBe(true); expect(validateFleetHost({ ...valid(), port: 65535 }).ok).toBe(true); }); // ── Tag rejection paths ── it('rejects non-array tags', () => { const r = validateFleetHost({ ...valid(), tags: 'prod' }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_TAGS'); }); it('rejects > 20 tags', () => { const r = validateFleetHost({ ...valid(), tags: Array.from({ length: 21 }, (_, i) => `t${i}`) }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_TAGS'); }); it('rejects empty-string tag', () => { const r = validateFleetHost({ ...valid(), tags: ['valid', ''] }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_TAGS'); }); it('rejects tag > 50 chars', () => { const r = validateFleetHost({ ...valid(), tags: ['x'.repeat(51)] }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_TAGS'); }); it('rejects tag with control characters', () => { const r = validateFleetHost({ ...valid(), tags: ['good', 'bad\ntag'] }); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_TAGS'); }); it('accepts tags omitted (defaults to [])', () => { const r = validateFleetHost({ name: 'h', hostname: 'fleet.example.com', port: 3001 }); expect(r.ok).toBe(true); expect(r.normalized.tags).toEqual([]); }); }); describe('DC-068: resolveAndCheckAddress', () => { // The DNS code path uses `dns.promises.lookup` directly; for literal IPs // and IPv6, no DNS call is made. The DNS-name code path is exercised by // mocking dns.promises.lookup. it('accepts a public IPv4 literal without DNS lookup', async () => { const r = await resolveAndCheckAddress('8.8.8.8'); expect(r.ok).toBe(true); expect(r.ip).toBe('8.8.8.8'); expect(r.family).toBe(4); }); it('accepts a public IPv6 literal', async () => { const r = await resolveAndCheckAddress('2001:4860:4860::8888'); expect(r.ok).toBe(true); expect(r.ip).toBe('2001:4860:4860::8888'); expect(r.family).toBe(6); }); it('rejects a private IPv4 literal with opt-out', async () => { const r = await resolveAndCheckAddress('127.0.0.1'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); }); it('accepts a private IPv4 literal when allowPrivate=true', async () => { const r = await resolveAndCheckAddress('192.168.1.1', { allowPrivate: true }); expect(r.ok).toBe(true); expect(r.ip).toBe('192.168.1.1'); }); it('rejects a Tailscale (CGNAT) IPv4 literal', async () => { const r = await resolveAndCheckAddress('100.64.0.1'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); }); it('rejects the AWS metadata endpoint 169.254.169.254', async () => { const r = await resolveAndCheckAddress('169.254.169.254'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); expect(r.message).toMatch(/link-local|metadata/i); }); it('rejects IPv4-mapped IPv6 loopback', async () => { const r = await resolveAndCheckAddress('::ffff:127.0.0.1'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV6'); }); it('rejects garbage hostnames without DNS lookup', async () => { const r = await resolveAndCheckAddress('not a host'); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects empty hostname', async () => { const r = await resolveAndCheckAddress(''); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOSTNAME'); }); it('rejects DNS name that does not resolve', async () => { // We use a reserved TLD (.invalid) which RFC 6761 guarantees will not // resolve in production DNS — so the test is hermetic without mocking. const r = await resolveAndCheckAddress('does-not-resolve.invalid'); expect(r.ok).toBe(false); expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(r.code); }); it('rejects DNS name that resolves to a private IP', async () => { // Heremetic test: dns.promises.lookup is patched on the module instance. const dns = require('dns'); const originalLookup = dns.promises.lookup; dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }]; try { const r = await resolveAndCheckAddress('attacker.example.com'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); } finally { dns.promises.lookup = originalLookup; } }); it('accepts DNS name that resolves to a public IP', async () => { const dns = require('dns'); const originalLookup = dns.promises.lookup; dns.promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }]; try { const r = await resolveAndCheckAddress('public.example.com'); expect(r.ok).toBe(true); expect(r.ip).toBe('93.184.216.34'); expect(r.family).toBe(4); } finally { dns.promises.lookup = originalLookup; } }); it('skips private check when allowPrivate=true even for DNS-resolved address', async () => { const dns = require('dns'); const originalLookup = dns.promises.lookup; dns.promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }]; try { const r = await resolveAndCheckAddress('tailnet.example.com', { allowPrivate: true }); expect(r.ok).toBe(true); expect(r.ip).toBe('10.0.0.5'); } finally { dns.promises.lookup = originalLookup; } }); });