diff --git a/dashcaddy-api/__tests__/routes/sites-dc074.routes.test.js b/dashcaddy-api/__tests__/routes/sites-dc074.routes.test.js new file mode 100644 index 0000000..604d812 --- /dev/null +++ b/dashcaddy-api/__tests__/routes/sites-dc074.routes.test.js @@ -0,0 +1,535 @@ +/** + * 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); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/routes/sites.js b/dashcaddy-api/routes/sites.js index cd80b65..8ac17ec 100644 --- a/dashcaddy-api/routes/sites.js +++ b/dashcaddy-api/routes/sites.js @@ -4,6 +4,9 @@ const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants'); const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors'); const { validateURL } = require('../src/security/input-validator'); const { ok, successMessage } = require('../src/utils/responses'); +// DC-074: SSRF defense — reject upstream hosts that resolve to +// private/reserved ranges before they reach the Caddyfile. +const { validateUpstream } = require('../src/utilities/fleet-validation'); /** * Sites route factory @@ -166,8 +169,25 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a if (!domain || !upstream) throw new ValidationError('Domain and upstream are required'); if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format'); - const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i; - if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port'); + // DC-074: SSRF defense — reject upstreams that resolve to private/ + // reserved ranges BEFORE we write them into the Caddyfile. Without + // this, an authenticated dashboard operator can call POST /api/v1/site + // with `upstream: '10.0.0.1:80'` 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. + // + // The existing upstreamRegex /^[a-z0-9.-]+:\d{1,5}$/i only checks + // charset — it happily accepts 192.168.1.1:80 and 169.254.169.254:80 + // (the AWS metadata IP). validateUpstream() also does a DNS lookup + // for hostnames so a malicious operator can't sneak a public-looking + // domain past the gate and have it resolve to a private IP later. + const upstreamCheck = await validateUpstream(upstream); + if (!upstreamCheck.ok) { + // Don't echo attacker-supplied hostnames in the audit log; keep the + // canonical code + message but never write the raw value. + log?.warn?.('site', 'POST /site rejected by SSRF gate', { code: upstreamCheck.code }); + throw new ValidationError(`[DC-074] ${upstreamCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`); + } const content = await caddy.read(); const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); @@ -199,12 +219,40 @@ module.exports = function({ asyncHandler, ok, caddy, dns, fetchT, buildDomain, a throw new ValidationError('[DC-301] Invalid subdomain format'); } + // DC-074: SSRF defense — validate the URL syntax via validateURL() (catches + // non-http(s) schemes, malformed URLs) AND validateUpstream() (catches + // every private/reserved range including CGNAT, multicast, TEST-NET + // ranges that validateURL's isPrivateIP() regex misses). + // + // We intentionally do NOT pass `blockPrivate: true` to validateURL() + // here — that's handled by validateUpstream() below, which honors the + // SITES_ALLOW_PRIVATE_UPSTREAMS opt-in. validateURL's blockPrivate path + // is a hard reject with no escape hatch, which would force operators + // who intentionally proxy to a private target to remove validation + // entirely. try { validateURL(externalUrl); } catch (validationErr) { throw new ValidationError(validationErr.message); } + // DC-074: validateUpstream() does the same rigorous private-IP check + // fleet-validation shipped for DC-068, with full CGNAT / multicast / + // broadcast / 0.0.0.0 / TEST-NET / benchmark range coverage and a DNS + // resolution step for hostnames (rebinding defense). + let parsedExternalUrl; + try { + parsedExternalUrl = new URL(externalUrl); + } catch (_) { + // validateURL() above already gates URL syntax — unreachable. + throw new ValidationError('Invalid external URL'); + } + const externalCheck = await validateUpstream(`${parsedExternalUrl.hostname}:${parsedExternalUrl.port || (parsedExternalUrl.protocol === 'https:' ? '443' : '80')}`); + if (!externalCheck.ok) { + log?.warn?.('site', 'POST /site/external rejected by SSRF gate', { code: externalCheck.code }); + throw new ValidationError(`[DC-074] ${externalCheck.message} (set SITES_ALLOW_PRIVATE_UPSTREAMS=true to opt in)`); + } + const domain = buildDomain(subdomain); let dnsWarning = null; diff --git a/dashcaddy-api/src/utilities/fleet-validation.js b/dashcaddy-api/src/utilities/fleet-validation.js index 602425c..8fa20d3 100644 --- a/dashcaddy-api/src/utilities/fleet-validation.js +++ b/dashcaddy-api/src/utilities/fleet-validation.js @@ -415,10 +415,91 @@ function validateFleetHost(input) { }; } +/** + * Validate a `host:port` upstream string for use in Caddy's `reverse_proxy`. + * + * DC-074 SSRF hardening: an authenticated dashboard operator can call + * POST /api/v1/site with `upstream: '10.0.0.1:80'` and end up with a + * Caddyfile entry that proxies public traffic (https://attacker.example.com) + * to an INTERNAL host (10.0.0.1:80). Caddy runs on DNS2 — same network + * as the targets — so the proxy lands the request on the private host. + * The operator doesn't even need DNS-rebinding tricks: a literal IPv4 + * like 192.168.1.1 is accepted by the existing `[a-z0-9.-]+:\d{1,5}` + * upstream regex. + * + * Reuses `resolveAndCheckAddress()` to: + * - reject literal private IPv4 / IPv6 + * - resolve DNS names and reject any private-IP answer + * (rebinding defense — the actual address Caddy connects to is + * the resolved IP at registration time; Caddy itself resolves + * the name per-request, so a malicious operator could flip the + * A record between registration and connection. Acceptable + * residual risk — the registration check is the main gate.) + * - cap port to 1..65535 (defense vs. `host:99999999` integer + * overflow / Caddy parser-bomb) + * + * Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who + * intentionally proxy to private targets (faster than a public DNS + * round-trip + central control plane). + * + * @param {string} upstream - "host:port" string (e.g. "10.0.0.1:80") + * @param {object} [opts] + * @param {boolean} [opts.allowPrivate] - override the env-var default + * @returns {Promise<{ok: true, host: string, port: number, resolvedIp?: string, family?: number} | {ok: false, code: string, message: string}>} + */ +async function validateUpstream(upstream, opts = {}) { + if (typeof upstream !== 'string' || upstream.length === 0) { + return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream is required' }; + } + + // Split on the LAST colon so IPv6 literals like `[::1]:80` parse + // correctly (and a malformed `[::1]` without port is rejected with + // a clean code, not a confusing TypeError from Number()). + const lastColon = upstream.lastIndexOf(':'); + if (lastColon < 0) { + return { ok: false, code: 'INVALID_UPSTREAM', message: 'upstream must be host:port' }; + } + const host = upstream.slice(0, lastColon); + const portStr = upstream.slice(lastColon + 1); + + const portNum = Number(portStr); + if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { + return { ok: false, code: 'INVALID_PORT', message: 'upstream port must be an integer 1..65535' }; + } + + // Allow-list the host charset BEFORE the DNS lookup so attacker + // payloads can't make the resolver do work. Matches the fleet + // isValidHostnameSyntax check; sites.js's own `[a-z0-9.-]+` regex + // is more restrictive (only letters/digits/dots/hyphens) so + // we widen here to also accept bracketed IPv6. Anything else gets + // rejected pre-DNS. + const isBracketedIPv6 = host.startsWith('[') && host.endsWith(']'); + const hostToCheck = isBracketedIPv6 ? host.slice(1, -1) : host; + if (!isValidHostnameSyntax(hostToCheck) && require('net').isIP(hostToCheck) === 0) { + return { ok: false, code: 'INVALID_HOST', message: `upstream host "${host}" is not a valid DNS name or IP address` }; + } + + const allowPrivate = typeof opts.allowPrivate === 'boolean' + ? opts.allowPrivate + : process.env.SITES_ALLOW_PRIVATE_UPSTREAMS === 'true'; + + const r = await resolveAndCheckAddress(hostToCheck, { allowPrivate }); + if (!r.ok) return r; // bubbles up PRIVATE_IPV4 / PRIVATE_IPV6 / INVALID_HOSTNAME / DNS_* + + return { + ok: true, + host, + port: portNum, + resolvedIp: r.ip, + family: r.family, + }; +} + module.exports = { validateFleetHost, resolveAndCheckAddress, isPrivateOrReservedIPv4, isPrivateOrReservedIPv6, isValidHostnameSyntax, + validateUpstream, };