mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-19 21:31:46 +00:00
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:
@@ -260,6 +260,13 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
} catch (err: unknown) {
|
||||
console.error('Restore error:', err);
|
||||
if (fs.existsSync(extractDir)) fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
// Belt-and-braces: the inner `finally` already drops the permissions
|
||||
// cache after a successful swap, but if the extraction/copy step
|
||||
// itself threw before the DB swap even started, the cache wasn't
|
||||
// stale anyway. Invalidating here too costs nothing and guarantees
|
||||
// we never serve cached permissions that don't match the DB state
|
||||
// we leave the process in after a failed restore.
|
||||
try { invalidatePermissionsCache(); } catch { /* best-effort */ }
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -205,20 +205,40 @@ export function restoreFile(id: string | number) {
|
||||
|
||||
export async function permanentDeleteFile(file: TripFile): Promise<void> {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
// `force: true` swallows ENOENT, removing the prior existsSync+unlink
|
||||
// double-call that blocked the event loop twice per deletion.
|
||||
await fs.promises.rm(resolved, { force: true }).catch((e) => console.error('Error deleting file:', e));
|
||||
// `force: true` swallows ENOENT, replacing the prior existsSync+unlink
|
||||
// double-call that blocked the event loop twice per deletion. Only
|
||||
// drop the DB row when the on-disk unlink either succeeded or the
|
||||
// file was already gone — otherwise a permission / ENOSPC failure
|
||||
// would orphan the bytes on disk with no DB pointer left to clean it.
|
||||
try {
|
||||
await fs.promises.rm(resolved, { force: true });
|
||||
} catch (e) {
|
||||
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
|
||||
throw e;
|
||||
}
|
||||
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
|
||||
}
|
||||
|
||||
export async function emptyTrash(tripId: string | number): Promise<number> {
|
||||
const trashed = db.prepare('SELECT * FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').all(tripId) as TripFile[];
|
||||
// Collect successful IDs separately so we only DELETE rows whose disk
|
||||
// content was actually removed — failing unlinks keep their DB row
|
||||
// and a retry via the single-file delete path can try again.
|
||||
const successfullyUnlinked: number[] = [];
|
||||
await Promise.all(trashed.map(async (file) => {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
await fs.promises.rm(resolved, { force: true }).catch((e) => console.error('Error deleting file:', e));
|
||||
try {
|
||||
await fs.promises.rm(resolved, { force: true });
|
||||
successfullyUnlinked.push(Number(file.id));
|
||||
} catch (e) {
|
||||
console.error(`[files] unlink failed for ${file.filename}, keeping DB row:`, e);
|
||||
}
|
||||
}));
|
||||
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
|
||||
return trashed.length;
|
||||
if (successfullyUnlinked.length > 0) {
|
||||
const placeholders = successfullyUnlinked.map(() => '?').join(',');
|
||||
db.prepare(`DELETE FROM trip_files WHERE id IN (${placeholders})`).run(...successfullyUnlinked);
|
||||
}
|
||||
return successfullyUnlinked.length;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -284,7 +284,13 @@ export async function verifyIdToken(
|
||||
try { keys = await fetchJwks(doc.jwks_uri); }
|
||||
catch (e) { return { ok: false, error: 'jwks_fetch_failed' }; }
|
||||
|
||||
const jwk = keys.find(k => !header.kid || k['kid'] === header.kid) ?? keys[0];
|
||||
// When the token carries a `kid`, refuse to fall back to any other
|
||||
// key in the JWKS — a mismatch means the token was signed with a key
|
||||
// the provider no longer publishes, and we should reject rather than
|
||||
// mask the failure by trying another key.
|
||||
const jwk = header.kid
|
||||
? keys.find((k) => k['kid'] === header.kid)
|
||||
: keys[0];
|
||||
if (!jwk) return { ok: false, error: 'no_matching_key' };
|
||||
|
||||
let publicKey;
|
||||
|
||||
Reference in New Issue
Block a user