- routes/ca.js: declare format before pfx/pem/crt dispatch (was ReferenceError on every request that passed validation); add CA_CERT_FORMATS single source of truth + hardened format extraction (string-coerce, whitelist) - routes/caddycode.js: fix upstream-validation regexes (control-char classes) - routes/logs.js: SSE/validation lint fixes - routes/openclaw.js: remove useless regex escape in ALLOWED_PATH_RE - fleet-validation.js + http-caddy-admin-origin test: eslint-disable for intentional control-regex security sentinels - ca-dc076.routes.test.js: regression test for declared format + behavioral coverage of format validation (now pre-PKI) Judge: Qwen lane (qwen3.8-max) grade B, 0 blocking, verdict /tmp/judge-batch1-verdict.json. API suite 2859/2859 green.
277 lines
13 KiB
JavaScript
277 lines
13 KiB
JavaScript
/**
|
|
* Caddy admin API CSRF Origin-header tests — DC-051
|
|
*
|
|
* Verifies:
|
|
* - _httpFetch (fetchT's :2019 raw http branch) injects `Origin: http://<host>:<port>`
|
|
* 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.
|
|
// eslint-disable-next-line no-control-regex -- \u0000 is the sentinel from pass 1
|
|
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/);
|
|
}
|
|
});
|
|
}); |