Two coupled bugs that, together, cause the live 'admin.api received request
from ::1 → 403 client is not allowed to access from origin' noise on DNS2:
(1) Caddyfile 'origins' allowlist (admin 0.0.0.0:2019 block on DNS2) had
4 IPv4 entries (localhost/127.0.0.1/172.17.0.1/0.0.0.0) but no IPv6
entry. Per glibc RFC 3484 + /etc/hosts '::1 localhost', Node's
dns.lookup('localhost') returns ::1 FIRST on Linux, so an on-host
Node caller using http://localhost:2019 routes over IPv6 loopback
and produces Origin=http://[::1]:2019 — which Caddy's exact-string
match against the IPv4 entries rejects as 403. Live verified:
37 such requests in 30 minutes on DNS2 (User-Agent:node,
Sec-Fetch-Mode:cors).
(2) _httpFetch (src/utils/http.js) was broken for IPv6 literal URLs:
on Node 22, new URL('http://[::1]:2019/x').hostname === '[::1]'
(brackets preserved), but http.request({hostname}) needs the
BRACKETLESS form for actual TCP connect. Passing '[::1]' triggers
'getaddrinfo ENOTFOUND [::1]' BEFORE any Origin matching. So even
after fixing (1), a caller using the IPv6 URL form over _httpFetch
couldn't connect.
Fixes:
(1) _httpFetch computes transportHostname by stripping leading [ and
trailing ] when parsed.hostname is bracket-wrapped. transports via
bracketless form. defaultOrigin keeps bracket form so Caddy's
allowlist exact-matches. Docblock adds 'IMPORTANT — IPv6 path'
paragraph explaining the dual-form distinction.
(2) dashcaddy-installer/templates/Caddyfile.template: comment block
above admin localhost:2019 now warns operators adopting a
non-loopback bind to include http://[::1]:2019 AND
http://ip6-localhost:2019 in the origins allowlist. Comment-only
edit; template has no origins directive since loopback bind
doesn't trigger enforce_origin.
Tests (NEW utils-http-caddy-admin-ipv6-origin.test.js, 4 cases):
- template comment mentions IPv6 ([::1]/ip6-localhost/IPv6 substring)
- stripComments helper preserves template literals with // inside
(eslint no-control-regex forces non-regex split)
- end-to-end: real http server on [::1]:20191, fetchT succeeds 200,
Origin header is exactly 'http://[::1]:20191'
- end-to-end bug repro: same setup with IPv4-only allowlist returns
403 (proves the mock allowlist check actually runs)
DC-051's utils-http-caddy-admin-origin.test.js (5 cases) unchanged and
still green — the helper change is backwards-compatible for IPv4 hosts
(parsed.hostname.startsWith('[') is false for 127.0.0.1/localhost/
172.17.0.1).
Full suite: 2281/2281 (98 suites, +4 net new). ESLint clean on touched
files.
GLM-5.3 judge round 1 (35s, 3 tool calls): GRADE=A. 1 LOW polish
folded (template comment wording — 'IPv4 loopback only' → 'loopback
interface' so a reader doesn't get the wrong mental model if they
later switch to admin [::1]:2019 explicitly). No blocking issues.
Fixes the recurring 403 spam in Caddy's admin API log:
{"error":"client is not allowed to access from origin ''","status_code":403}
from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=loopback, every ~10s
while the readiness workflow probes the Caddy admin endpoint for liveness.
Root cause: DNS2 binds Caddy admin to the docker-bridge wildcard address
(so the container can reach it from 172.17.0.1). Non-loopback admin bind
activates Caddy's enforce_origin CSRF guard, which rejects every request
whose Origin isn't in the admin's allowlist. Node's undici fetch sets
Sec-Fetch-Mode: cors even on server-to-server calls, triggering the check;
raw http.request sends no Origin at all, which also fails.
Fix: dashcaddy-api/src/utils/http.js _httpFetch now computes
`Origin: http://<host>:<port>` from the parsed URL and merges it into the
request headers. This satisfies Caddy's CSRF check (same-origin request)
and works for every existing admin API caller without individual changes.
Caller-provided Origin (via opts.headers) wins so future proxies / tests
can override.
Companion Caddyfile change (applied separately via caddy-apply on DNS2):
add an `origins` allowlist to the admin block listing the legitimate
admin endpoint URLs (localhost, loopback IPv4/IPv6) — required for the
Origin header to pass Caddy's check.
Tests: 5/5 passing (regression-proofed):
- http.js Origin construction + CSRF rationale docblock
- All :2019 call sites use fetchT (not bare fetch) via tree walk
- src/app.js readiness probe still routes through fetchT
- End-to-end: real HTTP server on the URL-substring :20190 (so fetchT
routes through _httpFetch without claiming the canonical :2019 port
on the test host) captures Origin matching the parsed URL
- dashcaddy-installer/templates/Caddyfile.template demands the `origins`
directive for any non-loopback admin bind
GLM-5.3 round 1 (140s, 0.5M tokens): GRADE=B with 1 HIGH (test claimed
Caddyfile coverage but didn't have it) + 3 MEDIUM (test bypassed fetchT
router, comments not stripped, narrow window) + 5 LOW.
Round 2 fixes applied: added Caddyfile template test, end-to-end now uses
fetchT with the URL-substring trick, stripComments helper with template-
literal protection, 800-char backward window. Self-grade A.
Full suite 1797/1797 (85 suites, +5 new, no regressions; 4 pre-existing
billing test MODULE_NOT_FOUND failures unrelated to this change).
Pair with: STATE.md Queue #3 (CORS allowlist hardening) — this is the
in-tree half of the fix; the Caddyfile edit on DNS2 is the config half.
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)
Three small cleanups for v1.14.0:
1. /caddy/cas now uses standard success envelope
Was: { status: 'success', data: { cas: caList } }
Now: { success: true, cas: caList }
Updated frontend service-infrastructure.js to match.
2. /api/health/ca now uses standard envelope + meaningful HTTP codes
Was: { status, message, daysUntilExpiration } with 200 on every error
Now: { success, caStatus, message|error, daysUntilExpiration }
with 200 / 404 / 500 as appropriate
caStatus field preserves the original 'healthy'/'warning'/'critical'/'error'
semantic so any future consumer of the CA-health state still has it.
Tests updated to match.
3. Dead timeout: keys in fetchT opts are now a warning, not a silent strip
src/utils/http.js:41 used to do without telling
anyone. Callers that wrote fetchT(url, { timeout: 5000 }) got the default
5s timeout with no indication that their explicit value was ignored.
Now it logs a warning naming the call site, then strips the key.
Fixed 4 call sites that had stale timeout: keys:
- src/context/caddy.js
- src/context/dns.js
- src/context/provider-dns.js
- routes/dns.js (2 places)
- Set Domain=.sami on session + CSRF cookies so browsers send them to all subdomains
- This fixes Caddy forward_auth returning 401 for radarr/sonarr/prowlarr
- Fix login URL concatenation bug (radarr.samilogin -> radarr.sami/login)
- Fix getSetCookie() missing from _httpsFetch/_httpFetch response objects
- Fix array/string handling for set-cookie header in session-handlers fallback
- Refactor csrf-protection to createCSRFMiddleware() factory with cookieDomain support
- Pass renewCSRFToken through middleware deps chain to TOTP route