All three r3 blocking issues fixed + polish applied:
- Radicale provision route now FAILS CLOSED (409) unless the target container
is present, inspectable, and its image matches /radicale/i. Missing
containerId, failed inspect, or non-Radicale image → nothing is written to
the shared config and no source/credential is registered. 4 new tests cover
the three refusal paths + the verified-image happy path.
- ICS feed fetch now streams with a hard 5 MiB body cap (same bound as the
CalDAV path) via readBodyCapped(); oversized feeds reject mid-stream and
degrade to per-source error. Test covers a >5 MiB feed end-to-end.
- Verifier mode separation: `--verify` runs the same checks fully read-only
(all output under mktemp -d in /tmp, transcript printed to stdout, scratch
removed on exit — proven rc=0 with zero repo/tmp-side writes); default
transcript mode is explicitly declared a TRANSCRIPT GENERATOR in its header
and writes only under the script's own directory.
- Full-suite count assertions replaced with the documented invariant
(failed ∈ [5,7], failing set == known-flaky billing suite, extras pass
isolated rerun) — judge r3 polish.
- Stale credential-key comment in radicale-provisioner.js header corrected.
Calendar suites: 43 passed (38 + 5 new). Perf note: readBodyCapped streams;
no full-buffer read of unbounded bodies on the ICS path anymore.
All three r2 blocking issues fixed + polish applied:
- useExisting (adopted) Radicale containers no longer get a source registered
with credentials the operator never provisioned. Registration happens ONLY
when pre-start provisioning succeeded; adopted deployments get a
calendarHint in the deploy response pointing at the manual-wiring modal.
- Weekly BYDAY expansion: candidates earlier than DTSTART are skipped (they
do not consume COUNT); window inclusion now uses interval-overlap
semantics (occurrence span vs window span) for both recurring and
non-recurring events. Three new tests cover midweek DTSTART, a straddling
multi-day event, and a boundary-exactly-ending event.
- Verifier is now read-only on the graded tree: no stash/checkout/rewrite.
Base proof runs in a temporary git worktree of 448f535 under /tmp (removed
on exit); the flaky pdf-parse suite is proven pre-existing via "red at
base" + "patch touches no billing file" (empty diff), and a race-flaky
suite (DC-116, witnessed once under parallel load) is given one isolated
rerun which must pass.
Polish: PUT /sources normalizes the user field (and clamps name) like POST.
Calendar suites: 38 passed (35 + 3 new recurrence tests). The 3558/3564
full-suite numbers vary by one flaky pdf-parse member between runs; the
evidence now asserts the invariant (suite red at base, no billing file in
the patch, flaky suites green on isolated rerun) instead of an exact count.
All five r1 blocking issues fixed + polish items applied:
- Unified credential-key convention: registerAutoWiredSource now stores at
calendar:source:<source.id> — the same key the events fetcher resolves —
so auto-wired sources authenticate on first fetch (was calendar:radicale:
<subdomain>, fetched key never written = empty-password CalDAV auth).
- calendarStateManager is now passed INTO assembleContext(...) (the r1 patch
only extended the function signature, so ctx.calendarStateManager was
undefined and the deploy hook silently skipped auto-wiring).
- Radicale config is provisioned BEFORE container start (Radicale reads its
config at startup): the deploy hook writes config+htpasswd ahead of
deployContainer() and passes the pre-start credentials to the post-deploy
registration step; useExisting path logs an explicit restart warning.
- PUT /sources/:id now validates URL updates through the same shared
validateSourceUrl() used by POST (protocol + remote-plaintext rules).
- New end-to-end contract test (calendar-autowire.e2e.test.js): spins a real
HTTP CalDAV server, registers an auto-wired source, then fetches events
through the API — the server verifies Basic auth against the provisioned
password, so a credential-key or wiring regression fails the suite.
Polish: provisioning is idempotent (deterministic source id, update instead
of duplicate), unbiased rejection-sampling for password/salt generation,
writeRadicaleConfig docstring no longer claims idempotence.
Calendar suites: 35 passed (32 prior + 3 new). Lint: 0 errors; complexity
warnings in deploy.js pre-exist at base (61 vs 68 now — the hook did not
worsen the trend line materially; calendar files themselves lint clean).
- __tests__/e2e-node/composition-root.e2e.test.js: `boot()` takes {timeoutMs, code}
and the timeout rejection carries the child it killed; a NEW test drives the real
timeout against a child that never reports PORT/READY and asserts the OS-observed
SIGKILL on that child (marker e2e-timeout-kill) — the r4 claim is now exercised.
- routes/deploys.js: bridge() coerces any non-object JSON body (null/[]/string) into
the same failure shape as a non-JSON body, so no `body.ok` deref can 500 or report a
false success; the subdomain label regex rejects leading AND trailing hyphens.
- __tests__/routes/deploys.catalog.r4.test.js: trailing-dash rejection case + three
malformed-successful-body cases (clean 502, never 500).
BLOCK0: catalog deploy audit now uses the REAL AuditLogger contract
(auditLogger.log({action,resource,details,outcome,ip})) fire-and-forget
via Promise.resolve().catch — async audit failures can never fail the
deploy response (tested with a throwing audit backend).
BLOCK1: verify.sh targeted-jest block captures jest exit code explicitly
(set +e / rc / loud FAIL) — no silent termination under set -e.
BLOCK2: subdomain advertised by the App Selector modal is now FORWARDED
end-to-end (selector -> /deploys/catalog/deploy validated charset ->
bridge --subdomain -> CLI CatalogInstallOptions, implemented in
dc-catalog-2 r3); invalid subdomains 400 before the bridge (4 cases).
POLISH: boot-timeout SIGKILLs the child (no leaked process).
New r4 suite: 7 tests (audit shape success/failure, subdomain forward +
4 rejections). Full suite 157 suites / 3519 tests green.
GET /deploys/catalog/apps -> bridge /api/catalog/apps (73 embedded apps,
single source of truth). POST /deploys/catalog/deploy {app,port,env} ->
validated (service-id charset, port 1024-65535, env name/value rules) then
proxied to bridge /api/catalog/deploy which runs the shipdeck CLI catalog
deploy (OCI image -> hardened systemd service; NO Docker daemon). Audited
as deploy.catalog with ok/seconds. 11 table-driven route tests incl.
validation rejections never reaching the bridge + bridge-failure 502 shape.
App Selector submit rewired from /apps/deploy (Docker chain) to
/deploys/catalog/deploy — same modal, same fields (port, mediaPath, plex
claim token as env), no new UI screens. Full suite 3510/3510 (156 suites).
- DC-160: /catalog/:appId read app.image/ports/env/volumes which live under
app.docker.* — every detail response was empty. Now reads docker.* and
surfaces defaultPort/subdomain/healthCheck/mediaMount/claimToken/difficulty/
setupInstructions for the deploy form. List/search use the templates own
category field (old hand-rolled CATEGORY_MAP mis-categorized apps).
- Shield (routes/shield.js + 5-line app.js mount) committed to source after
weeks as prod-only drift; regression test asserts the mount exists.
- New composition-root e2e suite: boots REAL createApp() on an ephemeral
port, probes the historically-broken routes through the REAL mount table
(wizard wiring, shield, catalog detail, deploys bridge) — the class of bug
all 3 P7 dogfood findings belonged to. 5 tests.
- Full suite 3499/3499 (155 suites); table-driven DC-160 test covers all 73
image templates.
Add 'sec' to the buildLoginPage pages map in sso-gate.js. Without it,
/api/v1/auth/login-page?service=sec returned 404 'Unknown service' — the
TOTP login page is built from a hardcoded per-service map, so adding sec
to services.json (runtime config, gitignored) alone was not enough.
sec needs no app-token (plain web app behind the TOTP gate): after
check-session verifies the session it redirects to /?direct=1, bypassing
the Caddy @needsAutoLogin loop.
Codex grade A (urn:ump:p3jkreyxpqikwt54uxgpl7sybkbsnklbs5kdonq5ejfqyh27udla).
- routes/ca.js: declare format before pfx/pem/crt dispatch (was ReferenceError
on every request that passed validation); add CA_CERT_FORMATS single source
of truth + hardened format extraction (string-coerce, whitelist)
- routes/caddycode.js: fix upstream-validation regexes (control-char classes)
- routes/logs.js: SSE/validation lint fixes
- routes/openclaw.js: remove useless regex escape in ALLOWED_PATH_RE
- fleet-validation.js + http-caddy-admin-origin test: eslint-disable for
intentional control-regex security sentinels
- ca-dc076.routes.test.js: regression test for declared format + behavioral
coverage of format validation (now pre-PKI)
Judge: Qwen lane (qwen3.8-max) grade B, 0 blocking, verdict
/tmp/judge-batch1-verdict.json. API suite 2859/2859 green.
Two silent-death defects in one class:
1. Seven emitters absent from DEFAULT events, so the send() gate
(config.events[canonical] !== true) dropped them on every install:
ssl-cert-expiry, dns-propagation, drift-detected,
dependency-restart-complete/-failed, recipeRemoved, workflow.
All now default ON; dependency-restart spellings fold onto one
canonical toggle; recipeRemoved aliases to recipe-removed in both
the manager and route alias maps.
2. Nine call sites used a legacy 4-arg send(event, title, message, type)
against the 3-arg signature: the message string landed in the type
slot (Discord embed color fell back to info-blue, history.type wrong)
and providers received the TITLE as the body - deploy-failure
notifications carried no error text at all. Fixed at source (9 sites)
plus a type-guarded shim in send() for external legacy callers.
Also: explicit data.title now flows to ntfy Title header, email subject,
Discord embed title, and history; settings UI gains 9 event toggles
(separate Backup Complete/Failed) with defaults-on semantics.
Tests: +24 (new DC-094 suite: defaults, gate pass-through, alias folding
send-time and load-time, stored-config inheritance, shim body/title/
color/subject/history, type-guard, 3-arg no-regression); 4 assertions in
bundled-workflows-health-check updated from the old 4-arg mock contract
to the canonical shape (same behavior asserted). Full suite 116 suites /
2706 tests green.
Judge: GLM-5.3 cold read via delegate_task (deleg_8a7cedd0), grade A,
zero blockers; 2 polish items (Backups toggle conflation, shim
type-guard) folded into this commit. Verdict URN:
urn:ump:uyipjjwdjqjy3alvceqxvlucrymd7hnsben5udpp2bqycoh3l2la
Live verification of 4125d7a caught a 500 every 60s: the handler called
deps.session.isSessionValid(req), but the production session context
(src/context/session.js) exposes isValid — isSessionValid is only the
middleware-internal name. The round-1 test stub mirrored the wrong
name, so tests passed while prod 500'd (stub-shape-fits-bug).
- routes/auth/index.js: precompute guarded _authed (typeof isValid ===
'function' check); malformed session object can no longer 500 a
60s-polled endpoint. Fallback true: handler runs only after the
session middleware admitted the request.
- routes/auth/admin.js:119: same latent 500 fixed (isSessionValid →
isValid) — pre-existing DC-048 bug, any legacy-session /me call.
- Test stub now carries the real shape {isValid} with isSessionValid
deliberately absent — regression to the wrong name now fails tests.
Judge: GLM-5.3 cold read round 2, grade B ship; stale-comment polish
folded in. Full suite 115/2682 green.
The frontend admin panel polls GET /api/v1/auth/me on every dashboard
load and every 60s per open tab. /me lived only in the DC-048 admin
router, mounted only when email auth is enabled — so every single-user
install (the default) answered 404 and logged a DC-404 ERROR + stack
once per minute per open tab. Verified live in production logs.
- routes/auth/index.js: /auth/me now ALWAYS mounted. Multi-user +
req.user returns the stored profile (mode:'multi'); otherwise the
legacy single-operator response (role:'admin', isAdmin:true,
legacy:true, mode:'single'). Session-gated — NOT added to
PUBLIC_ROUTES, so unauthenticated polls get a clean 401.
- Frontend behavior unchanged: attachTrigger requires me.user.role
=== 'admin', and user stays null in single-user mode, so no Admin
button appears on single-user installs.
- openapi.yaml /api/v1/auth/me (200/401) now matches reality.
- +6 tests (route present both modes, response shapes, PUBLIC_ROUTES
absence, DC-048 admin-mount invariant, HTTP-level dispatch reach).
Judge: GLM-5.3 cold read, grade B ship (verdict URN recorded in
STATE.md); polish note (HTTP-level mount-order test) folded in same
commit. Full suite 115 suites / 2687 tests green.
Three user-facing notification features were silently dead from contract
drift between the settings UI and the backend:
1. SMTP auth never applied: the UI sent email.user/email.pass while the
manager read username/password. Route now normalizes aliases; legacy
config files canonicalize at load.
2. Event toggles were cosmetic: UI sent camelCase keys (containerDown),
the send() gate read kebab-case ('container-down'). EVENT_ALIASES now
folds every known spelling (manager gate, route store, legacy files);
UI sends and reads canonical keys.
3. Deploy/auto-restart notifications always dropped: deploy-success/
deploy-failed/auto-restart were missing from DEFAULT events, and
send('test') was itself gated -> the Test button was a no-op. Defaults
added; 'test' bypasses the gate.
Also: strict boolean contract (string 'false' for secure/enabled rejected
— previously coerced truthy, silently forcing TLS), SMTP port bounds,
non-destructive credential merge (blank password no longer clobbers the
stored one), GET /config returns port/secure/to/username + hasPassword
(password never returned), full form prefill + keep-hint placeholder.
Verified live pre-fix: send('test')/'deploymentSuccess'/'auto-restart'
all returned 'not enabled'. Post-fix: +20 tests (route + manager),
full suite 2641/2641 (112 suites).
Judge: GLM-5.3 cold read via delegate_task (deleg_ad765d63), grade B,
ship, zero blockers; judge independently ran the DC-092 suites (38/38).
Polish #1 (canonical event for provider titles) folded in. Remaining
emitters with the same gate-miss class (recipeRemoved, workflow,
ssl-cert-expiry, dns-propagation, drift-detected, dependency-restart-*)
noted for a follow-up tick.
Two log sites in routes/auth/admin.js wrote raw email PII to the server
log: the SMTP-unconfigured 'auth-invite-send' warn and the 'invite
accepted, user created' info. Both now route through
AuthProvider.maskEmail() with a '[unmaskable-email]' sentinel fallback
(never the raw address). Two regression tests assert the raw address is
absent from log meta and the masked form present. Response contract
unchanged (full email still returned to the authenticated admin).
Judge: GLM-5.3 cold read (deleg_f0896de3), grade B / ship / zero
blockers; polish notes folded in. Verdict URN:
urn:ump:jd2htpwq76ni6bapj3vxjpvypfdnoc4argqbzpcerutmh7u5khea
Full suite: 2610/2610 (109 suites).
Flip POST /api/v1/auth/admin/invites default to no email; always return
the link. Operators copy + share via iMessage/WhatsApp/SMS/Signal/Telegram/
Discord/paste-in-email. Email becomes an opt-in checkbox (was the default).
Add shareText field with pre-formatted message for one-tap paste. Stop
logging raw invite URLs to error.log when SMTP is unconfigured (was just
a dev fallback — link is now in the response). Frontend flips the
checkbox default to unchecked and renders shareText + native share sheet
button (navigator.share) alongside the raw copy-link button. 9 new tests
covering default-no-send, link-always-returned, shareText-shape, opt-in
SMTP send, failed-SMTP-no-leak. Full suite: 2474/2474 + 9 new = 2483.
Public share endpoints accept untrusted fields. Pre-fix code used bare
type checks (email.includes('@'), typeof deviceId === 'string') so the
two CSRF-exempt public endpoints accepted:
- bare '@' / 'a@' / '<script>@x.c'
- 10MB email strings (data/shares.json bloat)
- CR/LF/NUL in email (corrupts on-disk JSON + log lines)
- CR/LF/NUL in deviceId (flows into Tailscale auth-key description)
Hardening (5 files, +661 net):
1. routes/share.js + src/security/share-store.js: shared validators
- validatePublicEmail(raw): charset (a-z0-9._%+-@), 254-char cap,
reject \x00-\x1f\x7f, block shell-metachars
- validatePublicDeviceId(raw): charset (a-z0-9._:-), 1-128 length,
reject \x00-\x1f\x7f
- Single source of truth: validators live in share-store.js, exported,
imported by routes/share.js (drift-eliminated)
2. Routes that were 'email.includes(@)' now use validator. Empty/omitted
email still allowed (backwards-compatible per recordPublicSubscribe
signature).
3. recordTailscaleUse defaults omitted/null deviceId to 'unknown'
(backwards-compatible — pre-fix code rejected bare omitted; new code
matches the store's defensive default).
4. constants.js: RATE_LIMITS.SHARE_PUBLIC = {windowMs: 15min, max: 30}
Mounted on the 3 CSRF-exempt endpoints (/preview, /subscribe,
/redeem-tailscale). 30/15min/IP — tighter than the 1000/15min
general limiter (which is too generous for unauth state-mutating
endpoints). Falls back to no-op in test envs.
5. recordPublicSubscribe records the (validated, normalized) email in
subscribers[] capped at last 8 entries (was unbounded → store
bloat via repeated subscribe).
Test coverage (38 new tests in __tests__/share-dc083.routes.test.js + 3
in __tests__/share-routes.test.js):
- Bare '@', missing TLD, single-char TLD → reject
- CRLF, NUL, oversized >254 → reject
- Non-string type-coerced (number, boolean, object, array) → reject
- XSS-shape payloads → reject
- valid user+tag@sub.domain.io + nodekey:... → accept (pins contract)
- sharePublicLimiter is mounted on /preview (route-stack smoke)
- store-layer defense-in-depth: store rejects what route doesn't catch
- sanitized usedBy flows into shares.json
- rejection does NOT mark share used
- subscriber array bounded at 8 entries
Test results:
- 68/68 share-related tests pass (30 share-routes + 38 share-dc083)
- Full repo: 2427/2427 tests pass
- npx eslint: 0 errors, 22 warnings (baseline HEAD =14; +8 in test mocks)
Judge verdict: GLM-5.3 round-2 grade A. Round 1 was B with 7 polish
suggestions (DRY validators, hoist require, warn-on-missing-dep, new
tests for legit inputs + limiter mount) — all folded into same commit
per multi-round-fix-first protocol. Zero blocking issues.
Threat model: the 2 POST endpoints mutate shares.json + Tailscale auth
descriptions. Pre-fix was effectively 'input trust boundary = NONE'.
Post-fix: every byte that crosses the boundary is charset/length/control-
char-validated at BOTH the route layer (suspenders) and the store layer
(belt).
Pre-fix, the dispose endpoint + storage info block in dashcaddy-api/routes/log-insights.js
HARDCODED /opt/dashcaddy/dashcaddy-api/data/audit-log.json and
/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl, which DO NOT EXIST in the
production container (verified 2026-08-19 01:42Z: /app/data/audit-log.json = 318 KB,
/app/data/security-events.jsonl = 15 MB, /opt/... = ENOENT). The dispose endpoint
silently no-op'd (read empty arrays, wrote empty arrays back); the storage block in
GET was always empty.
Also: parseInt(req.body.keepDays) || 30 accepted negative numbers. keepDays = -1000
produces a cutoff +3 years in the future, then the filter e.timestamp < cutoff
deletes 100% of the audit log. Operators must not be able to wipe forensic context
with a typo.
Fix:
* _resolvePaths() uses process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json'),
matching the canonical resolution in src/security/audit-logger.js and src/security/event-store.js.
Both GET + POST share the resolved paths (single source of truth).
* _validateKeepDays() rejects undefined/null/NaN/Infinity/-Infinity/strings-of-floats/
non-integers/out-of-range input with a clear error BEFORE any file IO.
Allowed: integer in [1, 3650] (1 day .. 10 years).
* POST /log-insights/dispose now requires { keepDays: integer 1..3650, confirm: true }.
Preview is read-only. Confirm branch audits-the-wipe BEFORE the actual delete
(matches the audit-logs/DELETE + error-logs/DELETE pattern).
* Atomic write for audit-log.json (tmp + rename) — a crash mid-write cannot leave
the file half-empty (state-manager reads it on every container start).
Tests (23 new, dashcaddy-api/__tests__/routes/log-insights.routes.test.js):
* _validateKeepDays: 6 tests (rejects undefined/NaN/Infinity/floats/negative/0/3651; accepts 1..3650; coerces numeric strings).
* _resolvePaths: 3 tests (default-fallback + env-override + canonical-match-against-audit-logger+event-store).
* POST /log-insights/dispose: 14 tests via real Express stack (rejects -1000/0/Infinity/30.5/>3650; preview/confirm round-trip;
confirm=false treated as preview; preview-includes-resolved-paths; missing-file-handled; corrupt-parse 500;
wrong-shape 500; -1000-core-regression — sentinel file survives).
GLM-5.3 round 1: A.
DC-080 round-1 GLM-5.3 judge verdict: B. Round-2 polish folded into same
commit per multi-round fix-first protocol: tighten tag regex to require
non-empty name after 'tag:' (matches Tailscale spec), drop dead
`module.exports.createApp = null` line.
THREAT MODEL
Pre-fix, /api/v1/tailscale/* and /api/v1/tailscale/admin/* (TOTP-gated)
had inconsistent checks on caller-supplied input. Three coupled gaps:
(a) PUT /settings validated `apiToken.startsWith('tskey-api-')` but
had NO length cap — body-parser limit was the only ceiling. A 1 MB
string starting with `tskey-api-` would be `.trim()`-ed, sent to
Tailscale's /devices endpoint, and waste server-side CPU on a
request that will always 401.
(b) POST /settings/test accepted `apiToken` from the body with NO
validation at all. The PUT route's prefix check did NOT extend to
this path. An operator could submit arbitrary junk and the
container would still call /devices on the Tailscale API with it
(DoS-reflection + fingerprint timing for an attacker probing
whether this API token format is accepted).
(c) POST /admin/keys validated `tags` as Array but NOT per-element
type — `tags: ['tag:guest', null, 123, {injection: true}]` would
be forwarded to Tailscale verbatim. Tailscale's API is JSON-strict
and would 400 the request, but the bad shape reached the wire.
Similarly `description` had no length cap (Tailscale caps at 120
chars per their docs).
All three are gated by TOTP — this is a logged-in-operator / phished-
session threat surface, not anonymous-unauth. The fix is defense-in-
depth: a bug in the auth path (TOTP bypass, session theft, future route
handler trust-boundary drift) should not turn these endpoints into a
`submit anything and forward to Tailscale` relay.
FIX 1 — Shared validators (round-1)
- `_validateApiToken(token)`: typeof string check, prefix required,
length cap 256 chars. Catches empty/null/non-string AND oversize.
- `_validateTags(tags)`: undefined/null allowed (optional field),
Array.isArray check, max 32 entries, per-element string check,
per-element length cap 64 chars, regex
`/^tag:[a-z0-9][a-z0-9_-]{0,62}$/` (round-2: requires non-empty
name after `tag:` per Tailscale spec).
- `_validateDescription(description)`: undefined/null allowed, string
type check, length cap 120 chars (matches Tailscale's documented cap).
All three return null on success or an error string on failure. Route
layer maps to 400 via `errorResponse`. Validators exported via
`module.exports._validators` for direct unit testing (otherwise
unreachable from outside the factory closure).
FIX 2 — Endpoint wiring (round-1)
- PUT /settings: replaced inline `!startsWith('tskey-api-')` check with
`_validateApiToken(token)`. Single source of truth for the rule.
- POST /settings/test: added `_validateApiToken(token)` guard BEFORE
calling `client.setApiToken(token)`. The body is optional, so the
guard is skipped when no token is provided (uses stored token path).
- POST /admin/keys: replaced `Array.isArray(opts.tags)` shallow check
with `_validateTags(opts.tags)`, plus `_validateDescription(opts.description)`.
Old code already validated `expirySeconds`; that stays.
FIX 3 — Round-2 polish
- TAG_KEY_RE: `/^[a-z0-9][a-z0-9:_-]{0,63}$/` → `/^tag:[a-z0-9][a-z0-9_-]{0,62}$/`.
The old regex accepted `tag:` (empty name), which Tailscale's API
rejects. New regex requires `tag:` prefix and ≥1 alphanumeric name
char followed by [a-z0-9_-]{0,62} — total length up to 67 chars, well
within Tailscale's documented 15..63 char tag length.
- Removed `module.exports.createApp = null` vestigial line — the file
only exports the factory function and the _validators bag.
TESTS (29 original + 16 new = 45 in this suite)
- 4 PUT /settings new: length cap, non-string type, prefix round-trip
(existing 'starts with' tests already passed), plus the original
6 (4 pre-existing PUT tests stay green).
- 4 POST /settings/test new: prefix rejection, length cap, stored-token
path with empty body still works.
- 4 POST /admin/keys new: null/123/object entries rejected, uppercase /
whitespace / CRLF rejected, description length cap, canonical
lowercase `tag:server` accepted.
- 4 direct validator unit tests: validateApiToken (5 cases incl. cap-edge),
validateTags (8 cases incl. round-2 bare-'tag:' rejection), validateDescription
(3 cases incl. cap-edge), constants-export surface.
All 45 tests pass on DNS2 (verified). Full repo suite unchanged: 2351/2351.
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
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
Bug class: silent state corruption via path-style endpoint inconsistency.
Pre-fix, only POST /caddy/upstreams/mute (bare body-style) rejected unknown
hosts with a 400. The path-style POST /caddy/upstreams/:host/mute and
POST /caddy/upstreams/:host/unmute endpoints skipped that check entirely.
An authenticated operator could POST /caddy/upstreams/phantom.test:12345/mute
and caddyUpstreamWatcher.setMuted() would silently add the phantom host
to its muted Set and _saveState() would persist it to disk. The phantom
entry survives container restarts and pollutes the snapshot view.
Fix: consolidate validation in a single validateAndMuteHost() helper used
by all three mute endpoints. The helper enforces (1) host format charset,
(2) length cap, (3) membership in caddyUpstreamWatcher.upstreams (the
live registry populated by scanSites()). No phantom host can reach setMuted.
Tests: 15 new regression tests in
__tests__/routes/caddy-upstreams-dc073.routes.test.js — exercises the
helper directly (unit) and via each endpoint (integration), asserts
rejection happens BEFORE setMuted is called (no state corruption), and
the existing 3 caddy-upstreams.routes.test.js cases still pass. Router
introspection test asserts no duplicate route registrations.
Full suite: 2342/2342 tests / 101 suites.