5add96217802ef7fb8aff813744ed8b4ab708b2f
6
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
a7260436d1 |
fix(disaster-recovery): stage Caddyfile + close path-traversal in assets/themes (DC-079) [glm-grade=A]
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
|
||
|
|
18ffd2e519 |
fix(nesting-guard): export dataDir from src/config/paths; harden fallback to platform-paths (DC-077) [glm-grade=B]
Pre-fix, every dashcaddy-api container startup logged:
[nesting-guard] Skipped: The "path" argument must be of type string. Received undefined
because src/utilities/nesting-guard.js does require('../config/paths') and
calls paths.dataDir — but src/config/paths.js imported platformPaths and
only re-exported its specific files (SERVICES_FILE, CONFIG_FILE, etc);
dataDir was never re-exported, so paths.dataDir was undefined.
Result: path.join(undefined, 'data') threw TypeError, the outer try/catch
swallowed it, and the entire nesting-guard became a silent no-op. The
cleanup that prevents recursive data/data/data/... directory duplicates
never ran on any startup. Bug class is 'silent functional no-op' (same
family as DC-056 AggregateError visibility).
(1) src/config/paths.js (+11): re-export dataDir as
SERVICES_DIR-derived (with platformPaths.dataDir fallback). dataDir is
the dirname of SERVICES_FILE in container (env override wins), which
equals /app/data — same value platform-paths.dataDir computes for the
default config. Either path is fine; SERVICES_DIR is preferred because it
respects env-override.
(2) src/utilities/nesting-guard.js (+13/-2): defensive fallback to
require('../../platform-paths').dataDir if paths.dataDir is missing
(any future export-shape drift or older caller). Explicit skip-warn
instead of silent catch when both paths fail.
(3) __tests__/nesting-guard.test.js (NEW, 112 lines, 4/4 passing):
isolates module cache per test, exercises (a) cleanup when nested
data/data exists, (b) no-op when clean, (c) dataDir export contract,
(d) dataDir === dirname(SERVICES_FILE) under env override. No jest.doMock
leaks across tests (verified via 4-call probe sequence).
Verified: 4/4 tests passing. Full repo suite: 100/104 suites / 2335/2335
tests passing (4 pre-existing failures in __tests__/billing/* are
unrelated module-resolution issues in src/billing/invoice.js, confirmed
unaffected by this change via stash+rerun).
GLM-5.3 round 1: B (ship, one polish nit — trailing newline on test
file, folded in same commit per multi-round-fix-first protocol).
Deploy plan: container rebuild + atomic swap via /opt/dashcaddy/start.sh
on DNS2; live-verify status.sami=200, dashcaddy-api=Up+healthy, and
absence of [nesting-guard] Skipped log line in container logs.
|
||
|
|
2fef1c47e5 | fix(ca): gate per-service cert/key download behind TOTP+admin scope; require explicit PFX password; add rate limit (DC-076) [glm-grade=A] | ||
|
|
5382d832d9 |
fix(fleet): SSRF hardening — hostname validation + DNS rebinding + probe-by-IP (DC-068) [glm-grade=A]
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. |
||
|
|
60852ee1ef |
[glm-grade=A] feat(api): error-log filter + pagination + distinct-contexts (DC-052)
Backend (dashcaddy-api/routes/errorlogs.js):
- GET /error-logs: server-side filter chain (level, context substring,
free-text search across error/context/detail/IP, ISO since/until),
real pagination via limit/offset with hasMore reporting, MAX_LIMIT=500
clamp, newest-first sort.
- New endpoint GET /error-logs/contexts returns distinct contexts with
occurrence counts for the frontend dropdown.
- Robust entry parser handles malformed blocks as raw entries so nothing
silently disappears from the operator's view.
- DELETE /error-logs requires { confirm: 'CLEAR' } body and audits the
wipe itself (mirrors DC-050 hardening).
- DC-052 fix: removed legacy /audit-logs GET/DELETE handlers that lived
here before DC-050. errorLogsRoutes is mounted in src/app.js (L733)
BEFORE auditLogRoutes (L789), so Express router.use() semantics meant
the legacy proxies shadowed DC-050's hardened versions — DELETE
without confirm=CLEAR would silently wipe the audit log, and
/audit-logs/actions was unreachable. The hardened routes/audit-log.js
is now the single source of truth.
Frontend (status/js/error-logs.js):
- Level / Context / Search / Since / Until filter row mirroring the
audit-log UI (DC-050).
- Load More pagination with abort-on-filter-change.
- Click-to-expand stack frames in <pre> with scroll-cap.
- Contexts dropdown populated from /error-logs/contexts (refreshes on
every modal open and after a clear).
- confirm=CLEAR clear with success/error notification.
Tests (__tests__/routes/errorlogs.routes.test.js — 20 cases, all pass):
- Endpoint shape, newest-first, level/context/search/since/until filters,
invalid-since + unknown-level 400s, pagination + hasMore, MAX_LIMIT
clamp, /contexts distinct list, confirm=CLEAR gating + audit emission,
missing-file empty results, malformed entry fallback, /contexts
missing-file empty, search-by-IP, huge since/until, combined filters.
Full suite: 86 suites / 1910 tests, all green.
GLM judge round 1 (372s, 50 tool calls): grade D — HIGH audit-log
shadowing + MEDIUM coverage gaps + LOW tofu glyph.
GLM judge round 2 (114s, 25 tool calls): grade A — all findings fixed,
no new regressions, ship recommendation: ship.
|
||
|
|
d79d19b769 |
chore(start): mount /etc/caddy/sites into container for upstream watcher (DC-049 fixup)
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. |