/** * DC-074: SSRF hardening for sites.js — `/site` and `/site/external` * must reject upstream hosts that resolve to private/reserved ranges * BEFORE they reach the Caddyfile. * * Bug class: an authenticated dashboard operator could call * POST /api/v1/site {domain: "x.example.com", upstream: "10.0.0.1:80"} * POST /api/v1/site/external {subdomain: "x", externalUrl: "http://192.168.1.5"} * and end up with a Caddy site block that proxies PUBLIC traffic to an * INTERNAL host. Caddy runs on DNS2 (same network as the targets), so * the SSRF lands. * * Pre-fix: `/site`'s only upstream check was `^[a-z0-9.-]+:\d{1,5}$/i`, * which accepts 192.168.1.1:80 and 169.254.169.254:80 (the AWS * metadata IP) with no problem. `/site/external` used `validateURL` * without `blockPrivate: true` at all. * * Post-fix: a new helper `validateUpstream()` in `fleet-validation.js` * reuses the resolver+private-range checks fleet-validation already has * for DC-068, gating Caddyfile writes behind a public-IP requirement. * Opt-in via `SITES_ALLOW_PRIVATE_UPSTREAMS=true` for operators who * intentionally proxy to private targets. * * The suite covers three layers: * 1. Helper unit tests — validateUpstream with mocked DNS / literal IPs * 2. Route integration tests — POST /site and POST /site/external * reject each known private range, accept public IPs and hostnames * 3. Regression — pre-fix payload `10.0.0.1:80` is rejected (the * canonical SSRF regression proof) */ const express = require('express'); const request = require('supertest'); const { validateUpstream, isPrivateOrReservedIPv4, isPrivateOrReservedIPv6, } = require('../../src/utilities/fleet-validation'); // --------------------------------------------------------------------------- // Test fixtures // --------------------------------------------------------------------------- const LOG = () => ({ info: jest.fn(), warn: jest.fn(), error: jest.fn() }); /** * Build a minimal Express app that mounts /api/v1/sites with stubbed * caddy/dns/buildDomain/addServiceToConfig. The stubs record every call * so tests can assert the route does NOT mutate the Caddyfile when it * should reject. */ function createSitesApp({ log, caddyStub, buildDomainStub, dnsStub, addServiceToConfigStub } = {}) { const app = express(); app.use(express.json({ limit: '1mb' })); const sites = require('../../routes/sites'); const wrap = (fn) => (req, res, next) => Promise.resolve(fn(req, res, next)).catch(next); const caddy = caddyStub || { read: async () => '# stub caddyfile\n', modify: jest.fn(async () => ({ success: true })), adminUrl: 'http://127.0.0.1:2019', filePath: '/tmp/stub-Caddyfile', }; const dns = dnsStub || { universalCreateRecord: jest.fn(async () => true), }; app.use('/api/v1', sites({ asyncHandler: wrap, ok: (res, data) => res.json({ ok: true, ...data }), successMessage: (res, msg) => res.json({ ok: true, message: msg }), caddy, dns, fetchT: async () => ({ ok: true, json: async () => ({}) }), buildDomain: buildDomainStub || ((sub) => `${sub}.example.com`), addServiceToConfig: addServiceToConfigStub || jest.fn(async () => true), siteConfig: { dnsServerIp: '127.0.0.1' }, log: log || LOG(), })); // JSON error middleware — must mirror the shape sites.js's production // global error middleware emits so route tests can assert on it. Without // this, Express's default error handler returns an HTML stack trace and // res.body.error is undefined. // eslint-disable-next-line no-unused-vars app.use((err, req, res, next) => { const status = err.statusCode || 500; res.status(status).json({ error: err.message || 'Internal Server Error', code: err.code || null, field: err.field || null, }); }); return { app, caddy }; } /** Mock dns.promises.lookup to return a specific IP for any hostname. * Returns an array of `{address, family}` records since fleet-validation * calls `dns.lookup(name, {all: true})`. */ function mockDnsLookup(map) { const dns = require('dns'); const original = dns.promises.lookup; dns.promises.lookup = async (hostname, opts) => { for (const [pattern, ip] of Object.entries(map)) { if (hostname === pattern || (pattern instanceof RegExp && pattern.test(hostname))) { const family = ip.includes(':') ? 6 : 4; return [{ address: ip, family }]; } } // Default: throw ENOTFOUND const err = new Error('ENOTFOUND'); err.code = 'ENOTFOUND'; throw err; }; return () => { dns.promises.lookup = original; }; } // --------------------------------------------------------------------------- // 1. Helper unit tests // --------------------------------------------------------------------------- describe('DC-074: validateUpstream (helper)', () => { let restoreDns; beforeEach(() => { delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; }); afterEach(() => { if (restoreDns) restoreDns(); delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; }); describe('format validation', () => { test('rejects empty / non-string with INVALID_UPSTREAM', async () => { expect(await validateUpstream('')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' }); expect(await validateUpstream(null)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' }); expect(await validateUpstream(undefined)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' }); expect(await validateUpstream(42)).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' }); }); test('rejects missing port with INVALID_UPSTREAM', async () => { expect(await validateUpstream('hostonly')).toMatchObject({ ok: false, code: 'INVALID_UPSTREAM' }); }); test('rejects non-integer port with INVALID_PORT', async () => { expect(await validateUpstream('host:abc')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); expect(await validateUpstream('host:80.5')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); }); test('rejects out-of-range port with INVALID_PORT', async () => { expect(await validateUpstream('host:0')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); expect(await validateUpstream('host:65536')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); expect(await validateUpstream('host:99999999')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); expect(await validateUpstream('host:-1')).toMatchObject({ ok: false, code: 'INVALID_PORT' }); }); }); describe('private IPv4 reject (literal)', () => { const PRIVATE_V4 = [ ['127.0.0.1', 'loopback'], ['127.255.255.1', 'loopback'], ['10.0.0.1', 'RFC 1918'], ['172.16.0.1', 'RFC 1918'], ['192.168.1.1', 'RFC 1918'], ['169.254.169.254', 'link-local'], // AWS IMDS ['100.64.0.1', 'CGNAT'], ['224.0.0.1', 'multicast'], ['255.255.255.255', 'broadcast'], ['0.0.0.0', 'reserved'], ]; for (const [ip, wantLabel] of PRIVATE_V4) { test(`rejects ${ip} (${wantLabel})`, async () => { const r = await validateUpstream(`${ip}:80`); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); expect(r.message).toMatch(new RegExp(wantLabel, 'i')); }); } }); describe('private IPv6 reject (literal)', () => { test('rejects ::1 (loopback)', async () => { const r = await validateUpstream('[::1]:80'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV6'); }); test('rejects fe80::1 (link-local)', async () => { const r = await validateUpstream('[fe80::1]:80'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV6'); }); test('rejects fc00::1 (ULA)', async () => { const r = await validateUpstream('[fc00::1]:80'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV6'); }); }); describe('public IPs accepted (literal)', () => { test('accepts 8.8.8.8', async () => { const r = await validateUpstream('8.8.8.8:53'); expect(r.ok).toBe(true); expect(r.host).toBe('8.8.8.8'); expect(r.port).toBe(53); expect(r.family).toBe(4); }); test('accepts 1.1.1.1', async () => { const r = await validateUpstream('1.1.1.1:443'); expect(r.ok).toBe(true); expect(r.port).toBe(443); }); }); describe('hostname resolve', () => { test('accepts hostname that resolves to public IP', async () => { restoreDns = mockDnsLookup({ 'public.example.com': '8.8.8.8' }); const r = await validateUpstream('public.example.com:443'); expect(r.ok).toBe(true); expect(r.resolvedIp).toBe('8.8.8.8'); expect(r.family).toBe(4); }); test('rejects hostname that resolves to private IP (DNS rebinding defense)', async () => { restoreDns = mockDnsLookup({ 'evil.example.com': '10.0.0.5' }); const r = await validateUpstream('evil.example.com:80'); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); expect(r.message).toMatch(/evil\.example\.com.*10\.0\.0\.5/); }); test('rejects hostname that fails to resolve', async () => { // mockDnsLookup default throws ENOTFOUND const r = await validateUpstream('does-not-exist.invalid:80'); expect(r.ok).toBe(false); expect(r.code).toMatch(/DNS_/); }); test('rejects hostname with invalid charset pre-DNS', async () => { const r = await validateUpstream('host with spaces:80'); expect(r.ok).toBe(false); expect(r.code).toBe('INVALID_HOST'); }); }); describe('SITES_ALLOW_PRIVATE_UPSTREAMS opt-in', () => { test('default rejects private IPs', async () => { const r = await validateUpstream('10.0.0.1:80'); expect(r.ok).toBe(false); }); test('opt-in accepts private literal IP', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; const r = await validateUpstream('10.0.0.1:80'); expect(r.ok).toBe(true); }); test('opt-in accepts private DNS-resolved host', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; restoreDns = mockDnsLookup({ 'internal.example.com': '10.0.0.5' }); const r = await validateUpstream('internal.example.com:80'); expect(r.ok).toBe(true); }); test('explicit allowPrivate:false overrides env opt-in (programmatic guard)', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; const r = await validateUpstream('10.0.0.1:80', { allowPrivate: false }); expect(r.ok).toBe(false); expect(r.code).toBe('PRIVATE_IPV4'); }); }); }); // --------------------------------------------------------------------------- // 2. Route integration tests — POST /site // --------------------------------------------------------------------------- describe('DC-074: POST /api/v1/site — SSRF hardening', () => { let restoreDns; let caddyStub; beforeEach(() => { delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; caddyStub = { read: async () => '# stub caddyfile\n', modify: jest.fn(async () => ({ success: true })), adminUrl: 'http://127.0.0.1:2019', filePath: '/tmp/stub-Caddyfile', }; }); afterEach(() => { if (restoreDns) restoreDns(); delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; }); const REGRESSION_CASES = [ ['10.0.0.1:80', 'PRIVATE_IPV4'], ['172.16.0.1:80', 'PRIVATE_IPV4'], ['192.168.1.1:80', 'PRIVATE_IPV4'], ['127.0.0.1:80', 'PRIVATE_IPV4'], ['169.254.169.254:80', 'PRIVATE_IPV4'], // AWS IMDS ['100.64.0.1:80', 'PRIVATE_IPV4'], // CGNAT ['224.0.0.1:80', 'PRIVATE_IPV4'], // multicast ['0.0.0.0:80', 'PRIVATE_IPV4'], // reserved ['[::1]:80', 'PRIVATE_IPV6'], ['[fc00::1]:80', 'PRIVATE_IPV6'], ]; for (const [upstream, wantCode] of REGRESSION_CASES) { test(`rejects upstream="${upstream}" with code=${wantCode}`, async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'evil.example.com', upstream }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/\[DC-074\]/); expect(res.body.error).toMatch(/SITES_ALLOW_PRIVATE_UPSTREAMS/); // caddy.modify() must NOT have been called (gate happens before write) expect(caddyStub.modify).not.toHaveBeenCalled(); }); } test('rejects DNS-resolved private IP (rebinding defense)', async () => { restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' }); const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'evil.example.com', upstream: 'looks-public.example.com:80' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/10\.0\.0\.5/); expect(caddyStub.modify).not.toHaveBeenCalled(); }); test('accepts public literal IP', async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'new.example.com', upstream: '8.8.8.8:80' }); expect(res.status).toBe(200); expect(caddyStub.modify).toHaveBeenCalledTimes(1); }); test('accepts hostname resolving to public IP', async () => { restoreDns = mockDnsLookup({ 'real.example.com': '8.8.8.8' }); const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'new.example.com', upstream: 'real.example.com:80' }); expect(res.status).toBe(200); expect(caddyStub.modify).toHaveBeenCalledTimes(1); }); test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private literal', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'lab.example.com', upstream: '10.0.0.1:80' }); expect(res.status).toBe(200); expect(caddyStub.modify).toHaveBeenCalledTimes(1); }); test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private-resolved hostname', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; restoreDns = mockDnsLookup({ 'internal.lan': '10.0.0.5' }); const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'lab.example.com', upstream: 'internal.lan:80' }); expect(res.status).toBe(200); }); test('rejects out-of-range port without invoking private-IP check', async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'new.example.com', upstream: '8.8.8.8:99999' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/INVALID_PORT|\[DC-074\]/); expect(caddyStub.modify).not.toHaveBeenCalled(); }); test('rejects upstream with spaces (charset) without invoking private-IP check', async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'new.example.com', upstream: 'not a host:80' }); expect(res.status).toBe(400); expect(caddyStub.modify).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // 3. Route integration tests — POST /site/external // --------------------------------------------------------------------------- describe('DC-074: POST /api/v1/site/external — SSRF hardening', () => { let restoreDns; let caddyStub; beforeEach(() => { delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; caddyStub = { read: async () => '# stub caddyfile\n', modify: jest.fn(async () => ({ success: true })), adminUrl: 'http://127.0.0.1:2019', filePath: '/tmp/stub-Caddyfile', }; }); afterEach(() => { if (restoreDns) restoreDns(); delete process.env.SITES_ALLOW_PRIVATE_UPSTREAMS; }); const REGRESSION_CASES = [ 'http://10.0.0.1', 'http://192.168.1.1', 'http://127.0.0.1', 'http://169.254.169.254', // AWS IMDS via URL form 'http://100.64.0.1', // CGNAT — caught by validateUpstream defense-in-depth, not validateURL 'http://0.0.0.0', 'http://[::1]', 'http://[fc00::1]', ]; for (const externalUrl of REGRESSION_CASES) { test(`rejects externalUrl="${externalUrl}"`, async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl }); // 400 from validateURL OR from validateUpstream — either path closes the gate. expect(res.status).toBe(400); expect(caddyStub.modify).not.toHaveBeenCalled(); }); } test('rejects DNS-resolved private IP', async () => { restoreDns = mockDnsLookup({ 'looks-public.example.com': '10.0.0.5' }); const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl: 'http://looks-public.example.com' }); expect(res.status).toBe(400); expect(res.body.error).toMatch(/\[DC-074\]|Private URLs/); expect(caddyStub.modify).not.toHaveBeenCalled(); }); test('accepts externalUrl with public hostname', async () => { restoreDns = mockDnsLookup({ 'api.example.com': '8.8.8.8' }); const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl: 'http://api.example.com' }); expect(res.status).toBe(200); expect(caddyStub.modify).toHaveBeenCalledTimes(1); }); test('accepts externalUrl with public literal IP', async () => { const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl: 'http://8.8.8.8' }); expect(res.status).toBe(200); }); test('SITES_ALLOW_PRIVATE_UPSTREAMS=true opts in for private externalUrl', async () => { process.env.SITES_ALLOW_PRIVATE_UPSTREAMS = 'true'; const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl: 'http://10.0.0.5' }); expect(res.status).toBe(200); }); }); // --------------------------------------------------------------------------- // 4. Regression — pre-fix payload (the canonical SSRF regression proof) // --------------------------------------------------------------------------- describe('DC-074: regression — pre-fix payloads are now rejected', () => { test('the canonical SSRF payload `10.0.0.1:80` is rejected at the route layer', async () => { const caddyStub = { read: async () => '', modify: jest.fn(async () => ({ success: true })), adminUrl: 'http://127.0.0.1:2019', filePath: '/tmp/stub-Caddyfile', }; const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site') .send({ domain: 'evil.attacker.com', upstream: '10.0.0.1:80' }); expect(res.status).toBe(400); // Pre-fix this payload would have been accepted, the regex happily // matches `[a-z0-9.-]+:\d{1,5}` against `10.0.0.1:80`, and a Caddy // site block would have been written that proxied public HTTPS // traffic at `evil.attacker.com` to the internal 10.0.0.1:80. expect(caddyStub.modify).not.toHaveBeenCalled(); }); test('the canonical SSRF payload `http://192.168.1.5` is rejected at the external endpoint', async () => { const caddyStub = { read: async () => '', modify: jest.fn(async () => ({ success: true })), adminUrl: 'http://127.0.0.1:2019', filePath: '/tmp/stub-Caddyfile', }; const { app } = createSitesApp({ caddyStub }); const res = await request(app) .post('/api/v1/site/external') .send({ subdomain: 'ext', externalUrl: 'http://192.168.1.5' }); expect(res.status).toBe(400); expect(caddyStub.modify).not.toHaveBeenCalled(); }); }); // --------------------------------------------------------------------------- // 5. Sanity — fleet-validation helper exports still work as before // --------------------------------------------------------------------------- describe('DC-074: fleet-validation helpers still exported and unchanged behavior', () => { test('isPrivateOrReservedIPv4 still detects the same set as before', () => { expect(isPrivateOrReservedIPv4('10.0.0.1').isPrivate).toBe(true); expect(isPrivateOrReservedIPv4('8.8.8.8').isPrivate).toBe(false); }); test('isPrivateOrReservedIPv6 still detects the same set as before', () => { expect(isPrivateOrReservedIPv6('::1').isPrivate).toBe(true); expect(isPrivateOrReservedIPv6('2001:4860:4860::8888').isPrivate).toBe(false); }); });