/** * Fleet-host input validation — defends against SSRF on /api/v1/fleet/*. * * Why this lives in its own module instead of inline in routes/fleet.js: * The fleet endpoints compose a user-supplied hostname + port into a URL * that is then fetched from inside the dashcaddy-api container * (DC-108, GET /fleet/status probes `http://${hostname}:${port}/api/v1/system/health`; * POST /fleet/deploy returns `http://${hostname}:${port}/api/v1/apps/deploy` * for the operator to call). Without validation, an authenticated dashboard * operator could register a host with `hostname: "127.0.0.1"` or * `hostname: "169.254.169.254"` (cloud metadata service) and have the * container reach that internal endpoint on the operator's behalf. Worse: * a hostname like `attacker.example.com` could exploit DNS rebinding * (public IP at registration time → loopback IP at fetch time). * * By extracting `validateFleetHost()`, `isPrivateOrReservedIPv4()`, and * `isPrivateOrReservedIPv6()` here, the policy is unit-testable without * booting Express + auth + CSRF, and a future route that wants the same * guard can reuse it. * * Default-deny posture: * - Reject IPv4 loopback (127.0.0.0/8), link-local (169.254.0.0/16 — * including the AWS/GCP/Azure metadata address 169.254.169.254), RFC 1918 * private (10/8, 172.16/12, 192.168/16), CGNAT (100.64.0.0/10, * which Tailscale uses), multicast (224.0.0.0/4), broadcast * (255.255.255.255), and the reserved/documentation ranges (0.0.0.0/8, * 192.0.0.0/24, 192.0.2.0/24, 198.18.0.0/15, 198.51.100.0/24, * 203.0.113.0/24, 240.0.0.0/4). * - Reject IPv6 loopback (::1), link-local (fe80::/10), ULA (fc00::/7), * multicast (ff00::/8), and the IPv4-mapped loopback (::ffff:127.0.0.1). * - Allow public DNS hostnames (e.g. `fleet.example.com`) and public IPs. * - To opt in to private-network hosts (a real fleet of homelab DashCaddy * instances behind Tailscale or RFC1918), set FLEET_ALLOW_PRIVATE_HOSTS=true * in the operator's environment. Even then, DNS-rebinding protection still * resolves the hostname once before probing and rejects private results. * * Public API: * validateFleetHost({ name, hostname, port, tags }) * -> { ok: true, normalized: {...} } | { ok: false, code, message } * resolveAndCheckAddress(hostname) * -> { ok: true, ip } | { ok: false, code, message } * Resolves a DNS hostname to its first A/AAAA record and validates the * resolved IP is also non-private (defends against DNS rebinding). * isPrivateOrReservedIPv4(ip) * isPrivateOrReservedIPv6(ip) */ 'use strict'; const dns = require('dns').promises; // IPv4 ranges that should NEVER be probed from the fleet container unless // the operator has explicitly opted in via FLEET_ALLOW_PRIVATE_HOSTS. // Order matters: most specific (longest prefix) first so a `192.168.x.y` // check happens before a generic `192.*` swallow-all. const PRIVATE_OR_RESERVED_IPV4 = [ // ── Broadcast — checked first because 255.255.255.255 matches the // `240.0.0.0/4 reserved` range and would otherwise be mislabeled. { cidr: '255.255.255.255/32', label: 'broadcast' }, // ── Loopback (RFC 1122) ── // 127.0.0.0/8 — covers 127.0.0.1 and the rest of the loopback block. { cidr: '127.0.0.0/8', label: 'loopback (RFC 1122)' }, // ── Link-local (RFC 3927) + cloud metadata ── // 169.254.0.0/16 covers AWS / GCP / Azure metadata at 169.254.169.254 // (the canonical IMDS endpoint) and any other link-local address. { cidr: '169.254.0.0/16', label: 'link-local / cloud-metadata (RFC 3927, IMDS)' }, // ── RFC 1918 private ── { cidr: '10.0.0.0/8', label: 'RFC 1918 private' }, { cidr: '172.16.0.0/12', label: 'RFC 1918 private' }, { cidr: '192.168.0.0/16', label: 'RFC 1918 private' }, // ── CGNAT (RFC 6598) — Tailscale uses this range ── { cidr: '100.64.0.0/10', label: 'CGNAT / Tailscale (RFC 6598)' }, // ── Multicast (RFC 5771) ── { cidr: '224.0.0.0/4', label: 'multicast (RFC 5771)' }, // ── Reserved / documentation / benchmarks ── { cidr: '0.0.0.0/8', label: 'reserved "this network" (RFC 1122)' }, { cidr: '192.0.0.0/24', label: 'IETF protocol assignments (RFC 6890)' }, { cidr: '192.0.2.0/24', label: 'TEST-NET-1 documentation (RFC 5737)' }, { cidr: '198.18.0.0/15', label: 'benchmark testing (RFC 2544)' }, { cidr: '198.51.100.0/24', label: 'TEST-NET-2 documentation (RFC 5737)' }, { cidr: '203.0.113.0/24', label: 'TEST-NET-3 documentation (RFC 5737)' }, { cidr: '240.0.0.0/4', label: 'reserved for future use (RFC 1112)' }, ]; /** * IPv4 reserved-range check. Returns { isPrivate, label } where label names * the matched range (loopback / RFC 1918 / etc.) for human-readable errors. */ function isPrivateOrReservedIPv4(ip) { if (typeof ip !== 'string') return { isPrivate: false, label: null }; const parts = ip.split('.'); if (parts.length !== 4) return { isPrivate: false, label: null }; const nums = parts.map((p) => parseInt(p, 10)); if (nums.some((n) => !Number.isFinite(n) || n < 0 || n > 255)) { return { isPrivate: false, label: null }; } // Decode the IP to a 32-bit unsigned integer for prefix matching. const asInt = ((nums[0] << 24) | (nums[1] << 16) | (nums[2] << 8) | nums[3]) >>> 0; for (const { cidr, label } of PRIVATE_OR_RESERVED_IPV4) { const [base, bits] = cidr.split('/'); const prefix = parseInt(bits, 10); const baseParts = base.split('.').map((p) => parseInt(p, 10)); const baseInt = ((baseParts[0] << 24) | (baseParts[1] << 16) | (baseParts[2] << 8) | baseParts[3]) >>> 0; // Build a mask by shifting prefix bits down from the top. const mask = prefix === 0 ? 0 : (~0 << (32 - prefix)) >>> 0; if ((asInt & mask) === (baseInt & mask)) { return { isPrivate: true, label }; } } // Broadcast is now handled by the cidr list (255.255.255.255/32 entry), // checked first to win over the 240.0.0.0/4 reserved-for-future-use range. return { isPrivate: false, label: null }; } /** * IPv6 reserved-range check. Returns { isPrivate, label }. */ function isPrivateOrReservedIPv6(ip) { if (typeof ip !== 'string') return { isPrivate: false, label: null }; // Normalize IPv4-mapped IPv6 (::ffff:127.0.0.1) -> delegate to v4 check. const mapped = ip.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/i); if (mapped) { const v4Check = isPrivateOrReservedIPv4(mapped[1]); return v4Check.isPrivate ? { isPrivate: true, label: `IPv4-mapped (${mapped[1]})` } : { isPrivate: false, label: null }; } const lc = ip.toLowerCase(); // ::1 loopback if (lc === '::1') return { isPrivate: true, label: 'IPv6 loopback (RFC 4291)' }; // :: unspecified if (lc === '::') return { isPrivate: true, label: 'IPv6 unspecified (RFC 4291)' }; // fe80::/10 link-local if (/^fe[89ab][0-9a-f]:/i.test(lc) || /^fe80::/i.test(lc)) { return { isPrivate: true, label: 'IPv6 link-local (RFC 4291)' }; } // fc00::/7 unique-local (ULA) if (/^[fF][cdCE]/.test(lc)) { return { isPrivate: true, label: 'IPv6 unique-local (RFC 4193)' }; } // ff00::/8 multicast if (/^ff[0-9a-fA-F]?[0-9a-fA-F]?:/.test(lc)) { return { isPrivate: true, label: 'IPv6 multicast (RFC 4291)' }; } return { isPrivate: false, label: null }; } /** * Lightweight hostname syntax check (RFC 1123-style DNS names + literal IPs). * `net.isIP` would also work for IP literals, but we accept IPv6 with * a leading colon here and delegate that branch separately. */ const RFC1123_LABEL = /^[a-zA-Z0-9](?:[a-zA-Z0-9-]{0,61}[a-zA-Z0-9])?$/; function isValidHostnameSyntax(hostname) { if (typeof hostname !== 'string') return false; if (hostname.length === 0 || hostname.length > 253) return false; // Trailing dot is legal (signals root); strip for label parsing. let h = hostname; if (h.endsWith('.')) h = h.slice(0, -1); if (h.length === 0) return false; const labels = h.split('.'); if (labels.length === 0) return false; for (const label of labels) { if (!RFC1123_LABEL.test(label)) return false; } return true; } /** * Async DNS-resolve the hostname to its first A and AAAA records, run the * private-range check on each, and return the first non-private match. If * all resolved addresses are private (or the name doesn't resolve), report * the failure mode so the caller can return a meaningful 400. * * DNS-rebinding protection: by resolving ONCE at validation time and returning * the IP, a follow-up probe URL built from the resolved IP can't be pointed * at a different IP via a fast-flipping DNS record. For maximum robustness * the caller should pass the resolved IP back as the host's `resolvedIp` so * future `fetch()` calls use `http://:`, not * `http://:`. */ async function resolveAndCheckAddress(hostname, opts = {}) { const allowPrivate = !!opts.allowPrivate; if (typeof hostname !== 'string' || hostname.length === 0) { return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname is required' }; } // Literal IPv4 -- skip the DNS round-trip. if (/^\d+\.\d+\.\d+\.\d+$/.test(hostname)) { const v4Check = isPrivateOrReservedIPv4(hostname); if (v4Check.isPrivate && !allowPrivate) { return { ok: false, code: 'PRIVATE_IPV4', message: `hostname "${hostname}" resolves to a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } return { ok: true, ip: hostname, family: 4 }; } // Literal IPv6 -- detect by containing a colon AND no `/` or `://` // substrings (URL-like strings contain colons but aren't IPv6). Use // Node's built-in `net.isIP` for the authoritative check; the // colon-presence check is a fast-path to skip the DNS call for obvious // IPv6 inputs. const net = require('net'); const isLikelyIPv6 = hostname.includes(':') && net.isIP(hostname) === 6; if (isLikelyIPv6) { const v6Check = isPrivateOrReservedIPv6(hostname); if (v6Check.isPrivate && !allowPrivate) { return { ok: false, code: 'PRIVATE_IPV6', message: `hostname "${hostname}" resolves to a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } return { ok: true, ip: hostname, family: 6 }; } // Hostname syntax guard before DNS call -- saves an OS query for obvious junk. if (!isValidHostnameSyntax(hostname)) { return { ok: false, code: 'INVALID_HOSTNAME', message: `hostname "${hostname}" is not a valid DNS name or IP address`, }; } // DNS resolve. let results; try { results = await dns.lookup(hostname, { all: true }); } catch (err) { return { ok: false, code: 'DNS_RESOLUTION_FAILED', message: `hostname "${hostname}" did not resolve: ${err.code || err.message}`, }; } if (!results || results.length === 0) { return { ok: false, code: 'DNS_NO_RECORDS', message: `hostname "${hostname}" has no A or AAAA records`, }; } for (const r of results) { if (r.family === 4) { const v4Check = isPrivateOrReservedIPv4(r.address); if (v4Check.isPrivate && !allowPrivate) { return { ok: false, code: 'PRIVATE_IPV4', message: `hostname "${hostname}" resolves to ${r.address}, a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } return { ok: true, ip: r.address, family: 4 }; } else if (r.family === 6) { const v6Check = isPrivateOrReservedIPv6(r.address); if (v6Check.isPrivate && !allowPrivate) { return { ok: false, code: 'PRIVATE_IPV6', message: `hostname "${hostname}" resolves to ${r.address}, a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } return { ok: true, ip: r.address, family: 6 }; } } return { ok: false, code: 'DNS_NO_RECORDS', message: `hostname "${hostname}" has no usable A or AAAA records`, }; } /** * Validate the full input shape of POST /fleet/hosts and POST /fleet/deploy. * On success, returns the normalized payload (with `port` coerced to int and * `hostname` lowercased). On failure, returns { ok: false, code, message } for * the caller to surface as a 400 errorResponse. * * Validates in this order (cheapest predicate first): * 1. name: string, 1..100 chars, no control chars * 2. hostname: syntax (IP or RFC 1123 DNS name); literal IPv4/v6 also runs * the private-range check synchronously here * 3. port: integer 1..65535; port 22 explicitly rejected (SSH, not HTTP) * 4. tags: array of strings, max 20 items, each 1..50 chars, no control chars * * Note: DNS-rebinding check is async (resolveAndCheckAddress) and runs * separately, because this function is kept synchronous for testability. * Callers MUST invoke resolveAndCheckAddress after validateFleetHost * for DNS-named hosts. */ function validateFleetHost(input) { const { name, hostname, port, tags } = input || {}; if (typeof name !== 'string' || name.length === 0 || name.length > 100) { return { ok: false, code: 'INVALID_NAME', message: 'name is required and must be 1..100 characters', }; } // Disallow control chars in name (newlines would let a stored name break // log-file formats and could enable log injection if not properly escaped). if (/[\x00-\x1f]/.test(name)) { return { ok: false, code: 'INVALID_NAME', message: 'name must not contain control characters', }; } if (typeof hostname !== 'string' || hostname.length === 0) { return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname is required', }; } // Hard syntax check (catches obvious junk before any DNS call). Use // `net.isIP` to detect literal IPv4/IPv6 (handles both pure-v6 AND the // IPv4-mapped v6 `::ffff:x.y.z.w` correctly), then fall back to the // RFC 1123 DNS-name check. const syntaxIpFamily = require('net').isIP(hostname); if (syntaxIpFamily === 0 && !isValidHostnameSyntax(hostname)) { return { ok: false, code: 'INVALID_HOSTNAME', message: 'hostname must be a valid IPv4 address, IPv6 address, or DNS name', }; } // If it's a literal IP, run the private-range check synchronously here. // Use `net.isIP` to distinguish a real IPv4 dotted-quad or IPv6 from // URL-shaped junk like `http://evil.com` (which contains both `:` and `.` // but is not a valid IP literal). const net = require('net'); const ipFamily = net.isIP(hostname); if (ipFamily === 4) { const v4Check = isPrivateOrReservedIPv4(hostname); if (v4Check.isPrivate) { return { ok: false, code: 'PRIVATE_IPV4', message: `IPv4 address "${hostname}" is a ${v4Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } } else if (ipFamily === 6) { const v6Check = isPrivateOrReservedIPv6(hostname); if (v6Check.isPrivate) { return { ok: false, code: 'PRIVATE_IPV6', message: `IPv6 address "${hostname}" is a ${v6Check.label} address; set FLEET_ALLOW_PRIVATE_HOSTS=true to opt in`, }; } } // Port bounds + SSH sentinel. const portNum = Number(port); if (!Number.isInteger(portNum) || portNum < 1 || portNum > 65535) { return { ok: false, code: 'INVALID_PORT', message: 'port must be an integer in 1..65535', }; } if (portNum === 22) { return { ok: false, code: 'INVALID_PORT', message: 'port 22 is reserved (SSH); the fleet API probe is HTTP, not SSH', }; } // Tags — array of short strings. if (tags !== undefined) { if (!Array.isArray(tags)) { return { ok: false, code: 'INVALID_TAGS', message: 'tags must be an array of strings', }; } if (tags.length > 20) { return { ok: false, code: 'INVALID_TAGS', message: 'tags may contain at most 20 entries', }; } for (const t of tags) { if (typeof t !== 'string' || t.length === 0 || t.length > 50) { return { ok: false, code: 'INVALID_TAGS', message: 'each tag must be a string of 1..50 characters', }; } if (/[\x00-\x1f]/.test(t)) { return { ok: false, code: 'INVALID_TAGS', message: 'tags must not contain control characters', }; } } } return { ok: true, normalized: { name: name.trim(), hostname: hostname.toLowerCase(), port: portNum, tags: tags || [], }, }; } module.exports = { validateFleetHost, resolveAndCheckAddress, isPrivateOrReservedIPv4, isPrivateOrReservedIPv6, isValidHostnameSyntax, };