Merge dc/DC-074-sites-ssrf: DC-074 sites SSRF hardening [glm-grade=A]
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -4,6 +4,9 @@ const { CADDY, REGEX, LIMITS } = require('../src/utilities/constants');
|
|||||||
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
const { ValidationError, ConflictError, NotFoundError } = require('../src/utilities/errors');
|
||||||
const { validateURL } = require('../src/security/input-validator');
|
const { validateURL } = require('../src/security/input-validator');
|
||||||
const { ok, successMessage } = require('../src/utils/responses');
|
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
|
* 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 (!domain || !upstream) throw new ValidationError('Domain and upstream are required');
|
||||||
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
if (!REGEX.DOMAIN.test(domain)) throw new ValidationError('[DC-301] Invalid domain format');
|
||||||
|
|
||||||
const upstreamRegex = /^[a-z0-9.-]+:\d{1,5}$/i;
|
// DC-074: SSRF defense — reject upstreams that resolve to private/
|
||||||
if (!upstreamRegex.test(upstream)) throw new ValidationError('Invalid upstream format. Use host:port');
|
// 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 content = await caddy.read();
|
||||||
const escapedDomain = domain.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
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');
|
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 {
|
try {
|
||||||
validateURL(externalUrl);
|
validateURL(externalUrl);
|
||||||
} catch (validationErr) {
|
} catch (validationErr) {
|
||||||
throw new ValidationError(validationErr.message);
|
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);
|
const domain = buildDomain(subdomain);
|
||||||
let dnsWarning = null;
|
let dnsWarning = null;
|
||||||
|
|
||||||
|
|||||||
@@ -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 = {
|
module.exports = {
|
||||||
validateFleetHost,
|
validateFleetHost,
|
||||||
resolveAndCheckAddress,
|
resolveAndCheckAddress,
|
||||||
isPrivateOrReservedIPv4,
|
isPrivateOrReservedIPv4,
|
||||||
isPrivateOrReservedIPv6,
|
isPrivateOrReservedIPv6,
|
||||||
isValidHostnameSyntax,
|
isValidHostnameSyntax,
|
||||||
|
validateUpstream,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user