/** * DC-068: Fleet SSRF hardening — routes-layer integration tests * * Verifies that: * - POST /api/v1/fleet/hosts rejects a public-DNS name that resolves to a * private IP (DNS rebinding defense) * - POST /api/v1/fleet/hosts accepts a public-DNS name that resolves to a * public IP and stores the resolved IP * - POST /api/v1/fleet/hosts rejects literal IPv4 in loopback / link-local * / RFC 1918 / CGNAT / broadcast ranges * - POST /api/v1/fleet/hosts accepts a literal public IPv4 * - POST /api/v1/fleet/hosts rejects port 22 (SSH collision) * - POST /api/v1/fleet/hosts rejects control characters in name/tag * - POST /api/v1/fleet/hosts stores the resolved IP and dnsFamily so * /fleet/status and /fleet/deploy can probe by IP * - FLEET_ALLOW_PRIVATE_HOSTS=true opts in to private-range hosts * * The route tests live alongside the existing DC-108 suite in * caddycode-fleet.routes.test.js. We extend that file with two new describe * blocks so we can co-locate SSRF regression tests with their feature. */ const express = require('express'); const request = require('supertest'); function createFleetApp(log, opts = {}) { const app = express(); app.use(express.json()); const routes = require('../../routes/fleet'); const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); app.use('/api/v1', routes({ log: log || { info: jest.fn(), warn: jest.fn(), error: jest.fn() }, asyncHandler: wrap, })); return app; } describe('DC-068: Fleet POST /hosts — SSRF hardening', () => { let dnsBackup; let filePath; beforeEach(() => { filePath = `/tmp/fleet-ssrf-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; process.env.FLEET_HOSTS_FILE = filePath; dnsBackup = require('dns').promises.lookup; }); afterEach(() => { require('dns').promises.lookup = dnsBackup; delete process.env.FLEET_HOSTS_FILE; try { require('fs').unlinkSync(filePath); } catch {} delete process.env.FLEET_ALLOW_PRIVATE_HOSTS; }); it('rejects 127.0.0.1 (loopback) with PRIVATE_IPV4', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Local', hostname: '127.0.0.1', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); expect(res.body.error).toMatch(/loopback/i); }); it('rejects 169.254.169.254 (AWS IMDS)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'IMDS', hostname: '169.254.169.254', port: 80 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); expect(res.body.error).toMatch(/metadata|link-local/i); }); it('rejects 10.0.0.1 (RFC 1918)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'RFC1918', hostname: '10.0.0.1', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); expect(res.body.error).toMatch(/RFC 1918/); }); it('rejects 192.168.1.1 (LAN)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'LAN', hostname: '192.168.1.1', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); }); it('rejects 100.64.0.1 (Tailscale CGNAT)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Tailscale', hostname: '100.64.0.1', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); }); it('rejects ::1 (IPv6 loopback)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'v6loop', hostname: '::1', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV6'); }); it('rejects port 22 (SSH)', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'h', hostname: 'fleet.example.com', port: 22 }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_PORT'); expect(res.body.error).toMatch(/22.*reserved|reserved.*22/); }); it('rejects port > 65535', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'h', hostname: 'fleet.example.com', port: 65536 }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_PORT'); }); it('rejects port = 0', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'h', hostname: 'fleet.example.com', port: 0 }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_PORT'); }); it('rejects garbage hostname', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'h', hostname: 'not a host!', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_HOSTNAME'); }); it('rejects control characters in name', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'evil\nname', hostname: 'fleet.example.com', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_NAME'); }); it('rejects control characters in tags', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'h', hostname: 'fleet.example.com', port: 3001, tags: ['good', 'bad\ntag'] }); expect(res.status).toBe(400); expect(res.body.code).toBe('INVALID_TAGS'); }); it('accepts a literal public IPv4', async () => { const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Public', hostname: '8.8.8.8', port: 3001 }); expect(res.status).toBe(201); expect(res.body.host.resolvedIp).toBe('8.8.8.8'); expect(res.body.host.dnsFamily).toBe(4); }); it('accepts a public DNS name and resolves it', async () => { require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }]; const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Public DNS', hostname: 'public.example.com', port: 3001 }); expect(res.status).toBe(201); expect(res.body.host.hostname).toBe('public.example.com'); expect(res.body.host.resolvedIp).toBe('93.184.216.34'); expect(res.body.host.dnsFamily).toBe(4); }); it('rejects a DNS name that resolves to a private IP (DNS rebinding)', async () => { // Simulate a rebinding attacker: registration-time DNS returns a public // IP, but a follow-up resolve returns a loopback IP. We mock with the // private IP directly — the validator catches it at registration time. require('dns').promises.lookup = async () => [{ address: '10.0.0.5', family: 4 }]; const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Rebind', hostname: 'attacker.example.com', port: 3001 }); expect(res.status).toBe(400); expect(res.body.code).toBe('PRIVATE_IPV4'); }); it('opts in to private hosts when FLEET_ALLOW_PRIVATE_HOSTS=true', async () => { process.env.FLEET_ALLOW_PRIVATE_HOSTS = 'true'; require('dns').promises.lookup = async () => [{ address: '100.100.50.25', family: 4 }]; const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Tailscale', hostname: 'tailnet.example.com', port: 3001 }); expect(res.status).toBe(201); expect(res.body.host.resolvedIp).toBe('100.100.50.25'); }); it('rejects unresolvable DNS name', async () => { // .invalid is a guaranteed-non-resolving TLD per RFC 6761. const app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'NoDNS', hostname: 'does-not-resolve.invalid', port: 3001 }); expect(res.status).toBe(400); expect(['DNS_RESOLUTION_FAILED', 'DNS_NO_RECORDS']).toContain(res.body.code); }); }); describe('DC-068: Fleet GET /status — probes use resolved IP, not hostname', () => { let dnsBackup; let filePath; beforeEach(() => { filePath = `/tmp/fleet-ssrf-status-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; process.env.FLEET_HOSTS_FILE = filePath; dnsBackup = require('dns').promises.lookup; }); afterEach(() => { require('dns').promises.lookup = dnsBackup; delete process.env.FLEET_HOSTS_FILE; try { require('fs').unlinkSync(filePath); } catch {} }); it('reports validation_failed for a stored host whose hostname resolves to a private IP', async () => { // Step 1: register a host with a public DNS name. Mock lookup so // registration succeeds. require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }]; let app = createFleetApp(); let res = await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Was Good', hostname: 'fleet.example.com', port: 3001 }); expect(res.status).toBe(201); // Step 2: flip the DNS to a private IP (simulating DNS rebinding). // Now GET /status should re-validate, detect the rebind, and tag the // host validation_failed instead of probing the internal address. require('dns').promises.lookup = async () => [{ address: '127.0.0.1', family: 4 }]; app = createFleetApp(); res = await request(app).get('/api/v1/fleet/status'); expect(res.status).toBe(200); const host = res.body.hosts[0]; expect(host.status).toBe('validation_failed'); expect(host.validationError).toBeTruthy(); expect(res.body.summary.validation_failed).toBe(1); expect(res.body.summary.offline).toBe(0); }); it('probes using stored resolvedIp, not raw hostname', async () => { // This is the route-level safety net: even if the stored resolvedIp // somehow no longer resolves correctly, /fleet/status must probe the // captured IP. We assert by checking the host.lastSeen / probe data is // driven by the resolved IP endpoint — but since we can't easily mock // fetch in this test, we verify the structural invariant: hosts with a // valid stored resolvedIp pass validation when DNS lookup ALSO returns // a public IP at probe time. require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }]; let app = createFleetApp(); await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 }); app = createFleetApp(); const res = await request(app).get('/api/v1/fleet/status'); expect(res.status).toBe(200); // Status will be offline because the probed host (93.184.216.34:3001) // doesn't actually serve our health endpoint in the test environment — // but it should NOT be validation_failed. const host = res.body.hosts[0]; expect(host.status).not.toBe('validation_failed'); // The validation_failed counter should remain 0. expect(res.body.summary.validation_failed).toBe(0); }); }); describe('DC-068: Fleet POST /deploy — deployUrl uses resolvedIp', () => { let dnsBackup; let filePath; beforeEach(() => { filePath = `/tmp/fleet-ssrf-deploy-${Date.now()}-${Math.random().toString(36).slice(2)}.json`; process.env.FLEET_HOSTS_FILE = filePath; dnsBackup = require('dns').promises.lookup; }); afterEach(() => { require('dns').promises.lookup = dnsBackup; delete process.env.FLEET_HOSTS_FILE; try { require('fs').unlinkSync(filePath); } catch {} }); it('emits deployUrl from the resolved IP, not the raw hostname', async () => { require('dns').promises.lookup = async () => [{ address: '93.184.216.34', family: 4 }]; let app = createFleetApp(); await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Test', hostname: 'fleet.example.com', port: 3001 }); app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/deploy') .send({ templateId: 'plex' }); expect(res.status).toBe(200); expect(res.body.plan).toHaveLength(1); // The deployUrl was built from the resolved IP, not the user-supplied // hostname — defending against a DNS rebinding pivot at deploy time. expect(res.body.plan[0].deployUrl).toBe('http://93.184.216.34:3001/api/v1/apps/deploy'); // The user-visible hostname is preserved on the plan entry. expect(res.body.plan[0].hostname).toBe('fleet.example.com'); }); it('emits deployUrl from the literal IP for IPv4-literal hosts', async () => { const app = createFleetApp(); await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'Literal', hostname: '8.8.8.8', port: 3001 }); const res = await request(app) .post('/api/v1/fleet/deploy') .send({ templateId: 'plex' }); expect(res.status).toBe(200); expect(res.body.plan[0].deployUrl).toBe('http://8.8.8.8:3001/api/v1/apps/deploy'); }); it('wraps IPv6 resolved IPs in [brackets] so the URL parses correctly', async () => { require('dns').promises.lookup = async () => [{ address: '2001:4860:4860::8888', family: 6 }]; let app = createFleetApp(); await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'v6 DNS', hostname: 'dns.example.com', port: 3001 }); app = createFleetApp(); const res = await request(app) .post('/api/v1/fleet/deploy') .send({ templateId: 'plex' }); expect(res.status).toBe(200); expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy'); }); it('wraps IPv6 literal hosts in [brackets]', async () => { const app = createFleetApp(); await request(app) .post('/api/v1/fleet/hosts') .send({ name: 'v6', hostname: '2001:4860:4860::8888', port: 3001 }); const res = await request(app) .post('/api/v1/fleet/deploy') .send({ templateId: 'plex' }); expect(res.status).toBe(200); expect(res.body.plan[0].deployUrl).toBe('http://[2001:4860:4860::8888]:3001/api/v1/apps/deploy'); }); });