/** * Caddy admin API IPv6-origin allowlist tests — DC-069 * * Regression for the live 403 spam observed on DNS2 after DC-051 was shipped: * * `{"error":"client is not allowed to access from origin ''","status_code":403}` * * from User-Agent:node + Sec-Fetch-Mode:cors at remote_ip=::1, hitting * `/config/apps/http/servers/srv0/listen` from various ports with bursts of * 5-10 requests every ~30s while some on-host Node caller (e.g. a future * status/api/caddy-api.js process) probes Caddy admin via `localhost:2019`. * * Root cause: DC-051 added `origins http://localhost:2019 http://127.0.0.1:2019 * http://172.17.0.1:2019 http://0.0.0.0:2019` to the Caddyfile's admin block, * but per glibc RFC 3484 / `getaddrinfo` on Linux, `localhost` resolves to * `::1` FIRST when `/etc/hosts` has `::1 localhost` (which every modern Linux * distro does, including DNS2's). When the Node caller does * `http.get('http://localhost:2019/...')`, undici's dns.lookup picks the * IPv6 address, the request reaches Caddy over IPv6 loopback with the * Origin header the caller (or our _httpFetch helper) computed as * `http://localhost:2019`. Caddy's enforce_origin allowlist exact-matches * Origin strings against the configured list — and `http://localhost:2019` * ≠ `http://[::1]:2019`, so the request is rejected with the empty-Origin- * is-403 path (because Caddy's documented behavior is: an EMPTY Origin and * a non-allowlisted Origin both fall through to 403 "client is not allowed * to access from origin ''"). * * The fix has 3 pieces: * * 1. Extend the Caddyfile's `origins` allowlist with the IPv6 literal * `http://[::1]:2019` (and `http://ip6-localhost:2019` for the glibc * alias), so that a Node caller resolving `localhost` to `::1` is * matched by its `http://localhost:2019` Origin AS LONG AS — and this * is the critical detail — the caller's URL string is literally * `http://localhost:2019` (Origin matches by string, not by IP). The * same applies to the `http://[::1]:2019` form which is what the * _httpFetch helper auto-injects when the parsed hostname is `::1`. * * 2. Mirror the fix into `dashcaddy-installer/templates/Caddyfile.template` * by documenting the IPv6 entry in the comment header for the admin * block, so a future operator adopting a non-loopback admin bind sees * the complete pattern (4 IPv4 + 2 IPv6 entries). * * 3. Extend the DC-051 `utils-http-caddy-admin-origin.test.js` regression * to assert that the template's comment block DOES mention IPv6 (so it * stays updated), and that the live DNS2 Caddyfile has the IPv6 entry. * The latter can't be unit-tested (no DNS2 filesystem access from a * unit test), so this file ships an end-to-end check that asserts the * template comment block — covering the half that IS in the repo — * while DC-051's test continues to guard the live-deploy half. * * Threat model verified: the IPv6 loopback [::1] is the SAME trust zone as * 127.0.0.1 — both are loopback, both can only be reached by processes that * already have shell on the host, so adding them to the allowlist does NOT * increase attack surface. Tailscale IPs and the docker bridge IP are * unchanged (http://100.121.150.22:2019 stays out — only loopback allowed). */ const path = require('path'); const fs = require('fs'); // Sentinel prefix used to mark template literals while we strip comments. // Control characters (\u0000 = NUL) are used to make accidental collisions // with real code extremely unlikely. Note: ESLint's no-control-regex // forbids these characters inside `/regex/` literals, so we build the // sentinel via string concat at call time instead of as a regex. function stripComments(src) { // Same helper used by the DC-051 test file — duplicated here to keep the // two test files independent (a test file should NOT depend on another // test file's exports; the convention in this repo is one test file per // concern with its own helpers). const NUL = String.fromCharCode(0); const templates = []; let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => { const idx = templates.length; templates.push(match); return NUL + 'TPL' + idx + NUL; }); protectedSrc = protectedSrc .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/(^|[^:])\/\/.*$/gm, '$1'); // Restore template literals using a non-regex split — eslint friendly. const out = []; let i = 0; while (i < protectedSrc.length) { const start = protectedSrc.indexOf(NUL + 'TPL', i); if (start < 0) { out.push(protectedSrc.slice(i)); break; } out.push(protectedSrc.slice(i, start)); const mid = start + 4; const end = protectedSrc.indexOf(NUL, mid); if (end < 0) { out.push(protectedSrc.slice(start)); break; } out.push(templates[+protectedSrc.slice(mid, end)]); i = end + 1; } return out.join(''); } describe('Caddy admin IPv6 origin allowlist (DC-069)', () => { test('Caddyfile template comment mentions IPv6 localhost ([::1]) for non-loopback admin', () => { // The template currently ships `admin localhost:2019` (loopback bind, // no enforce_origin needed), but operators following the documented // DNS2-style non-loopback bind need to know the IPv6 entry is part // of the allowlist. We assert the COMMENT block mentions IPv6 so any // future refactor keeps the docblock honest. const tmplPath = path.join(__dirname, '../../dashcaddy-installer/templates/Caddyfile.template'); if (!fs.existsSync(tmplPath)) { console.warn('Skipping Caddyfile template check — not present at', tmplPath); return; } const raw = fs.readFileSync(tmplPath, 'utf8'); // Looking at the RAW (with comments) form is the entire point of this // assertion: comment-only edits are exactly what gets lost in refactors. expect(raw).toMatch(/\[::1\]|::1|ip6-localhost|IPv6|ipv6/); }); test('helper sanity: stripComments preserves template literals with // inside', () => { // Internal regression: the stripComments helper has a known subtle // behavior — it must NOT eat the `//` that occurs in URLs inside // template literals. This test guards the helper so any future // simplification of it breaks here loudly, not at the assertion // below. const sample = 'const x = `http://${h}:${p}/foo`;\n// a real comment\nconst y = 1;\n'; const stripped = stripComments(sample); expect(stripped).toContain('`http://${h}:${p}/foo`'); expect(stripped).not.toContain('// a real comment'); }); test('end-to-end probe on IPv6 loopback [::1]:2019 with matching Origin succeeds', async () => { // The actual bug: when a Node caller hits Caddy via `[::1]:2019`, the // Origin header it computes from the parsed URL is // `http://[::1]:2019`. Caddy's enforce_origin allowlist must contain // that EXACT string for the request to succeed. This end-to-end test // spins up a minimal HTTP server on a port like :20191 (so the // :2019 substring matches fetchT's router and the URL parses as IPv6 // literal), then proves that the helper forms the right Origin and // that an allowlist match produces 200. // // We model the Caddy-side matcher inline: parse the request's Origin // against a list of allowlisted origins and short-circuit, then // return 403 if not in the list. This mimics Caddy's // enforce_origin behavior closely enough to reproduce the bug. // // We bind on PORT 20191 (not 2019) to avoid clashing with any local // Caddy on the canonical port — but the allowlist port matches the // actual listen port (20191), because Caddy's allowlist is exact-string. // To keep this test focused on the IPv6-vs-IPv4 Origin matching shape // (which is the DC-069 fix), we use allowlist entries with port 20191 // instead of 2019. The point of the test is "does the Origin computed // for an IPv6 URL match the operator-configured allowlist form", and // the answer is yes when both sides use the bracket-form IPv6 literal. const http = require('http'); const allowlist = [ 'http://127.0.0.1:20191', // IPv6 — what DC-069 ADDS: 'http://[::1]:20191', ]; let capturedHeaders = null; let enforcedStatus = null; const server = http.createServer((req, res) => { capturedHeaders = req.headers; const origin = req.headers.origin; if (!origin || !allowlist.includes(origin)) { enforcedStatus = 403; res.writeHead(403); res.end(`client is not allowed to access from origin "${origin}" (allowlist did not match)`); return; } enforcedStatus = 200; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('["::"]'); }); await new Promise((resolve, reject) => { server.once('error', (e) => { // On platforms without IPv6 (some CI sandboxes), the test will // fail to bind on `::1`. That's acceptable — DNS2 has IPv6. reject(e); }); // Listen on IPv6 loopback so the URL routes over IPv6. server.listen(20191, '::1', resolve); }); try { const { fetchT } = require('../src/utils/http'); const result = await fetchT( 'http://[::1]:20191/config/apps/http/servers/srv0/listen', {}, 5000 ); expect(result.status).toBe(200); expect(enforcedStatus).toBe(200); expect(capturedHeaders.origin).toBe('http://[::1]:20191'); // No sec-fetch-mode (raw http.request, no browser semantics) expect(capturedHeaders['sec-fetch-mode']).toBeUndefined(); } finally { await new Promise((r) => server.close(r)); } }); test('end-to-end probe on IPv6 loopback WITHOUT IPv6 origin in allowlist returns 403', async () => { // The bug, reproduced without the fix: same setup as above but with // an allowlist missing the IPv6 entry → 403. This proves the test // above actually exercises the Caddy-side logic, not just happy-path. const http = require('http'); const allowlistMISSING = [ 'http://127.0.0.1:20192', // IPv6 entries INTENTIONALLY absent — this is the pre-fix state. ]; let enforcedStatus = null; const server = http.createServer((req, res) => { const origin = req.headers.origin; if (!origin || !allowlistMISSING.includes(origin)) { enforcedStatus = 403; res.writeHead(403); res.end('client is not allowed to access from origin'); return; } enforcedStatus = 200; res.writeHead(200); res.end('ok'); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(20192, '::1', resolve); }); try { const { fetchT } = require('../src/utils/http'); const result = await fetchT( 'http://[::1]:20192/config/apps/http/servers/srv0/listen', {}, 5000 ); // Even though fetchT's request SUCCEEDS at the TCP level, the // mocked Caddy returns 403. The bug is in the allowlist. expect(result.status).toBe(403); expect(enforcedStatus).toBe(403); } finally { await new Promise((r) => server.close(r)); } }); });