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:
@@ -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,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user