Pre-fix: POST /api/v1/site accepted upstreams matching /^[a-z0-9.-]+:\d{1,5}$/i
with no private-IP gate. POST /api/v1/site/external called validateURL()
WITHOUT blockPrivate:true. An authenticated dashboard operator (TOTP + CSRF)
could register upstream=10.0.0.1:80 and have Caddy reverse_proxy public
traffic to an internal host. Caddy runs on DNS2, same network as the targets —
the SSRF lands.
Post-fix: new module helper validateUpstream() in fleet-validation.js
reuses the existing resolveAndCheckAddress() private-range gate (14 IPv4
reserved CIDR ranges, 6 IPv6 reserved ranges including CGNAT/multicast/
IMDS). Async, lastIndexOf(':')-split for bracketed IPv6, port 1..65535
validation, DNS resolution with rebinding defense. Opt-in via
SITES_ALLOW_PRIVATE_UPSTREAMS=true for operators who intentionally proxy
to private targets.
Routes sites.js:184 and :250 throw ValidationError [DC-074] BEFORE
caddy.read()/caddy.modify() is called. 60/60 new tests pass (helper unit,
route integration per private range, regression on canonical SSRF payloads,
helper exports unchanged). Full repo npm test: 4 unrelated billing suites
fail due to missing pdfkit module — pre-existing, not caused by this diff.