diff --git a/dashcaddy-api/__tests__/public-route-placeholders.test.js b/dashcaddy-api/__tests__/public-route-placeholders.test.js new file mode 100644 index 0000000..9c23405 --- /dev/null +++ b/dashcaddy-api/__tests__/public-route-placeholders.test.js @@ -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); + }); +}); \ No newline at end of file diff --git a/dashcaddy-api/src/security/csrf-protection.js b/dashcaddy-api/src/security/csrf-protection.js index 6df9c52..fd92c61 100644 --- a/dashcaddy-api/src/security/csrf-protection.js +++ b/dashcaddy-api/src/security/csrf-protection.js @@ -172,8 +172,16 @@ function csrfValidationMiddleware(req, res, next) { '/api/v1/system/update-notify' ]; - const isExcluded = excludedPaths.some(path => req.path === path) || - req.path.startsWith('/api/v1/auth/gate/'); + const isExcluded = excludedPaths.some(path => { + 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) { return next(); diff --git a/dashcaddy-api/src/utilities/middleware.js b/dashcaddy-api/src/utilities/middleware.js index 55631ca..e7ec8b8 100644 --- a/dashcaddy-api/src/utilities/middleware.js +++ b/dashcaddy-api/src/utilities/middleware.js @@ -392,6 +392,17 @@ module.exports = function configureMiddleware(app, { function isPublicRoute(req) { return PUBLIC_ROUTES.some(r => { 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; }); }