fix(sites): SSRF hardening — validate upstream + externalUrl reject private/reserved hosts (DC-074) [glm-grade=A]
Pre-fix, an authenticated dashboard operator could call:
POST /api/v1/site {domain:"evil.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 at
evil.example.com to an INTERNAL host. Caddy runs on DNS2 (same
network as the targets), so the SSRF lands.
The pre-fix /site upstream regex /^[a-z0-9.-]+:\d{1,5}$/i only
checked charset — it happily accepted 192.168.1.1:80 and
169.254.169.254:80 (AWS metadata IP). /site/external called
validateURL() without blockPrivate:true, leaving the door wide open.
(1) New helper validateUpstream() in fleet-validation.js — reuses
resolveAndCheckAddress() (DC-068 SSRF work) to reject literal
private IPv4/IPv6 (loopback / RFC1918 / link-local / CGNAT /
multicast / broadcast / 0.0.0.0 / TEST-NET / benchmark ranges),
resolve hostnames and reject private answers (rebinding defense),
and cap port to 1..65535. Opt-in via SITES_ALLOW_PRIVATE_UPSTREAMS=true.
(2) /site calls validateUpstream() BEFORE caddy.modify() — gate
happens before any state mutation. Throws ValidationError with
canonical [DC-074] tag and a redacted hostname audit log entry.
(3) /site/external calls validateURL() (syntax only) + validateUpstream()
(private-IP gate). validateURL's blockPrivate is intentionally
NOT passed because it has no opt-in — that's what validateUpstream
is for.
(4) Tests (__tests__/routes/sites-dc074.routes.test.js, NEW, 60/60
passing): helper unit tests (format, literal IPv4/IPv6 private
reject, public IP accept, hostname resolve + rebinding defense,
env opt-in override), POST /site integration (10 regression
payloads + public accept + opt-in + port range + charset), POST
/site/external integration (8 regression payloads + public
accept + DNS rebinding defense + opt-in), canonical SSRF regression
proof (RFC 1918 literal IPv4 in upstream + RFC 1918 literal IPv4
in URL host), unchanged-behavior checks on isPrivateOrReservedIPv4/IPv6.
Full repo suite: 2402/2402 tests in 102 suites (zero regressions).
GLM-5.3 stand-in judge round 1 (deleg_384b9f53, 41.46s, 3 tool
calls, MiniMax-M3 per Sami authorization 2026-08-17): A ship-first.
Refs: codex-as-judge SKILL.md 'Stand-in fallback chain'. Verdict
record: /root/dashcaddy-polish/.ump-verdicts/2026-08-18T22-35-00Z-dc-074-round-1-A.json
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user