Multiple modules derived file paths from __dirname, which is unstable in two
ways: (1) it moves whenever the file is reorganized under src/, and (2) it
points to the in-container source dir /app/src/<x> in production, which is
not bind-mounted, so writes would silently land in the image layer.
Affected modules (10 files): backup-manager, resource-monitor, update-manager,
docker-security, audit-logger, bundled-workflows, port-lock-manager, logging,
error-handler, license-keygen, plus crypto-utils and credential-manager which
already had multi-candidate resolvers but no centralised fallback.
Introduced platformPaths.dataDir (derived from SERVICES_FILE/CONFIG_FILE/
DNS_CREDENTIALS_FILE env vars when set, else path.dirname(servicesFile)) so
every module resolves the same canonical data directory. Each module now
fans the runtime files into the data dir while preserving per-file env-var
overrides for custom deployments.
Why a single resolver:
- one place to swap the default path scheme in v2.x without chasing
hardcoded __dirname joins
- a single source-of-truth for tests, backup tools, and the soon-to-be
added single-volume migration script
- prevents the class of DC-033 (self-updater 0.0.0) bugs where __dirname
drift in a subdirectory silently loses runtime state
Also fixed:
- audit-logger: AUDIT_LOG_FILE default was /app/src/security/audit-log.json
(writable in dev, image-layer in production). Now /app/data/audit-log.json
via platformPaths.dataDir, matching logging.js's same file. Same physical
path, no behavior change for callers that already set AUDIT_LOG_FILE.
- logging.js: LOG_DIR was __dirname (src/utils/) — error.log and
audit-log.json were being written into the source tree. Now
platformPaths.dataDir, matching every other persistent file.
- error-handler.js: ERROR_LOG_FILE hard-coded to __dirname/error.log
(src/utilities/error.log), redundant with logging.js's own default.
Now platformPaths.dataDir/error.log.
- host-registry / event-store / event-workers: simplified the
'platformPaths.dataDir || path.join(__dirname, ../../data)' pattern
to just platformPaths.dataDir (the legacy fallback is no longer
reachable — services.json lives at dataDir/services.json now).
- public-routes-drift.test.js: added 'routes/security.js' to the
direct-mount list so the /api/v1/security/events/ingest and
/api/v1/security/events/batch entries in PUBLIC_ROUTES are
recognized as mounted (was missing — fixed DC-044's drift-detection
test gap).
Tests: 1214/1214 pass (0 new failures, 1 new test for the corrected route
mount detection path). ESLint: 146 warnings + 4 errors — same baseline as
HEAD (no new warnings or errors introduced; one pre-existing require-await
on readline was removed as a drive-by in event-workers.js since the module
uses line-level fs reads, not readline). Container config files like
audit-log.json, container-stats.json, and workflow-history.json still
exist on the running container's image layer — Docker will pick up the
new defaults on the next recreate (the update path already moves
services.json+config.json+credentials.json via the data bind mount).
318 lines
14 KiB
JavaScript
318 lines
14 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
|
|
const blockMatch = content.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/services.js', // apiRouter.use(serviceRoutes({...})) // bare mount
|
|
'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({...}))
|
|
];
|
|
// 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/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 {
|
|
router = factory(universalDeps);
|
|
} 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)
|
|
// Express strips the mount path from layer.regex; reconstruct it from layer.regex
|
|
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);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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) {
|
|
if (layer.regex && layer.regex.fast_slash) return '';
|
|
if (!layer.regex || !layer.regex.source) return '';
|
|
// The source is something like '^\\/foo\\/?(?=\\/|$)' for mount path '/foo'.
|
|
// Match the first path segment after the optional leading slash.
|
|
const m = layer.regex.source.match(/^\\\/\(([^)]+)\)/);
|
|
if (m) {
|
|
// Convert path-to-regexp syntax like ':foo' or '*' back to a placeholder.
|
|
// For simple mounts (no params) this gives us the literal segment.
|
|
return '/' + m[1];
|
|
}
|
|
return '';
|
|
}
|
|
|
|
// 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', () => {
|
|
const stale = [];
|
|
for (const entry of publicRoutes) {
|
|
if (entry.endsWith('/')) continue; // prefix matches, skip
|
|
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);
|
|
});
|
|
}
|
|
});
|
|
});
|