Merge remote-tracking branch 'origin/fix/dc-069-caddy-admin-ipv6-origin'
This commit is contained in:
@@ -0,0 +1,241 @@
|
||||
/**
|
||||
* 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));
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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(),
|
||||
|
||||
@@ -3,6 +3,21 @@
|
||||
|
||||
# Global options
|
||||
{
|
||||
# The default `admin localhost:2019` binds to the loopback interface, so
|
||||
# Caddy's `enforce_origin` CSRF guard is never engaged and no `origins`
|
||||
# directive is required. (Note: glibc resolves `localhost` to `::1`
|
||||
# first per RFC 3484, so `admin localhost:2019` typically binds BOTH
|
||||
# IPv4 and IPv6 loopback — the actionable point is that any loopback
|
||||
# bind skips enforce_origin, not the exact IPv4/IPv6 split.)
|
||||
#
|
||||
# If a non-loopback bind is adopted later (e.g. `admin 0.0.0.0:2019 { ... }`
|
||||
# so a docker container on the host's bridge can reach admin via
|
||||
# 172.17.0.1:2019), the admin block MUST include an `origins` allowlist.
|
||||
# On Linux, `localhost` resolves to `::1` FIRST per glibc RFC 3484 (because
|
||||
# /etc/hosts has `::1 localhost`), so allowlist entries must include the
|
||||
# IPv6 literal form `http://[::1]:2019` AND `http://ip6-localhost:2019`
|
||||
# (the glibc alias) — `http://localhost:2019` alone will 403 every probe
|
||||
# that resolves localhost to `::1`. See DC-051 + DC-069 in repo history.
|
||||
admin localhost:2019
|
||||
auto_https off
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user