diff --git a/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js b/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js new file mode 100644 index 0000000..a7f2748 --- /dev/null +++ b/dashcaddy-api/__tests__/utils-http-caddy-admin-origin.test.js @@ -0,0 +1,215 @@ +/** + * 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('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(...)`, not `await 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/); + } + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/src/utils/http.js b/dashcaddy-api/src/utils/http.js index 0e52423..ffc9a04 100644 --- a/dashcaddy-api/src/utils/http.js +++ b/dashcaddy-api/src/utils/http.js @@ -118,16 +118,33 @@ function _httpsFetch(url, opts = {}, timeoutMs = TIMEOUTS.HTTP_DEFAULT) { /** * Raw http.request wrapper for Caddy admin API + * + * Auto-injects `Origin: http://:` because Caddy's admin API on a + * non-loopback bind (e.g. `admin 0.0.0.0:2019` so the DashCaddy docker + * container can probe it from 172.17.0.1) enables `enforce_origin` and + * rejects every request whose Origin isn't in the admin's `origins` allowlist + * OR is empty. Node's undici fetch sets `Sec-Fetch-Mode: cors` which triggers + * the check; raw http.request sets no Origin at all, which fails the empty + * check. Setting Origin to the admin endpoint's own origin satisfies + * gorilla/csrf same-origin and is the documented override. + * (See: https://caddyserver.com/docs/caddyfile/options — `origins` directive.) + * + * Caller-provided `Origin` header (via opts.headers) wins so tests / future + * proxies can override; default matches the parsed admin URL. */ 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}`; const options = { hostname: parsed.hostname, port: parsed.port || 2019, path: parsed.pathname + parsed.search, method: (opts.method || 'GET').toUpperCase(), - headers: { ...opts.headers }, + headers: { + Origin: defaultOrigin, + ...opts.headers, + }, timeout: timeoutMs, };