Domain=.sami cookies are silently rejected by real browsers - .sami is an
unregistered custom TLD, so browsers treat sami itself as the effective
public suffix and refuse to set a cookie scoped to it (the same rule that
stops a site from setting a supercookie for all of .com). Confirmed via
curl verbose (cookie dropped, domain must not set cookies for sami) and
via the Firefox console on the actual device (Cookie rejected for invalid
domain) for the same cookie. The session cookie set on status.sami after
TOTP verify could never reach plex.sami/jellyfin.sami/emby.sami/chat.sami
no matter how the cookie itself was built - prior fixes tonight left this
mechanism untouched, which is why the loop persisted.
Fix: /totp/verify mints a short-lived (60s) single-use opaque token. The
status.sami frontend appends it to the redirect URL when bouncing the
user back to a gated service. That services login page exchanges the
token via the new public GET /api/v1/auth/sso-exchange for a host-only
session cookie (no Domain attribute - always accepted). isSessionValid
only checks the cookies HMAC signature, never its Domain, so the host-only
cookie validates identically to the cross-domain one on every existing
check with zero changes to that logic.
The never-trap fallback commit (fb638f6) left an extra closing brace after
the fail() call in the plex/jellyfin/emby page bodies (JSON.stringify(j))}
followed by another }).catch(...) on the next line - one brace too many,
since unlike the chat body these have no try/catch needing the extra scope).
This threw a SyntaxError parsing the inline <script>, which silently killed
the ENTIRE script - including the 15s failsafe redirect - leaving the page
stuck on "Signing in to ..." forever with zero console output explaining
why. Confirmed via node --check on the actual generated <script> contents
for all four services.
Sami reported plex.sami/dashcaddy-login hangs at 'Signing in to Plex...'
indefinitely. Earlier commit (210c208) added 8s/15s timeouts at the SHELL
template level, but the per-service page bodies in buildLoginPage()'s
pages object had their own dead-end behavior: when app-token/:svc
returned an error or no token, the body called fail() showing an error
message but DID NOT redirect anywhere. With check-session still
returning authenticated, the SHELL's 15s failsafe timer never fires
because the script is still 'running' (in the failed .then chain).
Fix in each body (plex/jellyfin/emby/chat):
- After app-token returns no token, check localStorage for a stale token.
If present, redirect to /web/?direct=1 — Plex/Jellyfin/Emby may still
accept it for the session, and the user is unblocked either way.
- If no stale token, the fail() message now includes a manual link to
/web/?direct=1 (not just status.sami re-auth), so the user always has
an exit. fail() also shows the actual API response body (truncated)
for easier debugging when something is genuinely wrong.
- catch() handlers get the same manual-link treatment.
- Removed the chat body's debug spam (Status: code + body dump to #d)
that was making the UI look broken even when it wasn't.
Verified: 133/133 auth/sso/csrf/session tests pass; served page on
plex.sami/jellyfin.sami/emby.sami/chat.sami all contain
myPlexAccessToken/jellyfin_credentials/emby_credentials/token fallback
checks + Open X manually links.
buildLoginPage() in routes/auth/sso-gate.js shipped with bare fetches
(no signal). When app-token/:serviceId hung in the browser (slow upstream,
no response after 30s+, etc.), the page sat on 'Signing in to Plex...'
indefinitely. Verified on DNS2 2026-07-22: user reported 'still doing the
same thing' even after cookie + XFF fixes were verified working end-to-end.
Hardening:
- check-session fetch: 5s AbortSignal timeout
- app-token/:svc fetch (via ft()): 8s AbortSignal timeout
- 15s hard overall timer: if nothing succeeds, force-redirect to
status.sami?auth=required so the user can re-auth
- try/catch around fail() to prevent DOM exception from breaking flow
Verified live: 133/133 auth/sso/csrf/session tests pass; container
healthy; served page contains 'withTimeout' + 'overallTimer' + '15000'.
Auto-login can no longer hang the page.
The DC-027 rate limiter on /api/v1/auth/* shipped with skip: () => isTest,
which counted every request — including those from a logged-in TOTP session.
Caddy's forward_auth fires /auth/gate/* on every page-load asset (HTML, JS,
CSS, XHR), so a normal browser session exhausted the 20-req/15-min budget
within ~3 page loads and started getting 429 'Too many auth requests' even
with a valid session cookie.
Fix: extend skip to also return true when req.auth.type is 'session',
'jwt', or 'apikey' (set by jwtApiKeyAuthMiddleware, which runs upstream
of the limiter). The unauthenticated path is still rate-limited — DC-027's
credential-scraping defense is preserved.
Also closes the uncommitted working-tree changes for:
- DC-026: routes/auth/sso-gate.js — pre-auth check in buildLoginPage,
redirected error fallbacks to status.sami?auth=required&return=...
- DC-022: dashcaddy-api/VERSION bumped to fef7e07
- status/index.html + status/js/tailscale-devices.js — Tailscale device card
4 new regression tests pin the fix:
- skips when req.auth.type === 'session'
- skips when req.auth.type === 'jwt'
- skips when req.auth.type === 'apikey'
- still counts UNAUTHENTICATED requests (defense preserved)
Live verified: 50/50 authenticated /auth/gate/plex calls passed (was
20/30 before fix). plex.sami/dashcaddy-login returns 200 with no redirect
loop. Plex auto-login token round-trips end-to-end.
[DC-026] routes/auth/sso-gate.js — fix sessionDuration='never' bypass
Both /auth/gate/:serviceId and /auth/app-token/:serviceId had a session
check gated on `sessionDuration !== 'never'`. An admin setting TOTP to
never-expire accidentally created an authentication-free path to credential
injection (Basic Auth, X-Api-Key, Plex/Prowlarr tokens). Patched: session
required whenever TOTP is enabled, period. Added 8 regression tests.
[DC-027] src/utilities/middleware.js — rate limit /auth/*
New authLimiter (20 req / 15 min) on /auth/keys, /auth/jwt, /auth/gate,
/auth/app-token. These endpoints expose credentials and were unmetered.
Without this, an attacker with a guessed session cookie could burn through
every credential-touching endpoint. Added 5 tests.
[DC-028] src/security/audit-logger.js — log credential exposures
/auth/gate and /auth/app-token were in SKIP_PATHS, silently dropping
every credential-exposure event from the audit log. Combined with the
GET-skip rule, NONE of these events were being recorded. Now logged
with named actions: auth.credential-injection, auth.app-token-issue,
auth.api-key-generate, auth.api-key-revoke, auth.jwt-mint. Added 9 tests.
[start.sh] Disable in-container self-updater
DASHCADDY_UPDATE_ENABLED=false. Without this, the container kept writing
trigger.json every 30 min and clobbered my in-progress host edits. The
path unit on the host is still active for manual triggers, but the
container won't auto-update itself — only when an admin clicks the
update button or a new release is manually published.
[package.json] Bump to 1.14.7
Test results: 1066/1066 passing across 39 suites (added 22 new tests).
- sso-gate.js: add GET /api/v1/auth/login-page?service= route; auto-login
HTML for chat/plex/jellyfin/emby now served from code instead of inline
Caddyfile respond blobs. Fix merge() try-block syntax error (was missing
closing } before catch, breaking Jellyfin/Emby localStorage merge).
- middleware.js: add /api/v1/auth/login-page to PUBLIC_ROUTES.
- CLAUDE.md: complete rewrite — was describing the old Windows-local
C:/caddy/ layout; now accurately describes DNS2 production (paths,
container, caddy-apply workflow, SSO architecture, common mistakes).
- .gitignore: cover runtime JSON/log/cert files that were sitting untracked
in dev root (audit-log, backup-history, credentials, health-history, etc.),
plus generated-certs/, pki/, assets/.
- Remove tracked dev-root noise: comprehensive-test.js, license-keygen.js,
test-security-fixes.js (scripts that don't belong at repo root).
- Remove stale routes/openclaw.js (leftover from old monolithic structure).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
The DC-005 src/ refactor left depth-2 route files (routes/auth/*,
routes/recipes/*, routes/apps/*, routes/arr/*, routes/config/*) with
broken require() paths. A filesystem-resolving scanner found 67 broken
requires across 21 files — three distinct bug classes:
A) '../../../src/...' (3 levels up, above package root) — Bug 7, ~49 occurrences
B) '../src/utils/...' (1 level up, resolves to nonexistent routes/src/) — ~15 occurrences
C) routes/apps/restore.js:5 used utilities/responses (wrong dir) — should be utils/responses
All fixed to '../../src/...' (or '../../src/utils/responses' for class C).
routes/auth/totp.js was already fixed in the DC-006 commit.
Post-fix: 922/922 tests pass, zero new ESLint warnings. No logic changes —
purely mechanical require() path corrections.
After the DC-005 module reorganization (41 files moved into src/ subdirs),
138 test suites failed because the refactor script's path-rewrite logic
missed three categories:
1. Files inside src/ doing 'require("./src/...")' — should be 'require("../...")'
2. Files in src/X/Y/ doing 'require("../../../src/...")' — should be 'require("../../...")'
3. Test files in __tests__/ with leftover 'require("../../../src/...")' paths
Root cause: the original refactor script ran before all files were moved,
so it computed relative paths against stale filesystem state.
Result:
- 30/30 test suites pass
- 879/879 tests pass (was: 18/30 suites, 614/687 tests)
Also fixed:
- routes/apps/restore.js: wrong responses import path
- routes/*/*.js: '../../src/utilities/X' → '../src/utilities/X' (depth 2 routes)
Full codebase including API server (32 modules + routes), dashboard frontend,
DashCA certificate distribution, installer script, and deployment skills.