mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 13:21:46 +00:00
292e443dbe
Second-pass fixes caught by a self-review after the initial commit — each one would have undermined a fix from the previous commit. - mfaPolicy now goes through `verifyJwtAndLoadUser` too. Without this, a JWT stolen before a password reset still satisfied `require_mfa` until its natural 24h expiry, defeating the whole point of the password_version bump. - Drop the `?? keys[0]` fallback in OIDC JWKS key selection. When the token carries a `kid` that is not in the current JWKS, refuse outright instead of picking an arbitrary key and letting the signature check produce a generic failure — the real failure mode deserves a specific error code. - Tighten OAuth DCR custom-scheme rule so `javascript:`, `data:`, `vbscript:`, `file:`, `blob:`, `about:`, `chrome:` are all rejected. Previously the catch-all "not http/https" check admitted them; the authorize flow later 302s the browser to whatever is registered, which with a `javascript:` URI would execute attacker script on redirect. Also require the private-use scheme body to be reverse-DNS (contain a dot), matching RFC 8252 §7.1. - permanentDeleteFile / emptyTrash only delete the trip_files row when the on-disk unlink actually succeeded. Previously Promise.all swallowed individual unlink failures and DELETE ran unconditionally, so a permission / ENOSPC failure would orphan bytes on disk. - restoreFromZip also invalidates the permissions cache in the outer catch. If extraction threw before the DB swap even started, the cache wasn't stale, but belt-and-braces is cheap and guarantees no failed-restore path leaves stale cache behind.
100 lines
3.4 KiB
TypeScript
100 lines
3.4 KiB
TypeScript
import { Request, Response, NextFunction } from 'express';
|
|
import { db } from '../db/database';
|
|
import { extractToken, verifyJwtAndLoadUser } from './auth';
|
|
import { DEMO_EMAILS } from '../services/demo';
|
|
|
|
/** Paths that never require MFA (public or pre-auth). */
|
|
export function isPublicApiPath(method: string, pathNoQuery: string): boolean {
|
|
if (method === 'GET' && pathNoQuery === '/api/health') return true;
|
|
if (method === 'GET' && pathNoQuery === '/api/auth/app-config') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/login') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/register') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/demo-login') return true;
|
|
if (method === 'GET' && pathNoQuery.startsWith('/api/auth/invite/')) return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/verify-login') return true;
|
|
if (pathNoQuery.startsWith('/api/auth/oidc/')) return true;
|
|
return false;
|
|
}
|
|
|
|
/** Authenticated paths allowed while MFA is not yet enabled (setup + lockout recovery). */
|
|
export function isMfaSetupExemptPath(method: string, pathNoQuery: string): boolean {
|
|
if (method === 'GET' && pathNoQuery === '/api/auth/me') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/setup') return true;
|
|
if (method === 'POST' && pathNoQuery === '/api/auth/mfa/enable') return true;
|
|
if ((method === 'GET' || method === 'PUT') && pathNoQuery === '/api/auth/app-settings') return true;
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* When app_settings.require_mfa is true, block API access for users without MFA enabled,
|
|
* except for public routes and MFA setup endpoints.
|
|
*/
|
|
export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFunction): void {
|
|
const pathNoQuery = (req.originalUrl || req.url || '').split('?')[0];
|
|
|
|
if (!pathNoQuery.startsWith('/api')) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (isPublicApiPath(req.method, pathNoQuery)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Accept both the httpOnly session cookie (regular SPA users) and the
|
|
// Authorization header (MCP / API clients). Previously this only looked
|
|
// at the header so every normal cookie-authenticated session sailed
|
|
// past `require_mfa` unchecked.
|
|
const token = extractToken(req);
|
|
if (!token) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
// Use the shared verify helper so the `password_version` gate applies
|
|
// here too — a JWT stolen before a password reset would otherwise
|
|
// continue to satisfy this middleware until its natural 24h expiry.
|
|
const verified = verifyJwtAndLoadUser(token);
|
|
if (!verified) {
|
|
next();
|
|
return;
|
|
}
|
|
const userId = verified.id;
|
|
|
|
const requireRow = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
|
if (requireRow?.value !== 'true') {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
|
|
| { mfa_enabled: number | boolean }
|
|
| undefined;
|
|
if (!row) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
const mfaOk = row.mfa_enabled === 1 || row.mfa_enabled === true;
|
|
if (mfaOk) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
if (isMfaSetupExemptPath(req.method, pathNoQuery)) {
|
|
next();
|
|
return;
|
|
}
|
|
|
|
res.status(403).json({
|
|
error: 'Two-factor authentication is required. Complete setup in Settings.',
|
|
code: 'MFA_REQUIRED',
|
|
});
|
|
}
|