Pluggable AuthProvider framework for any future auth method (OIDC, SAML,
passkeys) to plug in without touching the auth path again. Two
implementations ship:
* TOTP — refactored from routes/auth/totp.js into src/auth/providers/totp.js
as one AuthProvider impl. Legacy /api/v1/totp/* routes stay mounted for
back-compat; new /api/v1/auth/login/totp/* routes use the new shape.
* EmailMagicLink — src/auth/providers/email.js. Initiate issues a 32-byte
base64url token, stores its SHA-256 hash in data/email-tokens.json
(atomic lockfile-based mutation, automatic TTL cleanup), and delivers via
nodemailer if providers.email.{host,port,username,password} is set OR
falls back to log.info('auth', 'email magic link issued', ...) for dev.
Verify accepts the token, marks it used, creates the same DashCaddy
session cookie that TOTP uses (single global cookie model).
createAuthProviderRegistry() composes both implementations and exposes
them via /api/v1/auth/login/{methods, :provider/initiate, :provider/verify,
recovery-info} and /api/v1/auth/disable/:provider. PUBLIC_ROUTES + CSRF
exemptions updated to use :provider placeholder (parameterized for future
providers).
Test fix: src/utilities/middleware.js PUBLIC_ROUTES and csrf-protection.js
both switched to the :provider form because the prior literal 'totp'
wouldn't match the parameterized mount path Express 4.22 produces.
Test fix: __tests__/public-routes-drift.test.js extractMountPath() was
broken for Express 4.22's new ^/path/?(?=/|$) source format (no \?\?
terminator in the literal-mount case). Rewrote the parser to normalize
escaped slashes + trailing lookaheads instead of relying on regex
matching against the raw source.
New: __tests__/auth-provider-registry.test.js — 9 tests covering registry
composition, getProvider round-trip, listEnabled no-secrets-leak guarantee,
enabled-flag respect, listAll vs listEnabled distinction, email provider
dev-console fallback (token written to JSON store + log.info with
deliveredVia: 'dev-console' + response masked), verify rejects unknown
tokens via AuthenticationError.
Tests: 1241/1241 passing across 46 suites (was 1232; +9 new).
DNS2 deploy: this commit + bump VERSION to 1.16.0 + publish tarball to
get.dashcaddy.net + docker build + bash start.sh. The image-layer migration
from DC-050 also runs on first container recreate post-merge.
353 lines
16 KiB
JavaScript
353 lines
16 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) — 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', () => {
|
|
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);
|
|
});
|
|
}
|
|
});
|
|
});
|