fix(sites): SSRF hardening — validateUpstream() blocks private/reserved upstreams in /site and /site/external (DC-074) [glm-grade=A]
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.
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