/** * Caddy admin API CSRF Origin-header tests — DC-051 * * Verifies: * - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://:` * for any Caddy admin URL, satisfying Caddy's `enforce_origin` CSRF check * that activates on non-loopback admin binds (e.g. `admin 0.0.0.0:2019`). * - Caller-provided Origin via opts.headers WINS over the auto-injected * default (so future proxies / tests can override). * - fetchT routes :2019 URLs through _httpFetch (raw http.request) and * leaves HTTPS URLs on Node's undici fetch (for self-signed cert support). * - The /config/apps/http/servers/srv0/listen health probe that the readiness * handler emits against http://localhost:2019 includes the Origin header. * * Regression for the live 403 spam observed on DNS2 (Caddy log: * `{"error":"client is not allowed to access from origin ''","status_code":403}` * from User-Agent:node + Sec-Fetch-Mode:cors at remote_port 5xxxx, repeated * every ~10s while the readiness workflow probes Caddy admin). The fix is * the Origin header injection here + the `origins` directive in the * Caddyfile's admin block on DNS2 — both are required for Caddy's CSRF * check to accept same-origin admin calls. */ // Capture the http.request call shape without spinning up a real server. // We do this by reading the http.js source and exporting a probe function // that the test calls directly — this avoids brittle mock plumbing while // still proving the Origin header is constructed correctly. // // Strategy: the test imports a small wrapper that exposes the request // construction step from _httpFetch in isolation, then asserts on the // returned options. const path = require('path'); const fs = require('fs'); // Strip JS comments so docblock prose doesn't false-positive on regex // patterns that look for code (e.g. `origins`, `enforce_origin`). // IMPORTANT: do not strip `//` inside template literals — those are // URL/comment sequences like `http://${parsed.hostname}:${parsed.port}`. // We do this in two passes: (1) protect template-literal contents by // replacing them with placeholders, (2) strip comments, (3) restore // the placeholders. function stripComments(src) { // Pass 1: replace template literals (backtick-delimited) with sentinels. const templates = []; let protectedSrc = src.replace(/`(?:\\.|[^`\\])*`/g, (match) => { const idx = templates.length; templates.push(match); return `\u0000TPL${idx}\u0000`; }); // Pass 2: strip block + line comments from the now-comment-safe string. protectedSrc = protectedSrc .replace(/\/\*[\s\S]*?\*\//g, '') // block comments .replace(/(^|[^:])\/\/.*$/gm, '$1'); // line comments, leaving URLs alone // Pass 3: restore template literals. return protectedSrc.replace(/\u0000TPL(\d+)\u0000/g, (_, idx) => templates[+idx]); } const { fetchT } = require('../src/utils/http'); describe('Caddyfile + utils/http.js — Origin header construction (DC-051)', () => { test('http.js _httpFetch computes Origin from parsed URL host+port', () => { // Read the source file and verify the Origin line is constructed from // the parsed URL's hostname+port, matching what the readiness probe needs. const code = stripComments(fs.readFileSync( path.join(__dirname, '../src/utils/http.js'), 'utf8' )); // 1. The default origin is built from the parsed URL expect(code).toMatch(/const defaultOrigin\s*=\s*`\$\{parsed\.protocol\}\/\/\$\{parsed\.hostname\}:\$\{parsed\.port\s*\|\|\s*2019\}`/); // 2. The Origin header is set, with caller opts.headers spread after // (so caller wins on duplicate keys) expect(code).toMatch(/headers:\s*{\s*Origin:\s*defaultOrigin,\s*\.\.\.opts\.headers,/); // 3. The router still routes :2019 to _httpFetch (raw http.request) expect(code).toMatch(/if\s*\(url\.includes\(':2019'\)\)/); // 4. Comments explain the CSRF rationale (regression-proofing). // We check the RAW (with comments) source so this catches accidental // removal of the rationale docblock too. const raw = fs.readFileSync( path.join(__dirname, '../src/utils/http.js'), 'utf8' ); expect(raw).toMatch(/enforce_origin/); expect(raw).toMatch(/origins/); }); test('all :2019 call sites use fetchT (not raw fetch)', () => { // Every Caddy admin API call in the API code should go through fetchT, // not bare fetch — fetchT routes :2019 through _httpFetch which now // injects Origin. A new call site using bare fetch would skip the // CSRF fix and re-introduce the 403 loop. const apiRoot = path.join(__dirname, '..'); const offenders = []; function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules' || entry.name === '__tests__') continue; const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p); else if (entry.name.endsWith('.js')) { const text = stripComments(fs.readFileSync(p, 'utf8')); // Find every `fetch(` call and check whether the SAME call contains // a :2019 URL — if so, it should be `fetchT(` instead. const matches = text.match(/await\s+fetch\(([^)]*)\)/g) || []; for (const m of matches) { if (/:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(m)) { offenders.push(`${p}: ${m.slice(0, 100)}`); break; } } } } } walk(apiRoot); expect(offenders).toEqual([]); }); test('all :2019 call sites in TESTS use fetchT or a mocked fetchT (not raw fetch)', () => { // DC-087 — the same rule, extended into __tests__. The api-code walk above // skips __tests__, which let two mirrored health-handler test files keep a // raw await-fetch caddy probe long after src/app.js moved to fetchT. On a // host where the suite runs alongside a live Caddy admin (the prod box // runs the full jest suite every 30 min via a cron adversarial check), // that Origin-less raw fetch 403-spammed the Caddy journal (~700 // client-not-allowed error lines per day) while the tests still passed — // checks.caddy.ok=false was silently accepted as sandbox noise. Mirrors // MUST call fetchT (mocked at buildApp scope for hermeticity). A raw // await-fetch at a Caddy-admin-URL call site in a test is an offender. // NOTE: keep this comment free of backticks — stripComments pairs // backtick spans across lines, and a stray pair shields real code from // the comment stripper (this test self-flagged its first draft). // // Detection is deliberately FILE-LEVEL, not call-window: the historical // drift kept the fetch call itself token-free (the URL came from a // caddyUrl variable defined on a PREVIOUS line from CADDY_ADMIN_URL), // so a call-window regex never fired. Any raw await-fetch in a file // that also references the Caddy admin anywhere is an offender. // Escape hatch for future tests that intentionally assert Origin-less // 403 behavior against their own local listener: put the marker // DC-087-ALLOW-RAW-FETCH in the file and it is skipped. const testsRoot = path.join(__dirname); const offenders = []; const skipped = []; function walk(dir) { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { if (entry.name === 'node_modules') continue; const p = path.join(dir, entry.name); if (entry.isDirectory()) walk(p); else if (entry.name.endsWith('.js')) { const rawText = fs.readFileSync(p, 'utf8'); // Escape hatch (checked on RAW text so a comment marker works — // comments are stripped below): a file carrying the // DC-087-ALLOW-RAW-FETCH marker declares it intentionally // raw-fetches the Caddy admin (e.g. asserting Origin-less 403 // against its own local listener). The guard file itself is // always scanned (never skipped) so the hatch can't be used to // blind this very test. if (p !== __filename && /DC-087-ALLOW-RAW-FETCH/.test(rawText)) { skipped.push(p); continue; } const text = stripComments(rawText); const hasAdminToken = /:2019|adminUrl|admin_api_url|CADDY_ADMIN/.test(text); const hasRawAwaitFetch = /await\s+fetch\(/.test(text); if (hasAdminToken && hasRawAwaitFetch) { offenders.push(`${p}: raw await-fetch in a file referencing the Caddy admin (mock fetchT instead; documented escape-hatch marker available for intentional 403 tests)`); } } } } walk(testsRoot); if (skipped.length) { // Visibility for hatch use — shows up in jest output for reviewers. console.info('[DC-087 guard] escape-hatch skipped:', skipped.join(', ')); } expect(offenders).toEqual([]); }); test('readiness handler in src/app.js probes the exact URL the watcher needs', () => { const raw = fs.readFileSync( path.join(__dirname, '../src/app.js'), 'utf8' ); // The probe URL is the one that was 403-looping every 10s in prod. expect(raw).toMatch(/\/config\/apps\/http\/servers\/srv0\/listen/); // Goes through fetchT, NOT bare fetch — that's how the Origin injection // takes effect. Look at the 800 chars BEFORE the probe URL on the same // line / call site — the call must be fetchT(...), never a raw await of // the global fetch. (We look backward because the URL sits inside the // call's argument list, so the call site comes before the URL token.) const idx = raw.indexOf('srv0/listen'); const around = raw.substr(Math.max(0, idx - 400), 800); expect(around).toMatch(/fetchT\(/); expect(around).not.toMatch(/await fetch\(/); }); test('end-to-end: fetchT sends Origin header to a real HTTP server on :2019', async () => { // Spin up a minimal HTTP server on a port that LOOKS like :2019 from // fetchT's router perspective. We use port :20190 (contains ':2019' // substring so url.includes(':2019') is true → routes through _httpFetch) // to avoid clashing with any local Caddy on the canonical :2019. const http = require('http'); let capturedHeaders = null; const server = http.createServer((req, res) => { capturedHeaders = req.headers; res.writeHead(200, { 'Content-Type': 'application/json' }); res.end('["::"]'); }); await new Promise((resolve, reject) => { server.once('error', reject); server.listen(20190, '127.0.0.1', resolve); }); try { // fetchT routes this URL through _httpFetch because it includes // ':2019' as a substring. _httpFetch computes Origin from the // parsed URL — parsed.port is '20190' here, so Origin is // http://127.0.0.1:20190. const result = await fetchT( 'http://127.0.0.1:20190/config/apps/http/servers/srv0/listen', {}, 5000 ); expect(result.status).toBe(200); expect(capturedHeaders.origin).toBe('http://127.0.0.1:20190'); // raw http doesn't add User-Agent by default expect(capturedHeaders['user-agent']).toBeUndefined(); // critical: no Sec-Fetch-Mode: cors (that's what triggers Caddy's CSRF) expect(capturedHeaders['sec-fetch-mode']).toBeUndefined(); } finally { await new Promise((r) => server.close(r)); } }); test('Caddyfile template documents the origins directive for non-loopback admin bind', () => { // The HIGH-severity fix from GLM review: the live /etc/caddy/Caddyfile // is operator-managed (via caddy-apply, NOT in this repo), so this // test guards the only Caddyfile that IS in the repo — the installer // template — so any future operator using `admin 0.0.0.0:2019` (like // DNS2 does for the docker bridge to reach it) sees the same shape // and isn't surprised by the 403 loop. If a future change adopts // non-loopback admin in the template, this test demands the `origins` // directive alongside it. const tmplPath = path.join(__dirname, '../dashcaddy-installer/templates/Caddyfile.template'); const exists = fs.existsSync(tmplPath); if (!exists) { // Template absent (maybe removed in a refactor) — skip with explicit note console.warn('Skipping Caddyfile template check — not present at', tmplPath); return; } const raw = fs.readFileSync(tmplPath, 'utf8'); // Strip comments to look at the actual config shape. const code = stripComments(raw); const adminBlock = code.match(/admin\s+([^{\s]+)(?:\s+\{([^}]*)\})?/); if (!adminBlock) { // No admin block configured at all — operator default; nothing to check. return; } const listen = adminBlock[1]; const isLoopback = listen === '127.0.0.1:2019' || listen === 'localhost:2019' || listen === '::1:2019'; const inner = adminBlock[2] || ''; if (!isLoopback) { // Non-loopback bind — the `origins` directive is REQUIRED to prevent // the 403 loop we just fixed. This assertion will fail if someone // changes the template to non-loopback without adding origins. expect(inner).toMatch(/origins\s/); } else { // Loopback bind — Caddy allows loopback origins implicitly, so the // `origins` directive is unnecessary. We just verify the template // shape is consistent (admin bind + optional inner block). expect(listen).toMatch(/:2019/); } }); });