fix: match parameterized public auth routes

This commit is contained in:
Krystie
2026-07-21 21:59:03 -07:00
parent d9e61ce1b7
commit a2a2bee71e
3 changed files with 110 additions and 2 deletions
@@ -0,0 +1,89 @@
/**
* Regression tests for PUBLIC_ROUTES / CSRF excludedPaths `:param` placeholder
* matching. Pre-DC-053 these were literal-string comparisons, so
* `/api/v1/share/:token/preview` never matched real request paths like
* `/api/v1/share/abc123/preview`. Fixed by converting `:param` to a
* `[^/]+` regex segment before testing. Caught during DC-053 live testing.
*/
'use strict';
const fs = require('fs');
const path = require('path');
const os = require('os');
function _tmpDir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dashcaddy-public-test-'));
}
function _cleanup(dir) {
try { fs.rmSync(dir, { recursive: true, force: true }); } catch {}
}
const SRC_MIDDLEWARE = path.join(__dirname, '..', 'src', 'utilities', 'middleware.js');
const SRC_CSRF = path.join(__dirname, '..', 'src', 'security', 'csrf-protection.js');
describe('PUBLIC_ROUTES + CSRF excludedPaths: `:param` placeholder matching', () => {
test('PUBLIC_ROUTES is parsed and contains the DC-053 share entries', () => {
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
// Sanity: file should still contain the public share entries
expect(content).toContain('/api/v1/share/:token/preview');
expect(content).toContain('/api/v1/share/:token/subscribe');
expect(content).toContain('/api/v1/share/:token/redeem-tailscale');
});
test('CSRF excludedPaths contains the DC-053 share entries', () => {
const content = fs.readFileSync(SRC_CSRF, 'utf8');
expect(content).toContain('/api/v1/share/:token/subscribe');
expect(content).toContain('/api/v1/share/:token/redeem-tailscale');
});
test('PUBLIC_ROUTES contains the DC-048 invite entries (regression coverage)', () => {
const content = fs.readFileSync(SRC_MIDDLEWARE, 'utf8');
expect(content).toContain('/api/v1/auth/invites/:token');
expect(content).toContain('/api/v1/auth/invites/:token/accept');
});
test('CSRF excludedPaths contains the DC-048 invite entry', () => {
const content = fs.readFileSync(SRC_CSRF, 'utf8');
expect(content).toContain('/api/v1/auth/invites/:token/accept');
});
// Behavioral test: the regex conversion that the middleware applies to a
// `:param` entry should match real request paths. This exercises the SAME
// algorithm used by `isPublicRoute()` in src/utilities/middleware.js and
// `isExcluded` in src/security/csrf-protection.js, just in isolation.
function _placeholderToRegex(p) {
return '^' + p.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
}
test('placeholder-to-regex algorithm matches share preview paths', () => {
const pattern = _placeholderToRegex('/api/v1/share/:token/preview');
expect(new RegExp(pattern).test('/api/v1/share/abc123/preview')).toBe(true);
expect(new RegExp(pattern).test('/api/v1/share/some-very-long-token/preview')).toBe(true);
// Different method/path segments should not match
expect(new RegExp(pattern).test('/api/v1/share/abc/extra/preview')).toBe(false);
expect(new RegExp(pattern).test('/api/v1/share/preview')).toBe(false);
});
test('placeholder-to-regex matches multi-param paths', () => {
const pattern = _placeholderToRegex('/api/v1/auth/login/:provider/verify');
expect(new RegExp(pattern).test('/api/v1/auth/login/totp/verify')).toBe(true);
expect(new RegExp(pattern).test('/api/v1/auth/login/email/verify')).toBe(true);
expect(new RegExp(pattern).test('/api/v1/auth/login/totp/initiate')).toBe(false);
});
test('placeholder-to-regex handles exact paths (no placeholders)', () => {
const pattern = _placeholderToRegex('/health/live');
expect(new RegExp(pattern).test('/health/live')).toBe(true);
expect(new RegExp(pattern).test('/health/ready')).toBe(false);
});
test('placeholder-to-regex handles the auth/gate/ prefix exemption', () => {
// /api/v1/auth/gate/ is a prefix match (not in PUBLIC_ROUTES entries
// individually). Verify the algorithm preserves this by NOT requiring
// placeholders when none are present.
const pattern = _placeholderToRegex('/api/v1/auth/gate/foo');
expect(new RegExp(pattern).test('/api/v1/auth/gate/foo')).toBe(true);
expect(new RegExp(pattern).test('/api/v1/auth/gate/bar')).toBe(false);
});
});
+10 -2
View File
@@ -172,8 +172,16 @@ function csrfValidationMiddleware(req, res, next) {
'/api/v1/system/update-notify' '/api/v1/system/update-notify'
]; ];
const isExcluded = excludedPaths.some(path => req.path === path) || const isExcluded = excludedPaths.some(path => {
req.path.startsWith('/api/v1/auth/gate/'); if (req.path === path) return true;
// Allow `:param` placeholders to match any single segment. Pre-existing
// bug — literal ':token' never matched real tokens — fixed under DC-053.
if (path.includes(':')) {
const pattern = '^' + path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
return new RegExp(pattern).test(req.path);
}
return false;
}) || req.path.startsWith('/api/v1/auth/gate/');
if (isExcluded) { if (isExcluded) {
return next(); return next();
+11
View File
@@ -392,6 +392,17 @@ module.exports = function configureMiddleware(app, {
function isPublicRoute(req) { function isPublicRoute(req) {
return PUBLIC_ROUTES.some(r => { return PUBLIC_ROUTES.some(r => {
if (r.method && req.method !== r.method) return false; if (r.method && req.method !== r.method) return false;
if (r.exact) {
// Exact string match, BUT allow `:param` placeholders in the
// PUBLIC_ROUTES entry to match any single path segment. This was a
// pre-existing bug — literal ':token' never matched real tokens —
// caught by DC-053 public share preview returning 401.
if (r.path.includes(':')) {
const pattern = '^' + r.path.replace(/:[A-Za-z_][A-Za-z0-9_]*/g, '[^/]+') + '$';
return new RegExp(pattern).test(req.path);
}
return req.path === r.path;
}
return r.prefix ? req.path.startsWith(r.path) : req.path === r.path; return r.prefix ? req.path.startsWith(r.path) : req.path === r.path;
}); });
} }