Files
TREK/server/src/routes/auth.ts
T
Julien G. e7b419d397 security: login timing enumeration fix + dep CVE patches (v3.0.18) (#984)
* fix(security): equalise login response timing to prevent user enumeration (CWE-208)

Always run bcrypt.compareSync regardless of whether the email exists, using a
module-scope DUMMY_PASSWORD_HASH for unknown/OIDC-only accounts. Also wraps the
login handler in a 350ms minimum-latency pad (matching /forgot-password) as
defence-in-depth against CPU jitter and future code-path drift.

Fixes: CWE-203, CWE-208 — Observable Timing Discrepancy (CVSS 5.3 Medium)

* chore(deps): patch hono/picomatch/ip-address/brace-expansion CVEs, bump to node:24-alpine

Extends server/package.json overrides to pin hono >=4.12.16, picomatch >=4.0.4,
brace-expansion >=2.0.3, ip-address >=10.1.1. Adds matching overrides to client/.
Lockfiles regenerated to resolve: hono 4.12.18, ip-address 10.2.0, picomatch 4.0.4.

Also bumps base image node:22-alpine -> node:24-alpine (reduces base image CVEs)
and adds .github/workflows/security.yml to gate PRs on critical/high CVEs via
Docker Scout.

Addresses: CVE-2026-44456, CVE-2026-44455 (hono), CVE-2026-42338 (ip-address),
           CVE-2026-33671, CVE-2026-33672 (picomatch), CVE-2026-33750 (brace-expansion)

* chore: update emails in security.md

* ci(security): use docker/login-action for Scout auth instead of env vars

* chore: regenerate lock files

* chore: correct secret names

* chore: pr perms write

* fix(docker): remove package-lock.json from production image after npm ci

Docker Scout reads package-lock.json as an SBOM source and reports all
lockfile entries including devDependencies (e.g. picomatch via vitest/vite)
even when they are not physically installed. The lockfile has no runtime
purpose after npm ci completes, so delete it to ensure Scout only reports
packages actually present in node_modules.

* fix(docker): remove npm CLI from production image to eliminate bundled CVEs

picomatch@4.0.3, brace-expansion@5.0.4, and ip-address@10.1.0 were all
coming from /usr/local/lib/node_modules/npm — npm's own bundled packages
shipped with node:24-alpine. The production container only needs the node
binary to run the server; npm is unused at runtime.

Removing npm + npx after npm ci drops the package count from 500 to 365
and eliminates all npm-ecosystem CVEs (0H 0M remaining from npm packages).
Only busybox CVE-2025-60876 remains, which has no fix in Alpine 3.23.

* fix(deps): remove client overrides and brace-expansion server override; audit fix

brace-expansion ^2.0.3 in the client forced all installations to v2, breaking
minimatch in CI (test:coverage path via @vitest/coverage-v8 -> test-exclude)
which expects the named-export API of brace-expansion v5. The CVE it targeted
(>=4.0.0,<5.0.5) was only in npm's own bundled packages, already eliminated
by removing npm from the Docker image.

Also removes picomatch and ip-address client overrides for the same reason:
all three CVEs sourced from /usr/local/lib/node_modules/npm/, not app deps.
Drops brace-expansion from server overrides (server uses v2.1.0, outside the
affected range >=4.0.0).

* fix(#981): align public share itinerary order with daily planner (#985)

The public share page rendered daily items in a different order than the
authenticated planner because it used a simplified, divergent merge
algorithm. Five specific bugs:

1. shareService never loaded reservation_day_positions, so per-day
   transport positions were lost on the share page (fell back to
   day_plan_position ?? 999, pushing transports to the bottom).
2. Multi-day transports (overnight trains/flights) only appeared on their
   start day due to date-string filtering instead of day_id span logic.
3. Assignment-linked transports appeared twice (once as place, once as
   transport card) because the assignment_id exclusion was missing.
4. Time-based transport insertion was absent; missing positions used 999
   instead of a computed fractional position from the place timeline.
5. created_at tiebreaker was missing for assignments and notes with equal
   order_index/sort_order, making order non-deterministic on the share page.

Fix: extract the authoritative merge logic (parseTimeToMinutes,
getSpanPhase, getDisplayTimeForDay, getTransportForDay, getMergedItems)
from DayPlanSidebar into client/src/utils/dayMerge.ts and use it in both
the planner and SharedTripPage. Enrich the shareService payload with
day_positions from reservation_day_positions and add created_at tiebreakers
to the assignment and day_notes ORDER BY clauses.

* fix(#983): shift owner vacay entries when update_trip moves trip window

updateTrip() now calls shiftOwnerEntriesForTripWindow() which looks up
the owner's own vacay plan (not the active plan) and shifts all entries
in the old date window by the same offset as the trip start date.
2026-05-10 16:03:15 +02:00

414 lines
17 KiB
TypeScript

import express, { Request, Response, NextFunction } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import { v4 as uuid } from 'uuid';
import { authenticate, optionalAuth, demoUploadBlock } from '../middleware/auth';
import { AuthRequest, OptionalAuthRequest } from '../types';
import { writeAudit, getClientIp } from '../services/auditLog';
import { setAuthCookie, clearAuthCookie } from '../services/cookie';
import {
getAppConfig,
demoLogin,
validateInviteToken,
registerUser,
loginUser,
getCurrentUser,
changePassword,
deleteAccount,
updateMapsKey,
updateApiKeys,
updateSettings,
getSettings,
saveAvatar,
deleteAvatar,
listUsers,
validateKeys,
getAppSettings,
updateAppSettings,
getTravelStats,
setupMfa,
enableMfa,
disableMfa,
verifyMfaLogin,
listMcpTokens,
createMcpToken,
deleteMcpToken,
createWsToken,
createResourceToken,
requestPasswordReset,
resetPassword,
} from '../services/authService';
import { sendPasswordResetEmail, getAppUrl } from '../services/notifications';
const router = express.Router();
// ---------------------------------------------------------------------------
// Avatar upload (multer config stays in route — middleware concern)
// ---------------------------------------------------------------------------
const avatarDir = path.join(__dirname, '../../uploads/avatars');
if (!fs.existsSync(avatarDir)) fs.mkdirSync(avatarDir, { recursive: true });
const avatarStorage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, avatarDir),
filename: (_req, file, cb) => cb(null, uuid() + path.extname(file.originalname)),
});
const ALLOWED_AVATAR_EXTS = ['.jpg', '.jpeg', '.png', '.gif', '.webp'];
const MAX_AVATAR_SIZE = 5 * 1024 * 1024;
const avatarUpload = multer({
storage: avatarStorage,
limits: { fileSize: MAX_AVATAR_SIZE },
fileFilter: (_req, file, cb) => {
const ext = path.extname(file.originalname).toLowerCase();
if (!file.mimetype.startsWith('image/') || !ALLOWED_AVATAR_EXTS.includes(ext)) {
const err: Error & { statusCode?: number } = new Error('Only image files (jpg, png, gif, webp) are allowed');
err.statusCode = 400;
return cb(err);
}
cb(null, true);
},
});
// ---------------------------------------------------------------------------
// Rate limiter (middleware concern — stays in route)
// ---------------------------------------------------------------------------
const RATE_LIMIT_WINDOW = 15 * 60 * 1000;
const RATE_LIMIT_CLEANUP = 5 * 60 * 1000;
const loginAttempts = new Map<string, { count: number; first: number }>();
const mfaAttempts = new Map<string, { count: number; first: number }>();
const forgotAttempts = new Map<string, { count: number; first: number }>();
const resetAttempts = new Map<string, { count: number; first: number }>();
setInterval(() => {
const now = Date.now();
for (const [key, record] of loginAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) loginAttempts.delete(key);
}
for (const [key, record] of mfaAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) mfaAttempts.delete(key);
}
for (const [key, record] of forgotAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) forgotAttempts.delete(key);
}
for (const [key, record] of resetAttempts) {
if (now - record.first >= RATE_LIMIT_WINDOW) resetAttempts.delete(key);
}
}, RATE_LIMIT_CLEANUP);
function rateLimiter(maxAttempts: number, windowMs: number, store = loginAttempts) {
return (req: Request, res: Response, next: NextFunction) => {
const key = req.ip || 'unknown';
const now = Date.now();
const record = store.get(key);
if (record && record.count >= maxAttempts && now - record.first < windowMs) {
return res.status(429).json({ error: 'Too many attempts. Please try again later.' });
}
if (!record || now - record.first >= windowMs) {
store.set(key, { count: 1, first: now });
} else {
record.count++;
}
next();
};
}
const authLimiter = rateLimiter(10, RATE_LIMIT_WINDOW);
const mfaLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, mfaAttempts);
const forgotLimiter = rateLimiter(3, RATE_LIMIT_WINDOW, forgotAttempts);
const resetLimiter = rateLimiter(5, RATE_LIMIT_WINDOW, resetAttempts);
// ---------------------------------------------------------------------------
// Routes
// ---------------------------------------------------------------------------
router.get('/app-config', optionalAuth, (req: Request, res: Response) => {
const user = (req as OptionalAuthRequest).user;
res.json(getAppConfig(user));
});
router.post('/demo-login', (req: Request, res: Response) => {
const result = demoLogin();
if (result.error) return res.status(result.status!).json({ error: result.error });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.get('/invite/:token', authLimiter, (req: Request, res: Response) => {
const result = validateInviteToken(req.params.token);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ valid: result.valid, max_uses: result.max_uses, used_count: result.used_count, expires_at: result.expires_at });
});
router.post('/register', authLimiter, (req: Request, res: Response) => {
const result = registerUser(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.register', ip: getClientIp(req), details: result.auditDetails });
setAuthCookie(res, result.token!, req);
res.status(201).json({ token: result.token, user: result.user });
});
router.post('/login', authLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const result = loginUser(req.body);
if (result.auditAction) {
writeAudit({ userId: result.auditUserId ?? null, action: result.auditAction, ip: getClientIp(req), details: result.auditDetails });
}
const elapsed = Date.now() - started;
if (elapsed < LOGIN_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, LOGIN_MIN_LATENCY_MS - elapsed));
}
if (result.error) return res.status(result.status!).json({ error: result.error });
if (result.mfa_required) return res.json({ mfa_required: true, mfa_token: result.mfa_token });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
// ---------------------------------------------------------------------------
// Password reset (forgot / complete)
// ---------------------------------------------------------------------------
// Generic OK response — identical regardless of email existence, to
// prevent enumeration via response body OR status code.
const GENERIC_FORGOT_RESPONSE = { ok: true };
// Minimum time we spend inside the forgot/login handlers so a "no such
// user" path does not complete noticeably faster than a real operation.
const FORGOT_MIN_LATENCY_MS = 350;
const LOGIN_MIN_LATENCY_MS = 350;
router.post('/forgot-password', forgotLimiter, async (req: Request, res: Response) => {
const started = Date.now();
const rawEmail = typeof req.body?.email === 'string' ? req.body.email : '';
const ip = getClientIp(req);
const outcome = requestPasswordReset(rawEmail, ip);
if (outcome.reason === 'issued' && outcome.tokenForDelivery && outcome.userEmail) {
// Build the reset URL from the server-side canonical APP_URL (or
// first ALLOWED_ORIGINS entry) — never from request headers. A
// crafted `Origin` / `Host` / `Referer` would otherwise put an
// attacker-controlled domain into the emailed reset link while the
// token itself is still legitimate.
const origin = getAppUrl();
const url = `${origin.replace(/\/$/, '')}/reset-password?token=${encodeURIComponent(outcome.tokenForDelivery)}`;
// Audit the REQUEST always — even for "no user" — so abuse is visible.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'pending' } });
try {
const delivery = await sendPasswordResetEmail(outcome.userEmail, url, outcome.userId);
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: delivery.delivered } });
} catch (err) {
// Never surface delivery failure to the caller — still respond ok.
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { delivered: 'failed' } });
}
} else {
writeAudit({ userId: outcome.userId, action: 'user.password_reset_request', ip, details: { reason: outcome.reason } });
}
// Pad the response so timing doesn't reveal outcome.
const elapsed = Date.now() - started;
if (elapsed < FORGOT_MIN_LATENCY_MS) {
await new Promise((r) => setTimeout(r, FORGOT_MIN_LATENCY_MS - elapsed));
}
res.json(GENERIC_FORGOT_RESPONSE);
});
router.post('/reset-password', resetLimiter, (req: Request, res: Response) => {
const ip = getClientIp(req);
const result = resetPassword(req.body);
if (result.error) {
writeAudit({ userId: null, action: 'user.password_reset_fail', ip, details: { reason: result.error } });
return res.status(result.status!).json({ error: result.error });
}
if (result.mfa_required) {
return res.status(200).json({ mfa_required: true });
}
writeAudit({ userId: result.userId ?? null, action: 'user.password_reset_success', ip });
// Purposefully do NOT auto-login — the user just demonstrated they
// have email+password access; asking them to sign in fresh is the
// standard, safer UX.
res.json({ success: true });
});
router.get('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const user = getCurrentUser(authReq.user.id);
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ user });
});
router.post('/logout', (req: Request, res: Response) => {
clearAuthCookie(res, req);
res.json({ success: true });
});
router.put('/me/password', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = changePassword(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.password_change', ip: getClientIp(req) });
res.json({ success: true });
});
router.delete('/me', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteAccount(authReq.user.id, authReq.user.email, authReq.user.role);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.account_delete', ip: getClientIp(req) });
res.json({ success: true });
});
router.put('/me/maps-key', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateMapsKey(authReq.user.id, req.body.maps_api_key));
});
router.put('/me/api-keys', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(updateApiKeys(authReq.user.id, req.body));
});
router.put('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: result.success, user: result.user });
});
router.get('/me/settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ settings: result.settings });
});
router.post('/avatar', authenticate, demoUploadBlock, avatarUpload.single('avatar'), async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
if (!req.file) return res.status(400).json({ error: 'No image uploaded' });
res.json(await saveAvatar(authReq.user.id, req.file.filename));
});
router.delete('/avatar', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(await deleteAvatar(authReq.user.id));
});
router.get('/users', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ users: listUsers(authReq.user.id) });
});
router.get('/validate-keys', authenticate, async (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = await validateKeys(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ maps: result.maps, weather: result.weather, maps_details: result.maps_details });
});
router.get('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = getAppSettings(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json(result.data);
});
router.put('/app-settings', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = updateAppSettings(authReq.user.id, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({
userId: authReq.user.id,
action: 'settings.app_update',
ip: getClientIp(req),
details: result.auditSummary,
debugDetails: result.auditDebugDetails,
});
res.json({ success: true });
});
router.get('/travel-stats', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json(getTravelStats(authReq.user.id));
});
router.post('/mfa/verify-login', mfaLimiter, (req: Request, res: Response) => {
const result = verifyMfaLogin(req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: result.auditUserId!, action: 'user.login', ip: getClientIp(req), details: { mfa: true } });
setAuthCookie(res, result.token!, req);
res.json({ token: result.token, user: result.user });
});
router.post('/mfa/setup', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = setupMfa(authReq.user.id, authReq.user.email);
if (result.error) return res.status(result.status!).json({ error: result.error });
result.qrPromise!
.then((qr_svg: string) => {
res.json({ secret: result.secret, otpauth_url: result.otpauth_url, qr_svg });
})
.catch((err: unknown) => {
console.error('[MFA] QR code generation error:', err);
res.status(500).json({ error: 'Could not generate QR code' });
});
});
router.post('/mfa/enable', authenticate, mfaLimiter, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = enableMfa(authReq.user.id, req.body.code);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_enable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled, backup_codes: result.backup_codes });
});
router.post('/mfa/disable', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = disableMfa(authReq.user.id, authReq.user.email, req.body);
if (result.error) return res.status(result.status!).json({ error: result.error });
writeAudit({ userId: authReq.user.id, action: 'user.mfa_disable', ip: getClientIp(req) });
res.json({ success: true, mfa_enabled: result.mfa_enabled });
});
// --- MCP Token Management ---
router.get('/mcp-tokens', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
res.json({ tokens: listMcpTokens(authReq.user.id) });
});
router.post('/mcp-tokens', authenticate, rateLimiter(5, RATE_LIMIT_WINDOW), (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createMcpToken(authReq.user.id, req.body.name);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.status(201).json({ token: result.token });
});
router.delete('/mcp-tokens/:id', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = deleteMcpToken(authReq.user.id, req.params.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ success: true });
});
// Short-lived single-use token for WebSocket connections
router.post('/ws-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const result = createWsToken(authReq.user.id);
if (result.error) return res.status(result.status!).json({ error: result.error });
res.json({ token: result.token });
});
// Short-lived single-use token for direct resource URLs
router.post('/resource-token', authenticate, (req: Request, res: Response) => {
const authReq = req as AuthRequest;
const token = createResourceToken(authReq.user.id, req.body.purpose);
if (!token) return res.status(503).json({ error: 'Service unavailable' });
res.json(token);
});
export default router;
// Exported for test resets only — do not use in production code
export { loginAttempts, mfaAttempts };