security: address silent-failure review findings on top of batch 1

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.
This commit is contained in:
Maurice
2026-04-20 20:44:57 +02:00
parent 2d0414b4a3
commit 292e443dbe
5 changed files with 69 additions and 28 deletions
+12 -16
View File
@@ -1,8 +1,6 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
import { db } from '../db/database';
import { JWT_SECRET } from '../config';
import { extractToken } from './auth';
import { extractToken, verifyJwtAndLoadUser } from './auth';
import { DEMO_EMAILS } from '../services/demo';
/** Paths that never require MFA (public or pre-auth). */
@@ -54,14 +52,15 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
let userId: number;
try {
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
userId = decoded.id;
} catch {
// 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') {
@@ -69,16 +68,13 @@ export function enforceGlobalMfaPolicy(req: Request, res: Response, next: NextFu
return;
}
if (process.env.DEMO_MODE === 'true') {
const demo = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
if (demo?.email && DEMO_EMAILS.has(demo.email)) {
next();
return;
}
if (process.env.DEMO_MODE === 'true' && verified.email && DEMO_EMAILS.has(verified.email)) {
next();
return;
}
const row = db.prepare('SELECT mfa_enabled, role FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean; role: string }
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as
| { mfa_enabled: number | boolean }
| undefined;
if (!row) {
next();