mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 22:01:45 +00:00
security: internal audit — batch 1
Fixes the critical + high + medium findings from our internal security
review. Bundled into one PR because the changes overlap heavily (JWT
verification unifies across three call sites; backup-code hashing and
demo-email handling cross-cut several services); splitting them out
would mean redundant reviews of the same files.
Critical
- CI-C1 — .github/workflows/test.yml: restore actions/{checkout,setup-
node,upload-artifact} to @v4. The @v6 refs don't exist, so the test
workflow was errorring before a single test ran.
- SEC-C1 — mfaPolicy now extracts the token via extractToken() (cookie-
first, Bearer fallback). Previously it only read Authorization, so
every cookie-authenticated SPA session bypassed require_mfa entirely.
- SEC-C2/C4/C6 — all JWT verification paths (MCP bearer, file download,
photo route) now go through the shared verifyJwtAndLoadUser that
checks password_version. resetPassword additionally deletes every
mcp_tokens row and marks outstanding oauth_tokens revoked, so a
password reset invalidates ALL credential classes — not just the
cookie JWT.
High
- SEC-H2 — reset email URL is built from server-side APP_URL /
ALLOWED_ORIGINS (via existing getAppUrl()), not request headers.
Closes the host-header-injection vector into reset links.
- SEC-H3 — OIDC findOrCreateUser wraps the invite-redemption UPDATE +
user INSERT in a transaction. The UPDATE is the capacity check; if
a concurrent callback takes the last slot, the whole transaction
aborts with registration_disabled instead of double-creating users.
- SEC-H4 — new verifyIdToken() performs full JWT signature
verification via the provider's JWKS (Node's crypto.createPublicKey
accepts JWK directly — no extra dependency), plus iss/aud/exp
checks. The callback also rejects the login when userinfo.sub does
not match id_token.sub.
- SEC-H5 — OAuth DCR now validates redirect_uris against an allowlist
of schemes: https, http-loopback, or a private custom scheme. Plain
http://non-loopback is rejected.
- SEC-H6 — oauthService audience defaults to mcpResource when the
`resource` parameter is missing, so tokens are always audience-bound
to /mcp instead of being issued with audience=null.
- SEC-H7 — HSTS is enabled any time NODE_ENV=production (previously
required FORCE_HTTPS=true), includeSubDomains defaults on and can
be disabled with HSTS_INCLUDE_SUBDOMAINS=false.
- SEC-H8 — trek_session cookie Secure flag is also driven by
req.secure (which Express resolves from X-Forwarded-Proto once
trust proxy is set), so instances behind a TLS-terminating proxy
get Secure cookies without needing FORCE_HTTPS.
Medium
- SEC-M1 — permanentDeleteFile / emptyTrash / avatar unlink now use
fs.promises.rm with { force: true } (one async op vs the previous
existsSync + unlinkSync pair per file).
- SEC-M2 — invalidatePermissionsCache() is called inside restoreFromZip
so a restored DB with different permission rows is honoured
immediately.
- SEC-M3 + C1 — idempotency store bounds the key at 128 chars, caches
only responses ≤ 256 KiB, and scopes the lookup by (key, user_id,
method, path) rather than (key, user_id). Same key replayed against
a different endpoint no longer returns a stale unrelated body.
- SEC-M4 — share_tokens gets an expires_at column; new tokens default
to 90-day TTL, expired tokens are denied at lookup. Existing tokens
stay NULL = no expiry so already-published links don't break.
- SEC-M5 — /uploads/photos/:filename now resolves the photo to its
trip_id and requires the share token to cover THAT trip. Previously
any share token for any trip would unlock any photo filename.
- SEC-M6 — BLOCKED_EXTENSIONS is the single source of truth shared
between fileService and collab uploads. The '*' allowed_file_types
wildcard now still rejects executables/scripts.
- SEC-M7 — single DEMO_EMAILS constant (services/demo.ts) used by
demoUploadBlock, mfaPolicy, and every demo-mode guard in
authService. The old demoUploadBlock only matched 'demo@nomad.app'
so the seed 'demo@trek.app' could in fact upload in demo mode.
- SEC-M8 — MFA backup codes are now bcrypt-hashed at rest
(hashBackupCodeBcrypt). matchBackupCode accepts both bcrypt and
legacy SHA-256 hex hashes, so existing installs keep working until
the user regenerates codes via enableMfa.
- SEC-M9 — document the "security via UUID v4 filename" model for
/uploads/avatars|covers|journey. Requires no code change but
captures the decision so future reviewers don't re-flag it.
- SEC-M10 — already covered by the resetPassword revocation logic
above: mcp_tokens DELETE + oauth_tokens UPDATE … SET revoked_at.
Performance
- PERF-H1 — new migration adds the indexes flagged in the audit:
trips(user_id), trips(created_at DESC), photos(day_id),
photos(place_id), reservations(day_id), share_tokens(token), plus
conditional day_accommodations and notifications indexes depending
on which columns are present.
Tests
- tests/integration/oidc.test.ts now mocks verifyIdToken and passes
an id_token in the exchangeCodeForToken stub for the three flows
that exercise a successful callback. The three remaining failures
tests pointed out were all pre-existing (file-upload flakes +
notificationPreferences event_types count drift), none introduced
by this PR.
This commit is contained in:
@@ -15,7 +15,9 @@ import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from './apiKe
|
||||
import { createEphemeralToken } from './ephemeralTokens';
|
||||
import { revokeUserSessions } from '../mcp';
|
||||
import { startTripReminders } from '../scheduler';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { User } from '../types';
|
||||
import { DEMO_EMAIL_PRIMARY, isDemoEmail } from './demo';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Constants
|
||||
@@ -175,10 +177,46 @@ export function normalizeBackupCode(input: string): string {
|
||||
return String(input || '').toUpperCase().replace(/[^A-Z0-9]/g, '');
|
||||
}
|
||||
|
||||
// Legacy SHA-256 hex hash. Kept so existing stored hashes (from before
|
||||
// the bcrypt migration) can still be verified in `matchBackupCode`
|
||||
// without forcing every user to re-enrol their MFA device. New hashes
|
||||
// are produced by `hashBackupCodeBcrypt` below.
|
||||
export function hashBackupCode(input: string): string {
|
||||
return crypto.createHash('sha256').update(normalizeBackupCode(input)).digest('hex');
|
||||
}
|
||||
|
||||
const BCRYPT_BACKUP_COST = 10;
|
||||
|
||||
/**
|
||||
* Hash a backup code with bcrypt for at-rest storage. Backup codes only
|
||||
* have ~40 bits of entropy (8 hex chars) so a plain SHA-256 rainbow
|
||||
* table cracks them in minutes if the DB ever leaks. bcrypt with a
|
||||
* moderate cost raises that cost by ~3-4 orders of magnitude.
|
||||
*/
|
||||
export function hashBackupCodeBcrypt(input: string): string {
|
||||
return bcrypt.hashSync(normalizeBackupCode(input), BCRYPT_BACKUP_COST);
|
||||
}
|
||||
|
||||
/**
|
||||
* Constant-time match of a plaintext backup code against a stored hash
|
||||
* in either format (bcrypt or legacy SHA-256 hex). Used by login and
|
||||
* password-reset flows; callers that need to CONSUME the matching
|
||||
* entry should use this to find the index, then splice it out.
|
||||
*/
|
||||
export function matchBackupCode(plaintext: string, storedHash: string): boolean {
|
||||
if (!storedHash) return false;
|
||||
if (storedHash.startsWith('$2')) {
|
||||
// bcrypt hash — compareSync is constant-time internally.
|
||||
try { return bcrypt.compareSync(normalizeBackupCode(plaintext), storedHash); }
|
||||
catch { return false; }
|
||||
}
|
||||
// Legacy SHA-256 hex. Compare the SHA-256 of the input against the
|
||||
// stored hex with a constant-time comparator so timing can't leak.
|
||||
const candidate = hashBackupCode(plaintext);
|
||||
if (candidate.length !== storedHash.length) return false;
|
||||
return crypto.timingSafeEqual(Buffer.from(candidate), Buffer.from(storedHash));
|
||||
}
|
||||
|
||||
export function generateBackupCodes(count = MFA_BACKUP_CODE_COUNT): string[] {
|
||||
const codes: string[] = [];
|
||||
while (codes.length < count) {
|
||||
@@ -260,7 +298,7 @@ export function getAppConfig(authenticatedUser: { id: number } | null) {
|
||||
require_mfa: requireMfaRow?.value === 'true',
|
||||
allowed_file_types: (db.prepare("SELECT value FROM app_settings WHERE key = 'allowed_file_types'").get() as { value: string } | undefined)?.value || 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv',
|
||||
demo_mode: isDemo,
|
||||
demo_email: isDemo ? 'demo@trek.app' : undefined,
|
||||
demo_email: isDemo ? DEMO_EMAIL_PRIMARY : undefined,
|
||||
demo_password: isDemo ? 'demo12345' : undefined,
|
||||
timezone: process.env.TZ || Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC',
|
||||
notification_channel: notifChannel,
|
||||
@@ -283,7 +321,7 @@ export function demoLogin(): { error?: string; status?: number; token?: string;
|
||||
if (process.env.DEMO_MODE !== 'true') {
|
||||
return { error: 'Not found', status: 404 };
|
||||
}
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get('demo@trek.app') as User | undefined;
|
||||
const user = db.prepare('SELECT * FROM users WHERE email = ?').get(DEMO_EMAIL_PRIMARY) as User | undefined;
|
||||
if (!user) return { error: 'Demo user not found', status: 500 };
|
||||
const token = generateToken(user);
|
||||
const safe = stripUserForClient(user) as Record<string, unknown>;
|
||||
@@ -458,7 +496,7 @@ export function changePassword(
|
||||
if (isOidcOnlyMode()) {
|
||||
return { error: 'Password authentication is disabled.', status: 403 };
|
||||
}
|
||||
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'Password change is disabled in demo mode.', status: 403 };
|
||||
}
|
||||
|
||||
@@ -480,7 +518,7 @@ export function changePassword(
|
||||
}
|
||||
|
||||
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
|
||||
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@trek.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'Account deletion is disabled in demo mode.', status: 403 };
|
||||
}
|
||||
if (userRole === 'admin') {
|
||||
@@ -600,11 +638,13 @@ export function getSettings(userId: number): { error?: string; status?: number;
|
||||
// Avatar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function saveAvatar(userId: number, filename: string) {
|
||||
export async function saveAvatar(userId: number, filename: string) {
|
||||
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
|
||||
if (current && current.avatar) {
|
||||
// Fire-and-forget: leftover files are harmless; the DB update is
|
||||
// the source of truth for which avatar is current.
|
||||
const oldPath = path.join(avatarDir, current.avatar);
|
||||
if (fs.existsSync(oldPath)) fs.unlinkSync(oldPath);
|
||||
await fs.promises.rm(oldPath, { force: true }).catch(() => {});
|
||||
}
|
||||
|
||||
db.prepare('UPDATE users SET avatar = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(filename, userId);
|
||||
@@ -613,11 +653,11 @@ export function saveAvatar(userId: number, filename: string) {
|
||||
return { success: true, avatar_url: avatarUrl(updated || {}) };
|
||||
}
|
||||
|
||||
export function deleteAvatar(userId: number) {
|
||||
export async function deleteAvatar(userId: number) {
|
||||
const current = db.prepare('SELECT avatar FROM users WHERE id = ?').get(userId) as { avatar: string | null } | undefined;
|
||||
if (current && current.avatar) {
|
||||
const filePath = path.join(avatarDir, current.avatar);
|
||||
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
|
||||
await fs.promises.rm(filePath, { force: true }).catch(() => {});
|
||||
}
|
||||
db.prepare('UPDATE users SET avatar = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(userId);
|
||||
return { success: true };
|
||||
@@ -865,7 +905,7 @@ export function getTravelStats(userId: number) {
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function setupMfa(userId: number, userEmail: string): { error?: string; status?: number; secret?: string; otpauth_url?: string; qrPromise?: Promise<string> } {
|
||||
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'MFA is not available in demo mode.', status: 403 };
|
||||
}
|
||||
const row = db.prepare('SELECT mfa_enabled FROM users WHERE id = ?').get(userId) as { mfa_enabled: number } | undefined;
|
||||
@@ -898,7 +938,7 @@ export function enableMfa(userId: number, code?: string): { error?: string; stat
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
}
|
||||
const backupCodes = generateBackupCodes();
|
||||
const backupHashes = backupCodes.map(hashBackupCode);
|
||||
const backupHashes = backupCodes.map(hashBackupCodeBcrypt);
|
||||
const enc = encryptMfaSecret(pending);
|
||||
db.prepare('UPDATE users SET mfa_enabled = 1, mfa_secret = ?, mfa_backup_codes = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(
|
||||
enc,
|
||||
@@ -914,7 +954,7 @@ export function disableMfa(
|
||||
userEmail: string,
|
||||
body: { password?: string; code?: string }
|
||||
): { error?: string; status?: number; success?: boolean; mfa_enabled?: boolean } {
|
||||
if (process.env.DEMO_MODE === 'true' && userEmail === 'demo@nomad.app') {
|
||||
if (process.env.DEMO_MODE === 'true' && isDemoEmail(userEmail)) {
|
||||
return { error: 'MFA cannot be changed in demo mode.', status: 403 };
|
||||
}
|
||||
const policy = db.prepare("SELECT value FROM app_settings WHERE key = 'require_mfa'").get() as { value: string } | undefined;
|
||||
@@ -973,8 +1013,9 @@ export function verifyMfaLogin(body: {
|
||||
const okTotp = authenticator.verify({ token: tokenStr.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const candidateHash = hashBackupCode(tokenStr);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
// matchBackupCode handles both bcrypt and legacy SHA-256 hashes;
|
||||
// any store older than the bcrypt migration keeps working.
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(tokenStr, h));
|
||||
if (idx === -1) {
|
||||
return { error: 'Invalid verification code', status: 401 };
|
||||
}
|
||||
@@ -1166,8 +1207,7 @@ export function resetPassword(body: {
|
||||
const okTotp = authenticator.verify({ token: supplied.replace(/\s/g, ''), secret });
|
||||
if (!okTotp) {
|
||||
const hashes = parseBackupCodeHashes(user.mfa_backup_codes);
|
||||
const candidateHash = hashBackupCode(supplied);
|
||||
const idx = hashes.findIndex(h => h === candidateHash);
|
||||
const idx = hashes.findIndex((h) => matchBackupCode(supplied, h));
|
||||
if (idx === -1) return { error: 'Invalid MFA code', status: 401 };
|
||||
backupCodeConsumedIndex = idx;
|
||||
}
|
||||
@@ -1193,6 +1233,16 @@ export function resetPassword(body: {
|
||||
hashes.splice(backupCodeConsumedIndex, 1);
|
||||
db.prepare('UPDATE users SET mfa_backup_codes = ? WHERE id = ?').run(JSON.stringify(hashes), user.id);
|
||||
}
|
||||
// Revoke every other credential class the user had. The
|
||||
// password_version bump alone invalidates JWT cookie sessions, but
|
||||
// MCP static tokens and OAuth 2.1 bearer tokens are separate stores
|
||||
// that survive the bump unless we prune them here.
|
||||
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(user.id);
|
||||
try {
|
||||
db.prepare(
|
||||
"UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL"
|
||||
).run(user.id);
|
||||
} catch { /* oauth_tokens table may not exist in very old installs */ }
|
||||
})();
|
||||
|
||||
// Kick off any MCP/WS session cleanup — same hook the account-delete path uses.
|
||||
@@ -1267,7 +1317,7 @@ export function createResourceToken(userId: number, purpose?: string): { error?:
|
||||
export function isDemoUser(userId: number): boolean {
|
||||
if (process.env.DEMO_MODE !== 'true') return false;
|
||||
const user = db.prepare('SELECT email FROM users WHERE id = ?').get(userId) as { email: string } | undefined;
|
||||
return user?.email === 'demo@nomad.app';
|
||||
return isDemoEmail(user?.email);
|
||||
}
|
||||
|
||||
export function verifyMcpToken(rawToken: string): User | null {
|
||||
@@ -1285,12 +1335,15 @@ export function verifyMcpToken(rawToken: string): User | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify a JWT the same way `middleware/auth.ts#verifyJwtAndLoadUser`
|
||||
* does — including the `password_version` check — so that stolen tokens
|
||||
* lose access the moment the victim resets their password.
|
||||
*
|
||||
* This is the single entry point every non-cookie JWT verification path
|
||||
* (MCP bearer, WebSocket handshake, file-download query tokens, photo
|
||||
* route) should go through.
|
||||
*/
|
||||
export function verifyJwtToken(token: string): User | null {
|
||||
try {
|
||||
const decoded = jwt.verify(token, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
const user = db.prepare('SELECT id, username, email, role FROM users WHERE id = ?').get(decoded.id) as User | undefined;
|
||||
return user || null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
return verifyJwtAndLoadUser(token);
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import fs from 'fs';
|
||||
import Database from 'better-sqlite3';
|
||||
import { db, closeDb, reinitialize } from '../db/database';
|
||||
import * as scheduler from '../scheduler';
|
||||
import { invalidatePermissionsCache } from './permissions';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Paths
|
||||
@@ -246,6 +247,12 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
|
||||
}
|
||||
} finally {
|
||||
reinitialize();
|
||||
// The restored DB has different permission-override rows from
|
||||
// the pre-restore DB, but our process-local permissions cache
|
||||
// still holds the pre-restore state. Any request using a cached
|
||||
// permission would decide against the wrong grants until the
|
||||
// next restart. Dropping the cache forces a fresh read.
|
||||
invalidatePermissionsCache();
|
||||
}
|
||||
|
||||
fs.rmSync(extractDir, { recursive: true, force: true });
|
||||
|
||||
@@ -1,9 +1,32 @@
|
||||
import { Response } from 'express';
|
||||
import { Request, Response } from 'express';
|
||||
|
||||
const COOKIE_NAME = 'trek_session';
|
||||
|
||||
export function cookieOptions(clear = false) {
|
||||
const secure = process.env.COOKIE_SECURE !== 'false' && (process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true');
|
||||
/**
|
||||
* Decide whether the session cookie should carry the `Secure` flag.
|
||||
*
|
||||
* We previously only derived this from `NODE_ENV=production` or
|
||||
* `FORCE_HTTPS=true`. That left behind a common self-host setup:
|
||||
* TREK running behind Traefik / Caddy / Cloudflare Tunnel with
|
||||
* `NODE_ENV=development` locally and no `FORCE_HTTPS` — the cookie
|
||||
* went out without `Secure`, even though the public leg was https.
|
||||
*
|
||||
* Now we also honour `req.secure`, which Express derives from
|
||||
* `X-Forwarded-Proto` once `trust proxy` is set (TREK sets it to `1`
|
||||
* in production automatically). If Express sees the request was TLS
|
||||
* on the outermost hop, the cookie is `Secure`. `COOKIE_SECURE=false`
|
||||
* remains the explicit escape hatch for plain-HTTP LAN testing.
|
||||
*/
|
||||
export function cookieOptions(clear = false, req?: Request) {
|
||||
if (process.env.COOKIE_SECURE === 'false') {
|
||||
return buildOptions(clear, false);
|
||||
}
|
||||
const envSecure = process.env.NODE_ENV === 'production' || process.env.FORCE_HTTPS === 'true';
|
||||
const requestSecure = req?.secure === true;
|
||||
return buildOptions(clear, envSecure || requestSecure);
|
||||
}
|
||||
|
||||
function buildOptions(clear: boolean, secure: boolean) {
|
||||
return {
|
||||
httpOnly: true,
|
||||
secure,
|
||||
@@ -13,10 +36,10 @@ export function cookieOptions(clear = false) {
|
||||
};
|
||||
}
|
||||
|
||||
export function setAuthCookie(res: Response, token: string): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions());
|
||||
export function setAuthCookie(res: Response, token: string, req?: Request): void {
|
||||
res.cookie(COOKIE_NAME, token, cookieOptions(false, req));
|
||||
}
|
||||
|
||||
export function clearAuthCookie(res: Response): void {
|
||||
res.clearCookie(COOKIE_NAME, cookieOptions(true));
|
||||
export function clearAuthCookie(res: Response, req?: Request): void {
|
||||
res.clearCookie(COOKIE_NAME, cookieOptions(true, req));
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// Central registry of demo-user email addresses.
|
||||
//
|
||||
// Historical: the demo account was seeded as "demo@trek.app" (see
|
||||
// authService.demoLogin), but several guards — demoUploadBlock in
|
||||
// middleware/auth.ts, the MFA/backup-code bypasses in authService —
|
||||
// were still checking the pre-rename "demo@nomad.app" string, so they
|
||||
// either never fired or silently diverged between call sites. Routing
|
||||
// every check through this constant keeps them aligned.
|
||||
|
||||
export const DEMO_EMAIL_PRIMARY = 'demo@trek.app';
|
||||
|
||||
/**
|
||||
* All email addresses that should be treated as the demo account.
|
||||
* Includes the historical `demo@nomad.app` identifier so instances that
|
||||
* upgraded in place without resetting the DB still hit demo-mode guards.
|
||||
*/
|
||||
export const DEMO_EMAILS: ReadonlySet<string> = new Set([
|
||||
DEMO_EMAIL_PRIMARY,
|
||||
'demo@nomad.app',
|
||||
]);
|
||||
|
||||
export function isDemoEmail(email: string | null | undefined): boolean {
|
||||
return !!email && DEMO_EMAILS.has(email);
|
||||
}
|
||||
@@ -1,9 +1,8 @@
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import jwt from 'jsonwebtoken';
|
||||
import { JWT_SECRET } from '../config';
|
||||
import { db, canAccessTrip } from '../db/database';
|
||||
import { consumeEphemeralToken } from './ephemeralTokens';
|
||||
import { verifyJwtAndLoadUser } from '../middleware/auth';
|
||||
import { TripFile } from '../types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -12,7 +11,18 @@ import { TripFile } from '../types';
|
||||
|
||||
export const MAX_FILE_SIZE = 50 * 1024 * 1024; // 50 MB
|
||||
export const DEFAULT_ALLOWED_EXTENSIONS = 'jpg,jpeg,png,gif,webp,heic,pdf,doc,docx,xls,xlsx,txt,csv,pkpass';
|
||||
export const BLOCKED_EXTENSIONS = ['.svg', '.html', '.htm', '.xml'];
|
||||
// Single authoritative blocklist for every file-upload surface (main
|
||||
// file manager + collab attachments). When the admin setting
|
||||
// `allowed_file_types` is `*`, this list is still enforced so the
|
||||
// wildcard doesn't silently admit executables/scripts.
|
||||
export const BLOCKED_EXTENSIONS = [
|
||||
// Server-rendered / scripted content that could XSS a viewer
|
||||
'.svg', '.html', '.htm', '.xml', '.xhtml',
|
||||
// Scripts
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs', '.php', '.py', '.rb', '.pl',
|
||||
// Executables
|
||||
'.exe', '.bat', '.sh', '.cmd', '.msi', '.dll', '.com', '.vbs', '.ps1', '.app',
|
||||
];
|
||||
export const filesDir = path.join(__dirname, '../../uploads/files');
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -68,12 +78,12 @@ export function authenticateDownload(bearerToken: string | undefined, queryToken
|
||||
}
|
||||
|
||||
if (bearerToken) {
|
||||
try {
|
||||
const decoded = jwt.verify(bearerToken, JWT_SECRET, { algorithms: ['HS256'] }) as { id: number };
|
||||
return { userId: decoded.id };
|
||||
} catch {
|
||||
return { error: 'Invalid or expired token', status: 401 };
|
||||
}
|
||||
// Use the shared helper so the password_version gate applies here too;
|
||||
// previously this bypassed the check and stolen download tokens stayed
|
||||
// valid across a password reset.
|
||||
const user = verifyJwtAndLoadUser(bearerToken);
|
||||
if (!user) return { error: 'Invalid or expired token', status: 401 };
|
||||
return { userId: user.id };
|
||||
}
|
||||
|
||||
const uid = consumeEphemeralToken(queryToken!, 'download');
|
||||
@@ -193,22 +203,20 @@ export function restoreFile(id: string | number) {
|
||||
return formatFile(restored);
|
||||
}
|
||||
|
||||
export function permanentDeleteFile(file: TripFile) {
|
||||
export async function permanentDeleteFile(file: TripFile): Promise<void> {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
if (fs.existsSync(resolved)) {
|
||||
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
|
||||
}
|
||||
// `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));
|
||||
db.prepare('DELETE FROM trip_files WHERE id = ?').run(file.id);
|
||||
}
|
||||
|
||||
export function emptyTrash(tripId: string | number): number {
|
||||
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[];
|
||||
for (const file of trashed) {
|
||||
await Promise.all(trashed.map(async (file) => {
|
||||
const { resolved } = resolveFilePath(file.filename);
|
||||
if (fs.existsSync(resolved)) {
|
||||
try { fs.unlinkSync(resolved); } catch (e) { console.error('Error deleting file:', e); }
|
||||
}
|
||||
}
|
||||
await fs.promises.rm(resolved, { force: true }).catch((e) => console.error('Error deleting file:', e));
|
||||
}));
|
||||
db.prepare('DELETE FROM trip_files WHERE trip_id = ? AND deleted_at IS NOT NULL').run(tripId);
|
||||
return trashed.length;
|
||||
}
|
||||
|
||||
@@ -582,10 +582,16 @@ export function validateAuthorizeRequest(
|
||||
return { valid: false, error: 'invalid_redirect_uri', error_description: 'redirect_uri does not match any registered URI' };
|
||||
}
|
||||
|
||||
// RFC 8707 resource indicator: if provided, must identify the TREK MCP endpoint exactly
|
||||
// RFC 8707 resource indicator: if provided, must identify the TREK
|
||||
// MCP endpoint exactly. If the client didn't supply `resource`, we
|
||||
// bind the token to the MCP endpoint by default — previously this
|
||||
// left `audience = null`, and the audience-bind check on MCP requests
|
||||
// then treated a null audience as "valid for any resource".
|
||||
const mcpResource = `${(getAppUrl() || '').replace(/\/+$/, '')}/mcp`;
|
||||
const resource = params.resource ? params.resource.replace(/\/+$/, '') : null;
|
||||
if (resource !== null && resource !== mcpResource) {
|
||||
const resource = params.resource
|
||||
? params.resource.replace(/\/+$/, '')
|
||||
: mcpResource;
|
||||
if (resource !== mcpResource) {
|
||||
return { valid: false, error: 'invalid_target', error_description: 'Requested resource must be the TREK MCP endpoint' };
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ export interface OidcDiscoveryDoc {
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
userinfo_endpoint: string;
|
||||
jwks_uri?: string;
|
||||
issuer?: string;
|
||||
_issuer?: string;
|
||||
}
|
||||
|
||||
@@ -221,6 +223,96 @@ export async function getUserInfo(userinfoEndpoint: string, accessToken: string)
|
||||
return (await res.json()) as OidcUserInfo;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// id_token verification (signature + iss + aud + exp)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// 5 minute JWKS cache — short enough to pick up key rotation within a
|
||||
// reasonable window, long enough that normal login flow doesn't fetch
|
||||
// JWKS on every callback.
|
||||
const JWKS_TTL_MS = 5 * 60 * 1000;
|
||||
type JwksEntry = { keys: Array<Record<string, unknown>>; fetchedAt: number };
|
||||
const jwksCache = new Map<string, JwksEntry>();
|
||||
|
||||
async function fetchJwks(jwksUri: string): Promise<Array<Record<string, unknown>>> {
|
||||
const cached = jwksCache.get(jwksUri);
|
||||
if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) return cached.keys;
|
||||
const res = await fetch(jwksUri);
|
||||
if (!res.ok) throw new Error(`JWKS fetch failed: HTTP ${res.status}`);
|
||||
const json = (await res.json()) as { keys?: Array<Record<string, unknown>> };
|
||||
const keys = json.keys ?? [];
|
||||
jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() });
|
||||
return keys;
|
||||
}
|
||||
|
||||
function base64UrlDecode(input: string): Buffer {
|
||||
const padded = input.replace(/-/g, '+').replace(/_/g, '/') + '='.repeat((4 - (input.length % 4)) % 4);
|
||||
return Buffer.from(padded, 'base64');
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify an OIDC id_token end-to-end: signature against the provider's
|
||||
* JWKS, issuer match, audience match, and exp/nbf. Does NOT verify a
|
||||
* nonce — the server doesn't currently send one in the auth request;
|
||||
* when that's added, pass the expected nonce here and check `claims.nonce`.
|
||||
*
|
||||
* Returning the claims lets callers cross-check `sub` / `email` against
|
||||
* the userinfo response. A mismatch would mean the provider's userinfo
|
||||
* endpoint is speaking for a different subject than the id_token — a
|
||||
* classic IdP-side compromise signal worth refusing login over.
|
||||
*/
|
||||
export async function verifyIdToken(
|
||||
idToken: string,
|
||||
doc: OidcDiscoveryDoc,
|
||||
clientId: string,
|
||||
expectedIssuer: string,
|
||||
): Promise<{ ok: true; claims: Record<string, unknown> } | { ok: false; error: string }> {
|
||||
if (!doc.jwks_uri) return { ok: false, error: 'no_jwks_uri' };
|
||||
const parts = idToken.split('.');
|
||||
if (parts.length !== 3) return { ok: false, error: 'malformed_token' };
|
||||
|
||||
let header: { kid?: string; alg?: string };
|
||||
try { header = JSON.parse(base64UrlDecode(parts[0]!).toString('utf8')); }
|
||||
catch { return { ok: false, error: 'bad_header' }; }
|
||||
|
||||
const alg = header.alg;
|
||||
if (!alg || !/^(RS256|RS384|RS512|ES256|ES384|ES512|PS256|PS384|PS512)$/.test(alg)) {
|
||||
return { ok: false, error: 'unsupported_alg' };
|
||||
}
|
||||
|
||||
let keys: Array<Record<string, unknown>>;
|
||||
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];
|
||||
if (!jwk) return { ok: false, error: 'no_matching_key' };
|
||||
|
||||
let publicKey;
|
||||
try {
|
||||
// Node 16+ understands JWK directly; no PEM conversion library needed.
|
||||
// Node's crypto accepts a JWK object directly as `{ key, format: 'jwk' }`.
|
||||
// The type signature isn't strict on our TS config so we cast through any.
|
||||
publicKey = crypto.createPublicKey({ key: jwk as any, format: 'jwk' });
|
||||
} catch {
|
||||
return { ok: false, error: 'key_import_failed' };
|
||||
}
|
||||
|
||||
let claims: Record<string, unknown>;
|
||||
try {
|
||||
const verified = jwt.verify(idToken, publicKey, {
|
||||
algorithms: [alg as jwt.Algorithm],
|
||||
issuer: expectedIssuer,
|
||||
audience: clientId,
|
||||
});
|
||||
claims = typeof verified === 'string' ? {} : (verified as Record<string, unknown>);
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'verify_failed';
|
||||
return { ok: false, error: `signature_or_claim_mismatch: ${msg}` };
|
||||
}
|
||||
|
||||
return { ok: true, claims };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Find or create user by OIDC sub / email
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -286,21 +378,34 @@ export function findOrCreateUser(
|
||||
const existing = db.prepare('SELECT id FROM users WHERE LOWER(username) = LOWER(?)').get(username);
|
||||
if (existing) username = `${username}_${Date.now() % 10000}`;
|
||||
|
||||
const result = db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
|
||||
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
|
||||
).run(validInvite.id);
|
||||
if (updated.changes === 0) {
|
||||
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exceeded max_uses (race condition)`);
|
||||
// Atomic registration: if an invite was presented, the increment IS
|
||||
// the capacity check — UPDATE matches zero rows the moment another
|
||||
// concurrent callback wins the last slot, and the transaction aborts
|
||||
// the user INSERT. Without this, two parallel OIDC callbacks could
|
||||
// both pass the earlier SELECT-based check and each create a user.
|
||||
const inviteRaceError = new Error('invite_exhausted');
|
||||
try {
|
||||
const createUser = db.transaction(() => {
|
||||
if (validInvite) {
|
||||
const updated = db.prepare(
|
||||
'UPDATE invite_tokens SET used_count = used_count + 1 WHERE id = ? AND (max_uses = 0 OR used_count < max_uses)',
|
||||
).run(validInvite.id);
|
||||
if (updated.changes === 0) throw inviteRaceError;
|
||||
}
|
||||
return db.prepare(
|
||||
'INSERT INTO users (username, email, password_hash, role, oidc_sub, oidc_issuer, first_seen_version, login_count) VALUES (?, ?, ?, ?, ?, ?, ?, 0)',
|
||||
).run(username, email, hash, role, sub, config.issuer, process.env.APP_VERSION || '0.0.0');
|
||||
});
|
||||
const result = createUser() as { lastInsertRowid: number | bigint };
|
||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||
return { user };
|
||||
} catch (err) {
|
||||
if (err === inviteRaceError) {
|
||||
console.warn(`[OIDC] Invite token ${inviteToken?.slice(0, 8)}... exhausted — concurrent callback won the last slot`);
|
||||
return { error: 'registration_disabled' };
|
||||
}
|
||||
throw err;
|
||||
}
|
||||
|
||||
user = { id: Number(result.lastInsertRowid), username, email, role } as User;
|
||||
return { user };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -44,9 +44,14 @@ export function createOrUpdateShareLink(
|
||||
return { token: existing.token, created: false };
|
||||
}
|
||||
|
||||
// New share links default to a 90-day TTL. Existing tokens that were
|
||||
// created before the expires_at migration keep NULL here and remain
|
||||
// valid indefinitely until the owner rotates them; that preserves
|
||||
// behaviour for anyone who's already sharing a link.
|
||||
const token = crypto.randomBytes(24).toString('base64url');
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab) VALUES (?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0);
|
||||
const expiresAt = new Date(Date.now() + 90 * 24 * 60 * 60 * 1000).toISOString();
|
||||
db.prepare('INSERT INTO share_tokens (trip_id, token, created_by, share_map, share_bookings, share_packing, share_budget, share_collab, expires_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)')
|
||||
.run(tripId, token, createdBy, share_map ? 1 : 0, share_bookings ? 1 : 0, share_packing ? 1 : 0, share_budget ? 1 : 0, share_collab ? 1 : 0, expiresAt);
|
||||
return { token, created: true };
|
||||
}
|
||||
|
||||
@@ -79,7 +84,9 @@ export function deleteShareLink(tripId: string): void {
|
||||
* permission flags. Returns null if the token is invalid or the trip is gone.
|
||||
*/
|
||||
export function getSharedTripData(token: string): Record<string, any> | null {
|
||||
const shareRow = db.prepare('SELECT * FROM share_tokens WHERE token = ?').get(token) as any;
|
||||
const shareRow = db.prepare(
|
||||
"SELECT * FROM share_tokens WHERE token = ? AND (expires_at IS NULL OR expires_at > datetime('now'))"
|
||||
).get(token) as any;
|
||||
if (!shareRow) return null;
|
||||
|
||||
const tripId = shareRow.trip_id;
|
||||
|
||||
Reference in New Issue
Block a user