mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-06-20 13:51:45 +00:00
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:
@@ -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 } {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user