Backend/frontend hardening & consistency cleanups (#1113)

* refactor(auth): session token validation and password-change consistency

* refactor(journey): entry field allow-list and public share-link consistency

* refactor(mcp): align tool authorization with the REST permission checks

* chore: input validation and sanitisation touch-ups (uploads, pdf, maps, backup, csp)
This commit is contained in:
Maurice
2026-06-06 16:37:03 +02:00
committed by GitHub
parent 070ef01328
commit 093e069ccc
41 changed files with 653 additions and 74 deletions
+24 -5
View File
@@ -490,8 +490,9 @@ export function loginUser(body: {
}
if (user.mfa_enabled === 1 || user.mfa_enabled === true) {
const pv = (user as User & { password_version?: number }).password_version ?? 0;
const mfa_token = jwt.sign(
{ id: Number(user.id), purpose: 'mfa_login' },
{ id: Number(user.id), purpose: 'mfa_login', pv },
JWT_SECRET,
{ expiresIn: '5m', algorithm: 'HS256' }
);
@@ -534,7 +535,7 @@ export function changePassword(
userId: number,
userEmail: string,
body: { current_password?: string; new_password?: string }
): { error?: string; status?: number; success?: boolean } {
): { error?: string; status?: number; success?: boolean; token?: string } {
if (isOidcOnlyMode()) {
return { error: 'Password authentication is disabled.', status: 403 };
}
@@ -549,14 +550,32 @@ export function changePassword(
const pwCheck = validatePassword(new_password);
if (!pwCheck.ok) return { error: pwCheck.reason, status: 400 };
const user = db.prepare('SELECT password_hash FROM users WHERE id = ?').get(userId) as { password_hash: string } | undefined;
const user = db.prepare('SELECT password_hash, password_version FROM users WHERE id = ?').get(userId) as { password_hash: string; password_version?: number } | undefined;
if (!user || !bcrypt.compareSync(current_password, user.password_hash)) {
return { error: 'Current password is incorrect', status: 401 };
}
const hash = bcrypt.hashSync(new_password, BCRYPT_COST);
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, userId);
return { success: true };
const newPv = (user.password_version ?? 0) + 1;
db.transaction(() => {
db.prepare('UPDATE users SET password_hash = ?, must_change_password = 0, password_version = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?').run(hash, newPv, userId);
// A password change rotates the user's sessions: bumping password_version
// invalidates existing JWT cookie sessions, and the separate MCP static
// token and OAuth bearer-token stores are pruned to match (same set the
// password-reset path already revokes).
db.prepare('DELETE FROM mcp_tokens WHERE user_id = ?').run(userId);
try {
db.prepare("UPDATE oauth_tokens SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL").run(userId);
} catch { /* oauth_tokens table may not exist in very old installs */ }
})();
try { revokeUserSessions?.(userId); } catch { /* best-effort */ }
// Re-issue a session bound to the new password_version so the current device
// stays logged in while other existing sessions are rotated out by the pv gate.
const token = generateToken({ id: userId, password_version: newPv });
return { success: true, token };
}
export function deleteAccount(userId: number, userEmail: string, userRole: string): { error?: string; status?: number; success?: boolean } {
+12 -1
View File
@@ -15,7 +15,10 @@ const dataDir = path.join(__dirname, '../../data');
const backupsDir = path.join(dataDir, 'backups');
const uploadsDir = path.join(__dirname, '../../uploads');
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB
export const MAX_BACKUP_UPLOAD_SIZE = 500 * 1024 * 1024; // 500 MB compressed
// Upper bound on the TOTAL decompressed size of a restore archive (the upload
// limit only caps the compressed bytes). Generous enough for any real backup.
export const MAX_BACKUP_DECOMPRESSED_SIZE = 5 * 1024 * 1024 * 1024; // 5 GB
// ---------------------------------------------------------------------------
// Helpers
@@ -187,6 +190,14 @@ export async function restoreFromZip(zipPath: string): Promise<RestoreResult> {
const extractDir = path.join(dataDir, `restore-${Date.now()}`);
let reinitFailed: unknown = null;
try {
// Check the declared uncompressed size from the central directory and bail
// if it exceeds the cap, before extracting anything.
const directory = await unzipper.Open.file(zipPath);
const claimedSize = directory.files.reduce((sum, f) => sum + (f.uncompressedSize || 0), 0);
if (claimedSize > MAX_BACKUP_DECOMPRESSED_SIZE) {
return { success: false, error: 'Backup exceeds the maximum decompressed size.', status: 400 };
}
await fs.createReadStream(zipPath)
.pipe(unzipper.Extract({ path: extractDir }))
.promise();
+5 -1
View File
@@ -280,7 +280,11 @@ export function updateMembers(id: string | number, tripId: string | number, user
return { members, item: updated };
}
export function toggleMemberPaid(id: string | number, userId: string | number, paid: boolean) {
export function toggleMemberPaid(id: string | number, tripId: string | number, userId: string | number, paid: boolean) {
// Resolve the item within the caller's trip before updating.
const item = db.prepare('SELECT id FROM budget_items WHERE id = ? AND trip_id = ?').get(id, tripId);
if (!item) return null;
db.prepare('UPDATE budget_item_members SET paid = ? WHERE budget_item_id = ? AND user_id = ?')
.run(paid ? 1 : 0, id, userId);
+10
View File
@@ -568,8 +568,18 @@ export function updateEntry(entryId: number, userId: number, data: Partial<{
const fields: string[] = [];
const values: unknown[] = [];
// Allow-list the columns a client may set: keys come from the request body
// and are interpolated as SQL column names, so restrict them to the known
// entry fields. Keep this in sync with the data type above.
const allowed = new Set([
'type', 'title', 'story', 'entry_date', 'entry_time',
'location_name', 'location_lat', 'location_lng',
'mood', 'weather', 'tags', 'pros_cons', 'visibility', 'sort_order',
]);
for (const [key, val] of Object.entries(data)) {
if (val === undefined) continue;
if (!allowed.has(key)) continue;
if (key === 'tags') {
fields.push('tags = ?');
values.push(Array.isArray(val) ? JSON.stringify(val) : val);
+40 -10
View File
@@ -84,10 +84,8 @@ export function validateShareTokenForAsset(token: string, assetId: string): { ow
JOIN trek_photos tkp ON tkp.id = gp.photo_id
WHERE tkp.asset_id = ? AND gp.journey_id = ?
`).get(assetId, row.journey_id) as any;
if (!photo) {
const journey = db.prepare('SELECT user_id FROM journeys WHERE id = ?').get(row.journey_id) as any;
return journey ? { ownerId: journey.user_id } : null;
}
// Only resolve assets that actually belong to this shared journey.
if (!photo) return null;
return { ownerId: photo.owner_id };
}
@@ -137,13 +135,45 @@ export function getPublicJourney(token: string) {
photos: photosByEntry[e.id] || [],
}));
// Stats
// Stats are derived from the full data so the overview pills stay accurate
// even when a section is hidden.
const stats = {
entries: entries.length,
photos: gallery.length,
places: new Set(entries.filter(e => e.location_name).map(e => e.location_name)).size,
};
const shareTimeline = !!row.share_timeline;
const shareGallery = !!row.share_gallery;
const shareMap = !!row.share_map;
// Honour the share flags server-side so the API only returns the sections the
// owner enabled (the client gates these too, but it must not rely on that).
let publicEntries: Record<string, unknown>[] = [];
if (shareTimeline) {
// Include the full entry, but drop GPS unless the map is shared and inline
// photos unless the gallery is shared.
publicEntries = enrichedEntries.map(e => {
const projected: Record<string, unknown> = { ...e };
if (!shareMap) { projected.location_lat = null; projected.location_lng = null; }
if (!shareGallery) projected.photos = [];
return projected;
});
} else if (shareMap) {
// Map-only share: just enough to plot markers, no story/photos/mood.
publicEntries = enrichedEntries.map(e => ({
id: e.id,
journey_id: e.journey_id,
type: e.type,
entry_date: e.entry_date,
title: e.title,
location_name: e.location_name,
location_lat: e.location_lat,
location_lng: e.location_lng,
sort_order: e.sort_order,
}));
}
return {
journey: {
title: journey.title,
@@ -151,13 +181,13 @@ export function getPublicJourney(token: string) {
cover_image: journey.cover_image,
status: journey.status,
},
entries: enrichedEntries,
gallery,
entries: publicEntries,
gallery: shareGallery ? gallery : [],
stats,
permissions: {
share_timeline: !!row.share_timeline,
share_gallery: !!row.share_gallery,
share_map: !!row.share_map,
share_timeline: shareTimeline,
share_gallery: shareGallery,
share_map: shareMap,
},
};
}
+14 -2
View File
@@ -28,6 +28,8 @@ export interface OidcTokenResponse {
export interface OidcUserInfo {
sub: string;
email?: string;
// Standard OIDC claim. Some IdPs send it as the string "true"/"false".
email_verified?: boolean | string;
name?: string;
preferred_username?: string;
groups?: string[];
@@ -200,7 +202,11 @@ export function frontendUrl(path: string): string {
}
export function generateToken(user: { id: number }): string {
return jwt.sign({ id: user.id }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
// Embed the current password_version so an OIDC-issued session is invalidated
// by a password change/reset exactly like a password-login session (the auth
// middleware compares this `pv` against users.password_version).
const pv = (db.prepare('SELECT password_version FROM users WHERE id = ?').get(user.id) as { password_version?: number } | undefined)?.password_version ?? 0;
return jwt.sign({ id: user.id, pv }, JWT_SECRET, { expiresIn: SESSION_DURATION_SECONDS, algorithm: 'HS256' });
}
// ---------------------------------------------------------------------------
@@ -365,8 +371,14 @@ export function findOrCreateUser(
}
if (user) {
// Link OIDC identity if not yet linked
// Reaching here without an oidc_sub means we matched an existing local
// account by email. Only auto-link the OIDC identity when the IdP asserts
// the email is verified; an unverified email must not auto-link.
if (!user.oidc_sub) {
const emailVerified = userInfo.email_verified === true || userInfo.email_verified === 'true';
if (!emailVerified) {
return { error: 'email_not_verified' };
}
db.prepare('UPDATE users SET oidc_sub = ?, oidc_issuer = ? WHERE id = ?').run(sub, config.issuer, user.id);
}
// Update role based on OIDC claims on every login (if claim mapping is configured)
+6 -4
View File
@@ -318,10 +318,12 @@ export function deleteTrip(tripId: string | number, userId: number, userRole: st
export function deleteOldCover(coverImage: string | null | undefined) {
if (!coverImage) return;
const oldPath = path.join(__dirname, '../../', coverImage.replace(/^\//, ''));
const resolvedPath = path.resolve(oldPath);
const uploadsDir = path.resolve(__dirname, '../../uploads');
if (resolvedPath.startsWith(uploadsDir) && fs.existsSync(resolvedPath)) {
// cover_image is client-supplied, so treat it as untrusted: covers live in
// uploads/covers as a flat filename — use basename() and confine the unlink
// to that directory.
const coversDir = path.resolve(__dirname, '../../uploads/covers');
const resolvedPath = path.resolve(path.join(coversDir, path.basename(coverImage)));
if (resolvedPath.startsWith(coversDir + path.sep) && fs.existsSync(resolvedPath)) {
fs.unlinkSync(resolvedPath);
}
}