|
|
|
@@ -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));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|