DC-079 2-round GLM-5.3 judge verdict: round1=C (blocking path-traversal
in assets/themes) → round2=A. 20/20 tests in routes/discover-disaster
(8 original + 12 new). Full repo: 2351/2351 (4 pre-existing billing
pdfkit failures unchanged).
THREAT MODEL
POST /api/v1/disaster/restore was the ONLY endpoint in the route tree
that wrote directly to process.env.CADDYFILE_PATH (=/caddyfile in
container = /etc/caddy/Caddyfile on host via start.sh:161 bind-mount).
Pre-fix: an authenticated dashboard operator POSTed
{caddyfile: '<attacker-controlled-string>'}
and the handler called fsp.writeFile(caddyfilePath, snapshot.caddyfile),
overwriting the live Caddyfile immediately. Caddy reads this file on
every reload (ACME renewal, health probe, admin API touch), so the
attacker-controlled content executes as Caddy config directives:
- import /etc/caddy/<anything-caddy-can-read> (content theft)
- admin off (lock out admin API)
- reverse_proxy to attacker IPs (Caddy becomes a pivot)
- acme_ca override to attacker CA (rogue cert issuance)
- log to attacker-writable paths (DoS/escape)
This bypassed the CLAUDE.md hard rule 'Caddyfile edits must use
caddy-apply' (validates + reloads + git-commits atomically).
FIX 1 — Caddyfile staging (round-1)
- New validateCaddyfileContent(): type check, non-empty check,
512 KiB byte cap (defense-in-depth below the 1 MB body-parser limit),
FORBIDDEN_IMPORT_RE rejects directives with absolute paths,
../-escape, ~/, or URL-encoded payloads.
- POST /disaster/restore now writes to <dataDir>/disaster-staged/
Caddyfile.candidate (atomic write + rename), NEVER to caddyfilePath.
- Response includes caddyfileStaged[{file, stagedPath, action: 'awaiting
caddy-apply', livePath}] and a DC-079 warning instructing the operator
to run `caddy-apply <reason>` to validate + reload + git-commit.
FIX 2 — assets/themes path-traversal (round-2 BLOCKING)
GLM round-1 caught a parallel vector: snapshot.assets[name] and
snapshot.themes[name] are user-controlled JSON keys flowing into
path.join(assetsDir, name) and path.join(themesDir, name). An attacker
could POST {assets: {'../../etc/caddy/Caddyfile': '<base64-evil>'}}
and overwrite the live Caddyfile via the dataDir bind-mount, fully
bypassing Fix 1.
- ASSET_KEY_RE = /^[a-zA-Z0-9._-]+$/ + ASSET_PATH_TRAVERSAL_RE catch
slashes, leading '..', and absolute-path keys.
- THEME_NAME_RE = /^[a-zA-Z0-9._-]+\.json\$/ additionally forces
.json extension and no slashes.
- assertSafeAssetKey/assertSafeThemeName helpers throw on invalid input.
- Both restore loops now: assert → path.resolve(dir, name) → containment
check (resolved must start with path.resolve(dir) + path.sep) → write
to resolved (never the raw join).
TESTS
12 new tests in __tests__/routes/discover-disaster.routes.test.js:
- staging: live sentinel unchanged, candidate at expected path
- rejects: non-string, empty, oversize, 3 forbidden-import variants
- assets: path-traversal key, absolute-path key
- themes: path-traversal name, no-extension name
- back-compat: no caddyfile field succeeds without staging
bug: POST /api/v1/fleet/hosts (DC-108) accepted any string as the
hostname field and the followup GET /fleet/status flow composed it
verbatim into a probe URL. An authenticated dashboard operator could
register 127.0.0.1 or 169.254.169.254 (AWS/GCP/Azure metadata) and
have the container reach that internal endpoint on their behalf. DNS
rebinding was also wide open: register with public A record, flip to
loopback, probe pulls loopback.
fix: 4 layers of defense
1. New fleet-validation.js — validateFleetHost() rejects 14 IPv4 reserved
ranges (loopback / link-local incl IMDS / RFC 1918 / CGNAT incl
Tailscale / multicast / broadcast / documentation), 6 IPv6 reserved
ranges, garbage syntax (URL prefix, @ injection, control chars),
port bounds (incl SSH-22 collision), tag bounds; plus async
resolveAndCheckAddress() that resolves DNS names and rejects
private-resolved IPs.
2. routes/fleet.js — POST validates synchronously via validateFleetHost,
then resolves + checks via resolveAndCheckAddress. Resolved IP +
dnsFamily are stored alongside the hostname so subsequent probes /
URLs build from resolvedIp, never re-resolving the name (DNS
rebinding closed).
3. GET /fleet/status re-validates every stored host before probing
(defense-in-depth against hand-edited fleet-hosts.json) and
categorizes hosts as validation_failed vs probe-able. Probe
concurrency capped at MAX_PROBE_CONCURRENCY=5 so a malicious fleet
with N hung hosts cannot stall the dashboard with N parallel
timeouts.
4. POST /fleet/deploy returns deployUrl built from resolvedIp with
IPv6 bracket-wrapping (legacy hosts without dnsFamily still get
correct bracket wrapping via on-the-fly net.isIP check).
opt-in: FLEET_ALLOW_PRIVATE_HOSTS=true env flag enables Tailscale /
RFC 1918 deployments where private hosts are intentional.
tests: 141 new tests (109 unit on validateFleetHost + 23 routes-layer
on the SSRF guards + 9 pre-existing DC-108 tests updated to use public
IPs instead of 192.168.x / 10.x). 2277 / 2277 pass on DNS2.
manual verification: GLM-5.3 judge round 1 = A (4 tool calls, 49s,
ship). IPv4-mapped IPv6 edge case ::ffff:127.0.0.1 caught correctly
via net.isIP + delegated IPv4 check.
The DC-049 dead-upstream watcher reads reverse_proxy host directives from
/etc/caddy/sites/*. Bind-mount the directory into the container so the
in-container watcher can see what the host's Caddy is configured to proxy.
Without this mount the watcher would see zero sites and silently no-op.