fix(caddy-admin): IPv6 loopback origin allowlist + bracket-strip helper (DC-069) [glm-grade=A]
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.
This commit is contained in:
@@ -131,13 +131,35 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
*
|
||||
* Caller-provided `Origin` header (via opts.headers) wins so tests / future
|
||||
* proxies can override; default matches the parsed admin URL.
|
||||
*
|
||||
* IMPORTANT — IPv6 path (DC-069): on Linux, `dns.lookup('localhost')` returns
|
||||
* `::1` FIRST (per RFC 3484, because /etc/hosts has `::1 localhost`). When the
|
||||
* caller passes `http://localhost:2019/...`, `parsed.hostname` is `::1` AND
|
||||
* the auto-injected Origin is `http://[::1]:2019` — which means the Caddy
|
||||
* `origins` allowlist MUST contain `http://[::1]:2019` (and ideally
|
||||
* `http://ip6-localhost:2019` for the glibc alias), otherwise every on-host
|
||||
* Node probe via `localhost` gets a 403 with empty-Origin-looking error.
|
||||
* The corresponding `origins` entries live in `/etc/caddy/Caddyfile` on DNS2
|
||||
* (committed via `caddy-apply`) and are documented in
|
||||
* `dashcaddy-installer/templates/Caddyfile.template`.
|
||||
*/
|
||||
function _httpFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const parsed = new URL(url);
|
||||
const defaultOrigin = `${parsed.protocol}//${parsed.hostname}:${parsed.port || 2019}`;
|
||||
// Node 22's WHATWG URL parser preserves the brackets around IPv6
|
||||
// literals in `parsed.hostname` (e.g. '[::1]'), but `http.request({hostname})`
|
||||
// expects the BRACKETLESS form for actual connection — passing '[::1]'
|
||||
// triggers `getaddrinfo ENOTFOUND [::1]` and the request fails before
|
||||
// any Origin matching happens. Caddy's `enforce_origin` allowlist
|
||||
// matches by exact Origin string (which DOES include the brackets),
|
||||
// so we keep `defaultOrigin` bracket-form for the header but strip them
|
||||
// for the transport-layer hostname. (DC-069 — IPv6 admin probe path.)
|
||||
const transportHostname = parsed.hostname.startsWith('[') && parsed.hostname.endsWith(']')
|
||||
? parsed.hostname.slice(1, -1)
|
||||
: parsed.hostname;
|
||||
const options = {
|
||||
hostname: parsed.hostname,
|
||||
hostname: transportHostname,
|
||||
port: parsed.port || 2019,
|
||||
path: parsed.pathname + parsed.search,
|
||||
method: (opts.method || 'GET').toUpperCase(),
|
||||
|
||||
Reference in New Issue
Block a user