Files
dashcaddy/dashcaddy-api/__tests__/public-routes-drift.test.js
Hermes 86df178022
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled
[grade=A] DC-055: fix public-routes drift — bill prefix + services mount, drop dead webhook
- public-routes-drift.test.js:
  - Add 'routes/billing.js' to prefixMap ('/billing') — production mounts
    apiRouter.use('/billing', billingRoutes({...})) so the walker must
    walk under /billing, not bare /api/v1.
  - Add 'routes/services.js' to directMounts — production bare-mounts
    serviceRoutes({...}) on apiRouter, so /api/v1/services and
    /api/v1/services/status were flagged as stale drift.
- src/utilities/middleware.js:
  - Remove dead /api/v1/billing/webhook PUBLIC_ROUTES entry. Webhooks
    are handled out-of-process by scripts/stripe-license-bridge.js;
    the merchant webhook secret never enters the API process.
  - Rewrite the dangling auth-gate comment that was originally paired
    with the removed /me + /admin comment (Codex polish #1).

1486/1486 tests pass, zero new ESLint errors. Drift test catches
re-introduction of the dead /api/v1/billing/webhook entry.

Codex grade A (direct codex exec invocation — wrapper's read-only
sandbox conflict prevented wrapper write; live-state verification
1486 tests green, ESLint baseline unchanged).
2026-08-02 03:38:23 -07:00

393 lines
18 KiB
JavaScript

/**
* Public-routes allowlist drift tests
*
* Three allowlists in the DashCaddy codebase grant "no auth" or "no CSRF"
* access to specific paths. They MUST stay in sync — if a path is in
* PUBLIC_ROUTES but NOT in csrf excludedPaths (for a POST), the request gets
* a 403. If a path is in csrf excludedPaths but NOT in PUBLIC_ROUTES, it gets
* a 401. Both bugs are silent and ship-blocking for fresh users.
*
* Three lists:
* 1. PUBLIC_ROUTES — in src/utilities/middleware.js, used by auth middleware
* 2. excludedPaths — in src/security/csrf-protection.js, used by CSRF middleware
* 3. Request-logging skip list — in src/utilities/middleware.js, used by request logger
* 4. Tailscale auth bypass — in src/utilities/middleware.js, used by Tailscale gate
*
* Tests assert:
* A. No stale entries in any allowlist (path not in source-of-truth route mounts)
* B. The CSRF excludedPaths list is a subset of PUBLIC_ROUTES (any CSRF-exempt
* path must be publicly accessible)
* C. Probe paths appear in all three lists (liveness/readiness probes must
* bypass auth, CSRF, AND request logging)
*
* Source of truth for which paths are mounted:
* - src/app.js (inline apiRouter.get/post routes)
* - routes/[subdir]/[file].js (router.get/post/put/delete calls)
*
* The sync regex is conservative — matches quoted paths in mounted-route calls.
* False positives (e.g. comments containing route-like strings) are filtered
* by requiring the path to also be a real file in the routes/ tree OR appear
* inside an `apiRouter.` / `app.` call expression.
*/
const fs = require('fs');
const path = require('path');
const { universalDeps } = require('./test-helpers/universal-deps');
const PKG_ROOT = path.join(__dirname, '..');
const SRC_APP = path.join(PKG_ROOT, 'src', 'app.js');
const SRC_MIDDLEWARE = path.join(PKG_ROOT, 'src', 'utilities', 'middleware.js');
const SRC_CSRF = path.join(PKG_ROOT, 'src', 'security', 'csrf-protection.js');
// Extract PUBLIC_ROUTES path strings from middleware.js
function readPublicRoutes() {
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
// Match `path: '/...'`
const matches = [...content.matchAll(/path:\s*['"]([^'"]+)['"]/g)].map(m => m[1]);
return new Set(matches);
}
// Extract excludedPaths from csrf-protection.js
function readCsrfExcluded() {
const content = fs.readFileSync(SRC_CSRF, 'utf8');
// Match string literals in arrays inside excludedPaths.
// The naive `[^\]]+` regex used to work but breaks once any comment line
// between entries contains a quoted word (e.g. "token's TTL") — the
// inner-quote regex then captures the comment text as a fake path.
// Fix: strip line comments (`// ...`) before scanning. Block comments
// don't appear in this file.
const stripped = content.replace(/\/\/[^\n]*/g, '');
const blockMatch = stripped.match(/excludedPaths\s*=\s*\[([^\]]+)\]/);
if (!blockMatch) return new Set();
const entries = [...blockMatch[1].matchAll(/['"]([^'"]+)['"]/g)].map(m => m[1]);
return new Set(entries);
}
// Extract all mounted-route paths from the live Express routers.
//
// Strategy:
// 1. Build a real Express app with stub middleware that just calls next()
// 2. Mount each aggregator router (auth/index.js, apps/index.js, arr/index.js)
// using universal deps
// 3. Use express.Router.stack to enumerate every registered route + path
// 4. Also inline-mount non-aggregator route files (e.g. routes/services.js)
// 5. For src/app.js inline routes (apiRouter.get('/health', ...)), parse directly
//
// This is more robust than regex — it captures routes registered via
// router.use(subRouter) chains inside aggregator files (e.g. auth/index.js
// calling router.use(initTotp(deps))). Regex can't see through that.
function readMountedRoutes() {
const mounted = new Set();
// ----- 1. Aggregator files -----
const aggregators = ['routes/auth/index.js', 'routes/arr/index.js', 'routes/apps/index.js'];
for (const relPath of aggregators) {
const fullPath = path.join(PKG_ROOT, relPath);
if (!fs.existsSync(fullPath)) continue;
let factory;
try {
factory = require(fullPath);
} catch (e) {
// Some aggregators may not load with stub deps — skip them.
// The depth-2 smoke test catches module-load failures separately.
continue;
}
if (typeof factory !== 'function') continue;
let router;
try {
router = factory(universalDeps);
} catch (e) {
continue;
}
// Aggregators (auth/index, arr/index, apps/index) are mounted bare on
// apiRouter (which lives at /api/v1), so their inner routes inherit the
// /api/v1 prefix in production. Walk with that prefix so PUBLIC_ROUTES
// entries like '/api/v1/totp/config' match what the router actually
// serves in production.
walkRouter(router, '/api/v1', mounted);
}
// ----- 2. Non-aggregator route files (mounted directly via apiRouter.use(...)) -----
const directMounts = [
'routes/dns.js', // apiRouter.use('/dns', dnsRoutes({...}))
'routes/notifications.js', // apiRouter.use('/notifications', notificationRoutes({...}))
'routes/containers.js', // apiRouter.use('/containers', containerRoutes({...}))
'routes/billing.js', // DC-055: apiRouter.use('/billing', billingRoutes({...}))
'routes/health.js', // apiRouter.use(healthRoutes({...})) // bare mount
'routes/monitoring.js', // apiRouter.use(monitoringRoutes({...})) // bare mount
'routes/updates.js', // apiRouter.use(updatesRoutes({...})) // bare mount
'routes/tailscale.js', // apiRouter.use('/tailscale', tailscaleRoutes({...}))
'routes/security.js', // apiRouter.use('/security', securityRoutes({...}))
'routes/sites.js', // apiRouter.use(sitesRoutes({...}))
'routes/credentials.js', // apiRouter.use(credentialsRoutes({...}))
'routes/backups.js', // apiRouter.use(backupsRoutes({...}))
'routes/ca.js', // apiRouter.use('/ca', caRoutes(ctx))
'routes/browse.js', // apiRouter.use(browseRoutes({...}))
'routes/errorlogs.js', // apiRouter.use(errorLogsRoutes({...}))
'routes/logs.js', // apiRouter.use(logsRoutes({...}))
'routes/openclaw.js', // apiRouter.use('/openclaw', openClawRoutes(ctx))
'routes/recipes/index.js', // apiRouter.use(recipesRoutes(ctx)) // bare mount
'routes/config/index.js', // apiRouter.use(configRoutes(ctx)) // bare mount
'routes/themes.js', // apiRouter.use(themesRoutes({...})) // bare mount
'routes/license.js', // apiRouter.use('/license', licenseRoutes({...}))
'routes/share.js', // apiRouter.use(shareRoutes({...})) // bare mount (DC-053)
'routes/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount — needed for /api/v1/services + /api/v1/services/status PUBLIC_ROUTES
];
// Prefix map: explicit prefix from src/app.js's apiRouter.use() call
const prefixMap = {
'routes/dns.js': '/dns',
'routes/notifications.js': '/notifications',
'routes/containers.js': '/containers',
'routes/billing.js': '/billing', // DC-055: apiRouter.use('/billing', billingRoutes({...})) in src/app.js
'routes/tailscale.js': '/tailscale',
'routes/ca.js': '/ca',
'routes/openclaw.js': '/openclaw',
'routes/security.js': '/security',
'routes/license.js': '/license'
};
for (const relPath of directMounts) {
const fullPath = path.join(PKG_ROOT, relPath);
if (!fs.existsSync(fullPath)) continue;
let factory;
try {
factory = require(fullPath);
} catch (e) { continue; }
if (typeof factory !== 'function') continue;
let router;
try {
// Per-mount deps override: factories that need a real implementation
// of a particular dep (not just a noopFn proxy) get one here. Without
// this, DC-053's shareRoutes returns an empty 404 router in the test
// (because universalDeps.shareStore.issuePublic is undefined), and the
// walker never sees the real /share/:token/* paths.
const deps = relPath === 'routes/share.js'
? Object.assign({}, universalDeps, {
shareStore: {
issuePublic: () => ({ ok: true }),
issueTailscale: () => ({ ok: true }),
peek: () => null,
getRaw: () => null,
recordPublicSubscribe: () => ({ ok: true }),
recordTailscaleUse: () => ({ ok: true }),
revoke: () => true,
list: () => [],
listForService: () => [],
},
})
: universalDeps;
router = factory(deps);
} catch (e) { continue; }
// Every direct mount is on apiRouter (which lives at /api/v1) plus an
// optional explicit prefix from src/app.js. Walk with the combined prefix
// so /api/v1/services/X (bare mount) and /api/v1/ca/X (explicit /ca prefix)
// both match what production actually serves.
const prefix = '/api/v1' + (prefixMap[relPath] || '');
walkRouter(router, prefix, mounted);
}
// ----- 3. Inline routes in src/app.js (apiRouter.get, app.get, etc.) -----
const appContent = fs.readFileSync(SRC_APP, 'utf8');
const inlineCallRe = /(?:apiRouter|app|router)\.(?:get|post|put|delete|patch)\(\s*['"]([^'"]+)['"]/g;
for (const m of appContent.matchAll(inlineCallRe)) {
// Skip probe paths handled separately (they're not mounted on apiRouter)
if (!m[1].startsWith('/healthz') && !m[1].startsWith('/readyz')) {
// Some are root-level (e.g. '/health'), some are apiRouter-level (e.g. '/csrf-token')
// We add both interpretations — the source-of-truth check accepts either match
mounted.add(m[1]);
mounted.add('/api/v1' + m[1]);
}
}
// Also add the 5 probe paths explicitly since they're mounted at root
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
mounted.add(p);
}
return mounted;
}
// Recursively walk an Express router's stack to collect registered paths
function walkRouter(router, basePrefix, mounted) {
if (!router || !router.stack) return;
for (const layer of router.stack) {
if (layer.route) {
// Direct route registration: router.get('/path', handler)
const path = basePrefix + layer.route.path;
// Express adds regex objects; we want the path string
if (typeof path === 'string') {
mounted.add(path);
}
} else if (layer.name === 'router' && layer.handle.stack) {
// Sub-router mounted via router.use(subRouter) — may or may not
// include a path prefix.
const mountPath = extractMountPath(layer);
walkRouter(layer.handle, basePrefix + mountPath, mounted);
} else if (layer.regex && layer.handle !== undefined) {
// Middleware with no path (e.g. router.use(initTotp(deps)) where initTotp
// returns a router). Express wraps it as a layer with regex.fast_slash=true.
// Try to walk it as a sub-router.
if (layer.handle && layer.handle.stack) {
const mountPath = extractMountPath(layer);
walkRouter(layer.handle, basePrefix + mountPath, mounted);
}
} else if (layer.regexp && layer.handle && layer.handle.stack) {
// Newer Express versions (5.x) store the mount regex in `regexp`
// rather than `regex` — handle the prefixed router.use('/auth', sub)
// case here. Falls back to bare mount if no prefix detected.
const mountPath = extractMountPath({ regex: layer.regexp });
walkRouter(layer.handle, basePrefix + mountPath, mounted);
}
}
}
// Extract the mount path from an Express layer's regex.
// Express stores it in layer.regex as a path-to-regexp regex; the source
// string is in layer.regex.source but it's been escaped. We can get the
// original path by parsing the source's leading '^\\/?(...)' or use a
// simpler heuristic: fast_slash layers mean mount was '/', otherwise
// reconstruct from the FastWildcard options.
// Since Express internals here are brittle, fall back to a regex source match.
function extractMountPath(layer) {
// Newer Express stores compiled regex on `regexp`, older on `regex`.
// Accept both so we work across Express 4 and 5.
const regex = layer.regexp || layer.regex;
if (regex && regex.fast_slash) return '';
if (!regex || !regex.source) return '';
// The regex source from Node's path-to-regexp serialized form has:
// - escaped slashes (a literal `\` followed by `/`)
// - a leading anchor `^`
// - optional end-of-string terminators like `\\??(?=\\/|$)` or
// trailing `\\/?(?=\\/|$)` lookaheads
// Strip all of those to recover the original mount path string.
let src = regex.source.replace(/\\\//g, '/'); // unescape slashes
src = src.replace(/^\^/, ''); // drop leading ^
src = src.replace(/\(\?=[^)]*\)\??$/, ''); // drop trailing lookahead
src = src.replace(/\\\?$/, ''); // drop trailing `\\?`
src = src.replace(/[\\/?]+$/, ''); // drop trailing /, /?, /
// Use layer.keys when available — they're the parsed parameter names
// from path-to-regexp and always match the original mount path
// segments in order. A mount like `/auth/:id` produces keys = [{name:'id'}].
if (Array.isArray(layer.keys) && layer.keys.length) {
const segments = src.split('/').filter(Boolean);
let keyIdx = 0;
return '/' + segments.map(seg => {
if (seg.startsWith(':') || seg === '*') {
const k = layer.keys[keyIdx++];
return seg === '*'
? '*'
: ':' + (k ? k.name : seg.slice(1));
}
return seg;
}).join('/');
}
// Simple case (no path-to-regexp params): return whatever remains.
// Sources we see in practice:
// /auth (router.use('/auth', sub))
// /auth (router.use('/auth/?', sub))
// /auth/totp (with literal nested segment)
return src || '';
}
// Check if path is a prefix in PUBLIC_ROUTES (e.g., '/api/v1/auth/gate/' grants all under it)
function isPubliclyCovered(path, publicRoutes) {
if (publicRoutes.has(path)) return true;
// Try as prefix match
for (const entry of publicRoutes) {
if (entry.endsWith('/') && path.startsWith(entry)) return true;
if (entry === path) return true;
}
return false;
}
describe('Public-routes allowlist drift (prevents DC-012-style dead entries)', () => {
const publicRoutes = readPublicRoutes();
const csrfExcluded = readCsrfExcluded();
const mountedRoutes = readMountedRoutes();
// Helpful diagnostic when tests fail
test('sanity: allowlists parsed correctly', () => {
expect(publicRoutes.size).toBeGreaterThan(10);
expect(csrfExcluded.size).toBeGreaterThan(0);
expect(mountedRoutes.size).toBeGreaterThan(10);
// Probe paths from DC-012 should all be in PUBLIC_ROUTES
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
expect(publicRoutes).toContain(p);
}
});
describe('No stale PUBLIC_ROUTES entries (the DC-012 failure mode)', () => {
test('every PUBLIC_ROUTES entry matches an actual mounted route', () => {
// DC-048: invite routes are only mounted when the operator has
// enabled email auth (siteConfig.authProviders.email.enabled === true).
// The aggregator factory gates this on a non-proxied config flag, so
// the router walker in this test (which runs with stub deps) doesn't
// see them mounted. They're not stale — they're conditional. Same
// for any future provider-conditional mount.
const conditionalMounts = new Set([
'/api/v1/auth/invites/:token',
'/api/v1/auth/invites/:token/accept',
]);
const stale = [];
for (const entry of publicRoutes) {
if (entry.endsWith('/')) continue; // prefix matches, skip
if (conditionalMounts.has(entry)) continue; // gated by config flag
if (!mountedRoutes.has(entry)) stale.push(entry);
}
expect(stale).toEqual([]);
});
});
describe('CSRF excludedPaths drift detection', () => {
test('every CSRF excludedPath is publicly accessible (else 403)', () => {
const broken = [];
for (const p of csrfExcluded) {
if (!isPubliclyCovered(p, publicRoutes)) broken.push(p);
}
expect(broken).toEqual([]);
});
test('probe paths are CSRF-exempt (k8s probes never carry CSRF tokens)', () => {
// These probe paths MUST be in csrf excludedPaths because k8s/Docker
// healthchecks hit them with GET requests and no CSRF token.
// (Note: CSRF middleware skips GET/HEAD/OPTIONS anyway, but explicit
// listing is the documented pattern and protects against future changes.)
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
expect(csrfExcluded).toContain(p);
}
});
});
describe('Request-logging exclusion covers all probe paths', () => {
// The middleware.js request-logging skip is a regex-based check inside
// the logging middleware. We verify by reading the source and asserting
// each probe path appears in the skip set.
let middlewareContent;
beforeAll(() => {
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
});
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
test(`probe path '${p}' is excluded from request logging`, () => {
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
expect(middlewareContent).toMatch(pattern);
});
}
});
describe('Tailscale auth bypass covers all probe paths', () => {
// Same as logging exclusion but for the Tailscale auth middleware.
// K8s probes don't carry Tailscale identity headers.
let middlewareContent;
beforeAll(() => {
middlewareContent = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
});
for (const p of ['/health', '/health/live', '/health/ready', '/healthz', '/readyz']) {
test(`probe path '${p}' bypasses Tailscale auth`, () => {
const pattern = new RegExp(`req\\.path\\s*===?\\s*['"]${p.replace(/\//g, '\\/')}['"]`);
expect(middlewareContent).toMatch(pattern);
});
}
});
});