Merge remote-tracking branch 'origin/main' into feat/oidc-sso-phase1
# Conflicts: # backend/src/middleware/maintenance.js
This commit is contained in:
@@ -33,6 +33,13 @@ jest.mock('../utils/tokenRevocation', () => ({
|
||||
isTokenRevoked: jest.fn(),
|
||||
}));
|
||||
|
||||
// The global session cutoff (added for .picpeak restore invalidation) queries
|
||||
// app_settings; stub it to "no cutoff" so it doesn't consume this suite's
|
||||
// one-shot db() mock. Its own behaviour is covered by utils/sessionCutoff.test.js.
|
||||
jest.mock('../utils/sessionCutoff', () => ({
|
||||
isTokenBeforeCutoff: jest.fn().mockResolvedValue(false),
|
||||
}));
|
||||
|
||||
jest.mock('../utils/tokenUtils', () => ({
|
||||
getCustomerTokenFromRequest: jest.fn(),
|
||||
}));
|
||||
|
||||
@@ -2,6 +2,7 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const logger = require('../utils/logger');
|
||||
const { getAdminTokenFromRequest, getGalleryTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
@@ -38,6 +39,13 @@ async function adminAuth(req, res, next) {
|
||||
});
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject any session issued before the global cutoff (set by a .picpeak
|
||||
// restore, which can reassign admin ids). Forces every pre-restore admin
|
||||
// session to re-authenticate against the restored data.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'admin') {
|
||||
@@ -157,7 +165,12 @@ async function galleryAuth(req, res, next) {
|
||||
if (await isTokenRevoked(decoded)) {
|
||||
return res.status(401).json({ error: 'Session has been invalidated', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
|
||||
// Reject sessions issued before the global restore cutoff.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
// Verify token type
|
||||
if (decoded.type !== 'gallery') {
|
||||
return res.status(403).json({ error: 'Invalid access token' });
|
||||
@@ -221,6 +234,11 @@ async function photoAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject sessions issued before the global restore cutoff.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
// Allow both admin and gallery tokens
|
||||
if (decoded.type === 'admin') {
|
||||
const admin = await db('admin_users')
|
||||
|
||||
@@ -13,6 +13,7 @@ const jwt = require('jsonwebtoken');
|
||||
const { db } = require('../database/db');
|
||||
const { formatBoolean } = require('../utils/dbCompat');
|
||||
const { isTokenRevoked } = require('../utils/tokenRevocation');
|
||||
const { isTokenBeforeCutoff } = require('../utils/sessionCutoff');
|
||||
const logger = require('../utils/logger');
|
||||
const { getCustomerTokenFromRequest } = require('../utils/tokenUtils');
|
||||
|
||||
@@ -61,6 +62,11 @@ async function customerAuth(req, res, next) {
|
||||
return res.status(401).json({ error: 'Token has been revoked', code: 'TOKEN_REVOKED' });
|
||||
}
|
||||
|
||||
// Reject sessions issued before the global restore cutoff.
|
||||
if (await isTokenBeforeCutoff(decoded)) {
|
||||
return res.status(401).json({ error: 'Session invalidated', code: 'SESSION_INVALIDATED' });
|
||||
}
|
||||
|
||||
if (decoded.type !== 'customer') {
|
||||
logger.warn('[customerAuth] wrong token type', {
|
||||
url: req.originalUrl,
|
||||
|
||||
@@ -73,6 +73,10 @@ async function maintenanceMiddleware(req, res, next) {
|
||||
// entries here matched nothing, which is exactly why the lockout happened).
|
||||
const skipPaths = [
|
||||
'/api/auth/admin/login',
|
||||
// The second factor is part of the same login — without this, any
|
||||
// MFA-enrolled admin gets a 503 on the verify step and cannot sign in
|
||||
// at all while maintenance mode is on.
|
||||
'/api/auth/admin/login/mfa',
|
||||
// SSO variants of the admin login (#798) — same reasoning: an SSO-only
|
||||
// (JIT-provisioned) admin has no password, so blocking these would make
|
||||
// maintenance mode admin-proof for them.
|
||||
|
||||
@@ -9,6 +9,7 @@ const { requirePermission } = require('../middleware/permissions');
|
||||
const archiver = require('archiver');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { requireEventOwnership } = require('../middleware/ownership');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const logger = require('../utils/logger');
|
||||
const { getPagination } = require('../utils/routeHelpers');
|
||||
const router = express.Router();
|
||||
@@ -183,6 +184,16 @@ router.post('/:id/restore', adminAuth, requirePermission('archives.restore'), re
|
||||
const entries = Object.values(await zip.entries());
|
||||
logger.info(`Archive contains ${entries.length} entries`);
|
||||
|
||||
// Reject ZIP-slip entries before writing anything to disk — extract()
|
||||
// does not neutralise `../` in entry names (GHSA-jfhw-fj23-fx6x).
|
||||
try {
|
||||
assertZipEntriesWithin(entries, eventDir);
|
||||
} catch (slipErr) {
|
||||
await zip.close();
|
||||
logger.warn(`Refusing archive restore — unsafe entry path: ${slipErr.message}`);
|
||||
return res.status(400).json({ error: 'Archive contains invalid entry paths' });
|
||||
}
|
||||
|
||||
// Stream-extract everything to disk
|
||||
await zip.extract(null, eventDir);
|
||||
await zip.close();
|
||||
|
||||
@@ -2,6 +2,8 @@ const express = require('express');
|
||||
const { db } = require('../database/db');
|
||||
const { adminAuth } = require('../middleware/auth');
|
||||
const { requirePermission } = require('../middleware/permissions');
|
||||
const { clearAdminAuthCookie } = require('../utils/tokenUtils');
|
||||
const { revokeToken } = require('../utils/tokenRevocation');
|
||||
const { triggerManualBackup, getBackupStatus, cleanupOldBackupRuns, getBackupManifest, validateBackupManifest } = require('../services/backupService');
|
||||
const logger = require('../utils/logger');
|
||||
const { errorResponse, getPagination } = require('../utils/routeHelpers');
|
||||
@@ -29,7 +31,13 @@ router.get('/config', adminAuth, requirePermission('backup.view'), async (req, r
|
||||
config[setting.setting_key] = setting.setting_value;
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
// Never return the stored credentials — mask like the email/WhatsApp
|
||||
// config endpoints do. The PUT below skips the mask sentinel, so the
|
||||
// form round-trips without clobbering the real values.
|
||||
if (config.backup_s3_secret_key) config.backup_s3_secret_key = '••••••••';
|
||||
if (config.backup_rsync_ssh_key) config.backup_rsync_ssh_key = '••••••••';
|
||||
|
||||
res.json(config);
|
||||
} catch (error) {
|
||||
errorResponse(res, error, 500, 'Failed to get backup configuration');
|
||||
@@ -65,6 +73,11 @@ router.put('/config', adminAuth, requirePermission('backup.create'), async (req,
|
||||
|
||||
// Update settings
|
||||
for (const [key, value] of Object.entries(updates)) {
|
||||
// An unchanged secret round-trips as the GET mask sentinel — keep the
|
||||
// stored value instead of overwriting it with bullets.
|
||||
if (value === '••••••••') {
|
||||
continue;
|
||||
}
|
||||
if (key.startsWith('backup_')) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
@@ -178,12 +191,40 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
const picpeakPath = req.file.path;
|
||||
try {
|
||||
const { importFromPicpeak } = require('../services/picpeakImportService');
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.user && req.user.id });
|
||||
// adminAuth populates req.admin, not req.user. Passing req.user.id here
|
||||
// left currentAdminId undefined, so reinjectCurrentAdmin() had no account
|
||||
// to preserve and the admin_users table was fully replaced by the backup —
|
||||
// letting a crafted .picpeak take over every admin account (GHSA-qxfx-4493-4v8f).
|
||||
const result = await importFromPicpeak({ picpeakPath, currentAdminId: req.admin && req.admin.id });
|
||||
|
||||
// The restore rewrote admin_users, so ids may have shifted. importFromPicpeak
|
||||
// already stamped a GLOBAL session cutoff (see setSessionsValidAfter), so
|
||||
// every JWT issued before the restore — admin, customer, gallery — now fails
|
||||
// auth. Here we additionally give the importing admin an immediate, clean
|
||||
// logout: revoke this token and clear the cookie so their browser drops the
|
||||
// session at once rather than on the next 401. Cookie clear is the
|
||||
// unconditional guarantee; revokeToken() swallows DB errors and returns
|
||||
// false, so check the result and log loudly if the denylist write didn't
|
||||
// land (the operator still re-logs-in, which the cookie clear forces).
|
||||
let tokenRevoked = false;
|
||||
try {
|
||||
if (req.token) {
|
||||
tokenRevoked = await revokeToken(req.token, 'picpeak-import', { adminId: req.admin && req.admin.id });
|
||||
}
|
||||
} catch (revokeErr) {
|
||||
logger.warn('[picpeak-import] failed to revoke session token after restore', { error: revokeErr.message });
|
||||
}
|
||||
if (req.token && !tokenRevoked) {
|
||||
logger.warn('[picpeak-import] session token was NOT added to the revocation denylist after restore; relying on cookie clear to force re-login');
|
||||
}
|
||||
clearAdminAuthCookie(res);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
tables: result.tables,
|
||||
filesRestored: result.filesRestored,
|
||||
usesExternalMedia: result.usesExternalMedia,
|
||||
sessionInvalidated: true,
|
||||
});
|
||||
} catch (error) {
|
||||
const status = error.statusCode || 500;
|
||||
|
||||
@@ -171,6 +171,16 @@ router.get('/', adminAuth, requirePermission('settings.view'), async (req, res)
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||||
// were returned in plaintext to any settings.view holder. Same masking
|
||||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||||
// /admin/backup/config endpoints handle the edit round-trip.
|
||||
if (settingsObject.backup_s3_secret_key) {
|
||||
settingsObject.backup_s3_secret_key = '••••••••';
|
||||
}
|
||||
if (settingsObject.backup_rsync_ssh_key) {
|
||||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
@@ -547,6 +557,16 @@ router.get('/:type', adminAuth, requirePermission('settings.view'), async (req,
|
||||
if (settingsObject.security_recaptcha_secret_key) {
|
||||
settingsObject.security_recaptcha_secret_key = '••••••••';
|
||||
}
|
||||
// Backup credentials — the S3 secret key and the rsync SSH PRIVATE KEY
|
||||
// were returned in plaintext to any settings.view holder. Same masking
|
||||
// pattern as the recaptcha/umami/rybbit keys; the dedicated
|
||||
// /admin/backup/config endpoints handle the edit round-trip.
|
||||
if (settingsObject.backup_s3_secret_key) {
|
||||
settingsObject.backup_s3_secret_key = '••••••••';
|
||||
}
|
||||
if (settingsObject.backup_rsync_ssh_key) {
|
||||
settingsObject.backup_rsync_ssh_key = '••••••••';
|
||||
}
|
||||
// Umami v2 API key (#661 Bug C) — read-write secret that authenticates
|
||||
// outbound calls to the operator's Umami instance for the device
|
||||
// breakdown. Masked on GET, same pattern as the recaptcha secret.
|
||||
|
||||
@@ -560,6 +560,18 @@ router.post('/gallery/share-login', [
|
||||
return res.status(401).json({ error: 'Invalid or expired share link' });
|
||||
}
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
// The share link only proves the holder was given the link — it is NOT the
|
||||
// gallery password. For a password-protected gallery, minting a full
|
||||
// `type:'gallery'` token here would let anyone with the share URL bypass
|
||||
// the password entirely (GHSA-9hmx-68vc-qpqw). Signal that a password is
|
||||
// still required and return WITHOUT a token/cookie; the client then goes
|
||||
// through POST /gallery/verify, which does check the password.
|
||||
if (requiresPassword) {
|
||||
return res.json({ requires_password: true });
|
||||
}
|
||||
|
||||
const jwtToken = jwt.sign({
|
||||
eventId: event.id,
|
||||
eventSlug: event.slug,
|
||||
@@ -574,8 +586,6 @@ router.post('/gallery/share-login', [
|
||||
await trackSuccessfulLogin(`gallery:${event.slug}:share`, ipAddress, userAgent);
|
||||
setGalleryAuthCookies(res, jwtToken, event.slug);
|
||||
|
||||
const requiresPassword = !(event.require_password === false || event.require_password === 0 || event.require_password === '0');
|
||||
|
||||
res.json({
|
||||
token: jwtToken,
|
||||
event: {
|
||||
|
||||
@@ -30,6 +30,16 @@ async function initializeUpload(options) {
|
||||
totalChunks
|
||||
} = options;
|
||||
|
||||
// Strip any directory components from the client-supplied filename. It is
|
||||
// later joined onto the temp merge dir (path.join(tempDir, filename)), and
|
||||
// path.join does NOT neutralise `../` — a filename like `../../uploads/
|
||||
// logos/evil.svg` would escape the temp dir and overwrite arbitrary files
|
||||
// (GHSA-pc72-jf53-w28j). basename() collapses it to the leaf name only.
|
||||
const safeFilename = path.basename(String(filename || ''));
|
||||
if (!safeFilename || safeFilename === '.' || safeFilename === '..') {
|
||||
throw new Error('Invalid filename');
|
||||
}
|
||||
|
||||
// Generate unique upload ID
|
||||
const uploadId = crypto.randomUUID();
|
||||
|
||||
@@ -43,7 +53,7 @@ async function initializeUpload(options) {
|
||||
// Store upload metadata
|
||||
const uploadMeta = {
|
||||
uploadId,
|
||||
filename,
|
||||
filename: safeFilename,
|
||||
fileSize,
|
||||
mimeType,
|
||||
eventId,
|
||||
@@ -59,7 +69,7 @@ async function initializeUpload(options) {
|
||||
|
||||
logger.info('Initialized chunked upload', {
|
||||
uploadId,
|
||||
filename,
|
||||
filename: safeFilename,
|
||||
fileSize,
|
||||
expectedChunks,
|
||||
eventId
|
||||
|
||||
@@ -18,10 +18,12 @@ const fsp = require('fs').promises;
|
||||
const path = require('path');
|
||||
const os = require('os');
|
||||
const StreamZip = require('node-stream-zip');
|
||||
const { assertZipEntriesWithin } = require('../utils/safePath');
|
||||
const { db } = require('../database/db');
|
||||
const knexConfig = require('../../knexfile');
|
||||
const { getStoragePath } = require('../config/storage');
|
||||
const { hasColumnCached } = require('../utils/schemaCache');
|
||||
const { setSessionsValidAfter } = require('../utils/sessionCutoff');
|
||||
const logger = require('../utils/logger');
|
||||
const { PICPEAK_FORMAT_VERSION, EXCLUDED_TABLES, listDataTables } = require('./picpeakExportService');
|
||||
|
||||
@@ -80,25 +82,164 @@ function parseNdjson(filePath) {
|
||||
}
|
||||
|
||||
// Re-insert the operator's account inside the restore transaction so they keep
|
||||
// working credentials. If the backup already loaded an admin with the same
|
||||
// email, overwrite that row's credentials with the current account's (current
|
||||
// creds win); otherwise insert the snapshot with a fresh id.
|
||||
// working credentials after the wipe.
|
||||
//
|
||||
// The operator's login + credentials + MFA must be restored, not just the
|
||||
// password. A crafted backup can carry a row with the operator's email whose
|
||||
// two_factor_* fields are attacker-chosen — leaving those in place would let
|
||||
// the backup strip or hijack the operator's MFA, or (cross-instance) pin a TOTP
|
||||
// secret encrypted with the source instance's key the operator can never
|
||||
// satisfy. These columns are scalar/text (recovery codes are a JSON string in a
|
||||
// TEXT column), so writing them needs no special json handling. Relationship/
|
||||
// audit FKs (role_id, created_by) are deliberately NOT forced from the snapshot
|
||||
// — see the update branch below.
|
||||
//
|
||||
// admin_users has UNIQUE constraints on BOTH email and username, and a restored
|
||||
// backup can collide with the operator on either — possibly on two DIFFERENT
|
||||
// rows (one shares the email, another shares the default `admin` username). We
|
||||
// reconcile WITHOUT deleting any restored row: deleting would fire ON DELETE
|
||||
// actions (SQLite) or dangle references such as events.created_by (Postgres,
|
||||
// where replica mode suppresses cascades). Instead:
|
||||
// - if a row already has the operator's email, overwrite it in place (its id
|
||||
// is preserved, so every FK pointing at the operator stays valid);
|
||||
// - if a DIFFERENT row holds the operator's username, rename that row (id
|
||||
// preserved, its own FKs stay valid) to free the username;
|
||||
// - only when no row has the operator's email do we insert a fresh row.
|
||||
async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
if (!currentAdmin) return;
|
||||
const existing = await trx('admin_users').whereRaw('lower(email) = lower(?)', [currentAdmin.email]).first();
|
||||
if (existing) {
|
||||
await trx('admin_users').where({ id: existing.id }).update({
|
||||
password_hash: currentAdmin.password_hash,
|
||||
is_active: currentAdmin.is_active,
|
||||
must_change_password: currentAdmin.must_change_password,
|
||||
});
|
||||
if (!currentAdmin) return null;
|
||||
|
||||
const emailMatch = await trx('admin_users')
|
||||
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
|
||||
.first();
|
||||
|
||||
// Free the operator's username if a different row holds it (rename, not delete).
|
||||
const usernameHolder = await trx('admin_users')
|
||||
.whereRaw('lower(username) = lower(?)', [currentAdmin.username])
|
||||
.first();
|
||||
if (usernameHolder && (!emailMatch || usernameHolder.id !== emailMatch.id)) {
|
||||
await trx('admin_users')
|
||||
.where({ id: usernameHolder.id })
|
||||
.update({ username: `${usernameHolder.username}__restored_${usernameHolder.id}` });
|
||||
}
|
||||
|
||||
if (emailMatch) {
|
||||
// Update in place — keeps emailMatch.id so restored FKs to the operator
|
||||
// hold. Write only the AUTH-critical columns (login identity + credentials
|
||||
// + MFA), never the relationship/audit FKs (role_id → roles, created_by →
|
||||
// admin_users). Forcing the operator's pre-restore role_id/created_by here
|
||||
// could reference rows absent from a cross-instance backup and dangle the
|
||||
// FK (SQLite rolls back at commit); the row already carries the backup's
|
||||
// own valid values for those. This still closes the MFA-hijack gap — a
|
||||
// crafted backup can't strip or replace the operator's second factor.
|
||||
const authUpdate = {};
|
||||
for (const field of PRESERVED_AUTH_FIELDS) {
|
||||
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
|
||||
}
|
||||
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
|
||||
return emailMatch.id;
|
||||
} else {
|
||||
const row = { ...currentAdmin };
|
||||
delete row.id; // let the engine assign a fresh id to avoid collision
|
||||
await trx('admin_users').insert(row);
|
||||
// The operator's email isn't in the backup, so nothing restored references
|
||||
// their id — a fresh row can't dangle a reference TO the operator. Null the
|
||||
// self-referential created_by (its target admin may be absent from this
|
||||
// backup; ON DELETE SET NULL makes null the correct "unknown inviter"
|
||||
// value) so the insert itself can't dangle. Use an explicit max(id)+1
|
||||
// rather than the identity sequence, which batchInsert left unadvanced on
|
||||
// Postgres (a sequence-based insert could collide with a restored id).
|
||||
const snapshot = { ...currentAdmin };
|
||||
delete snapshot.id;
|
||||
if ('created_by' in snapshot) snapshot.created_by = null;
|
||||
const maxRow = await trx('admin_users').max({ m: 'id' }).first();
|
||||
snapshot.id = (Number(maxRow && maxRow.m) || 0) + 1;
|
||||
await trx('admin_users').insert(snapshot);
|
||||
return snapshot.id;
|
||||
}
|
||||
}
|
||||
|
||||
// Capture the operator's role and its granted permission NAMES before the wipe,
|
||||
// so preserveOperatorRole() can re-establish the operator's authorization after
|
||||
// the RBAC tables are replaced. Permission NAMES (not ids) are captured because
|
||||
// the restored permissions table reassigns ids. Returns null if the operator
|
||||
// has no role.
|
||||
async function captureOperatorRole(roleId) {
|
||||
if (!roleId) return null;
|
||||
const role = await db('roles').where({ id: roleId }).first();
|
||||
if (!role) return null;
|
||||
const permissions = await db('role_permissions')
|
||||
.join('permissions', 'permissions.id', 'role_permissions.permission_id')
|
||||
.where('role_permissions.role_id', roleId)
|
||||
.pluck('permissions.name');
|
||||
return { role, permissions };
|
||||
}
|
||||
|
||||
// Restore the operator's authorization after roles/role_permissions are
|
||||
// replaced. A restore rewrites the RBAC tables, so the operator's pre-restore
|
||||
// role_id may now name a different (or missing) role — a crafted backup could
|
||||
// silently downgrade them, and reinjectCurrentAdmin deliberately does NOT copy
|
||||
// role_id (it could dangle). Here we resolve the role by NAME against the
|
||||
// restored data: if a role with the operator's role name exists we trust it
|
||||
// (it's the backup the operator chose to restore); otherwise we re-create the
|
||||
// role from the captured snapshot and re-grant the captured permissions that
|
||||
// still exist, so the operator can never be locked out of their own instance.
|
||||
async function preserveOperatorRole(trx, operatorId, snapshot) {
|
||||
if (!operatorId || !snapshot || !snapshot.role) return;
|
||||
const { role, permissions } = snapshot;
|
||||
|
||||
let target = await trx('roles').whereRaw('lower(name) = lower(?)', [role.name]).first();
|
||||
if (!target) {
|
||||
const roleRow = { ...role };
|
||||
delete roleRow.id;
|
||||
const maxRole = await trx('roles').max({ m: 'id' }).first();
|
||||
const newRoleId = (Number(maxRole && maxRole.m) || 0) + 1; // sequence resynced post-commit
|
||||
roleRow.id = newRoleId;
|
||||
await trx('roles').insert(roleRow);
|
||||
if (permissions && permissions.length) {
|
||||
const perms = await trx('permissions').whereIn('name', permissions).select('id');
|
||||
if (perms.length) {
|
||||
await trx('role_permissions').insert(
|
||||
perms.map((p) => ({ role_id: newRoleId, permission_id: p.id }))
|
||||
);
|
||||
}
|
||||
}
|
||||
target = { id: newRoleId };
|
||||
}
|
||||
await trx('admin_users').where({ id: operatorId }).update({ role_id: target.id });
|
||||
}
|
||||
|
||||
// Fast-forward each restored table's Postgres identity sequence to its current
|
||||
// max(id). batchInsert writes explicit ids without advancing the sequence, so
|
||||
// the next natural insert into any restored table (a new event, an accepted
|
||||
// invitation, etc.) would otherwise collide on the primary key. Runs AFTER the
|
||||
// restore transaction commits (setval is non-transactional and would survive a
|
||||
// rollback) and guards every table with a column-existence check —
|
||||
// pg_get_serial_sequence RAISES on a table lacking an `id` column (e.g. the
|
||||
// composite-key role_permissions), so an unguarded call would abort here.
|
||||
// No-op on SQLite, whose AUTOINCREMENT tracks the high-water mark itself.
|
||||
async function resyncSequences(tables) {
|
||||
if (!isPostgres()) return;
|
||||
for (const table of tables) {
|
||||
try {
|
||||
if (!(await db.schema.hasColumn(table, 'id'))) continue;
|
||||
const res = await db.raw('SELECT pg_get_serial_sequence(?, ?) AS seq', [table, 'id']);
|
||||
const seq = res && res.rows && res.rows[0] && res.rows[0].seq;
|
||||
if (!seq) continue; // `id` isn't a serial/identity column
|
||||
await db.raw(
|
||||
'SELECT setval(?, (SELECT COALESCE(MAX(id), 1) FROM ??), (SELECT MAX(id) IS NOT NULL FROM ??))',
|
||||
[seq, table, table]
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`[picpeak-import] could not resync sequence for ${table}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// AUTH-critical admin_users columns preserved when overwriting a restored row
|
||||
// that shares the operator's email. Deliberately excludes relationship/audit
|
||||
// FKs (role_id, created_by) — see reinjectCurrentAdmin for why.
|
||||
const PRESERVED_AUTH_FIELDS = [
|
||||
'username', 'email', 'password_hash', 'is_active', 'must_change_password',
|
||||
'two_factor_enabled', 'two_factor_secret', 'two_factor_recovery_codes', 'two_factor_enrolled_at',
|
||||
];
|
||||
|
||||
// The json/jsonb columns of a table (Postgres only). The pg driver returns
|
||||
// jsonb as parsed JS values, so on re-insert they must be serialised back to
|
||||
// valid JSON text — otherwise a scalar like the string "PicPeak" is sent
|
||||
@@ -127,7 +268,7 @@ function serialiseJsonColumns(rows, jsonCols) {
|
||||
// session_replication_role=replica on the trx connection, reset before commit;
|
||||
// sqlite: defer_foreign_keys so checks run at commit). knex_migrations is never
|
||||
// in the data set, so the target's schema/migration state is left intact.
|
||||
async function replaceAllTables(tables, dataDir, currentAdmin) {
|
||||
async function replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot) {
|
||||
await db.transaction(async (trx) => {
|
||||
if (isPostgres()) {
|
||||
try {
|
||||
@@ -157,7 +298,10 @@ async function replaceAllTables(tables, dataDir, currentAdmin) {
|
||||
await trx.batchInsert(table, serialiseJsonColumns(rows, jsonCols), 100);
|
||||
}
|
||||
|
||||
await reinjectCurrentAdmin(trx, currentAdmin);
|
||||
const operatorId = await reinjectCurrentAdmin(trx, currentAdmin);
|
||||
if (operatorId && roleSnapshot) {
|
||||
await preserveOperatorRole(trx, operatorId, roleSnapshot);
|
||||
}
|
||||
|
||||
// Reset the pg session flag BEFORE the connection returns to the pool.
|
||||
if (isPostgres()) await trx.raw("SET session_replication_role = 'origin'");
|
||||
@@ -227,11 +371,18 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
const currentAdmin = currentAdminId
|
||||
? await db('admin_users').where({ id: currentAdminId }).first()
|
||||
: null;
|
||||
// Capture the operator's role + granted permission names BEFORE the wipe so
|
||||
// their authorization can be re-established after the RBAC tables are replaced.
|
||||
const roleSnapshot = currentAdmin ? await captureOperatorRole(currentAdmin.role_id) : null;
|
||||
|
||||
const staging = await fsp.mkdtemp(path.join(os.tmpdir(), 'picpeak-import-'));
|
||||
try {
|
||||
const zip = new StreamZip.async({ file: picpeakPath });
|
||||
try {
|
||||
// Reject ZIP-slip entries before extracting — a crafted .picpeak could
|
||||
// otherwise write outside the staging dir via `../` entry names
|
||||
// (same class as GHSA-jfhw-fj23-fx6x).
|
||||
assertZipEntriesWithin(Object.values(await zip.entries()), staging);
|
||||
await zip.extract(null, staging);
|
||||
} finally {
|
||||
await zip.close();
|
||||
@@ -251,7 +402,16 @@ async function importFromPicpeak({ picpeakPath, currentAdminId }) {
|
||||
logger.warn(`[picpeak-import] ignoring ${skipped.length} backup table(s) not present in this DB (or protected): ${skipped.join(', ')}`);
|
||||
}
|
||||
|
||||
await replaceAllTables(tables, dataDir, currentAdmin);
|
||||
await replaceAllTables(tables, dataDir, currentAdmin, roleSnapshot);
|
||||
|
||||
// Post-commit fixups (must NOT run inside the restore transaction):
|
||||
// - resync Postgres identity sequences left behind by the explicit-id
|
||||
// batchInsert, so the next natural insert doesn't collide;
|
||||
// - stamp a global session cutoff so every JWT issued before this restore
|
||||
// (admin, customer, gallery) stops authenticating — ids may have shifted.
|
||||
await resyncSequences(tables);
|
||||
await setSessionsValidAfter(Math.floor(Date.now() / 1000));
|
||||
|
||||
const filesRestored = await restoreFiles(staging);
|
||||
const usesExternalMedia = await detectExternalMedia();
|
||||
|
||||
@@ -268,4 +428,8 @@ module.exports = {
|
||||
importFromPicpeak,
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
reinjectCurrentAdmin,
|
||||
captureOperatorRole,
|
||||
preserveOperatorRole,
|
||||
resyncSequences,
|
||||
};
|
||||
|
||||
@@ -118,7 +118,41 @@ function assertContractPdfPath(filePath) {
|
||||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* ZIP-slip guard. `node-stream-zip`'s `extract(null, root)` writes each entry
|
||||
* to `path.join(root, entry.name)` without neutralising `../` — a crafted
|
||||
* archive with an entry named `../../uploads/logos/evil.svg` escapes `root`
|
||||
* and overwrites arbitrary files (GHSA-jfhw-fj23-fx6x). Call this with the
|
||||
* entry list BEFORE extract() to reject any entry that resolves outside the
|
||||
* target directory.
|
||||
*
|
||||
* Purely lexical (path.resolve, no realpath) because the extraction target
|
||||
* does not exist on disk yet. Absolute entry names (`/etc/passwd`) resolve
|
||||
* away from `root` and are caught too. Throws AppError 400 on the first
|
||||
* offending entry so the whole archive is refused.
|
||||
*
|
||||
* @param {Array<{name?: string}>} entries node-stream-zip entry objects
|
||||
* @param {string} extractRoot directory extract() will write into
|
||||
*/
|
||||
function assertZipEntriesWithin(entries, extractRoot) {
|
||||
const rootResolved = path.resolve(extractRoot);
|
||||
const prefix = rootResolved.endsWith(path.sep) ? rootResolved : rootResolved + path.sep;
|
||||
for (const entry of entries || []) {
|
||||
const name = entry && entry.name;
|
||||
if (!name) continue;
|
||||
const target = path.resolve(rootResolved, name);
|
||||
if (target !== rootResolved && !target.startsWith(prefix)) {
|
||||
throw new AppError(
|
||||
`Archive contains an entry that escapes the extraction directory: ${name}`,
|
||||
400,
|
||||
'ZIP_SLIP'
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
assertPathInside,
|
||||
assertContractPdfPath,
|
||||
assertZipEntriesWithin,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Global session cutoff.
|
||||
*
|
||||
* A .picpeak restore rewrites admin_users / customer_accounts / events and can
|
||||
* reassign their primary keys, so any JWT issued BEFORE the restore may now
|
||||
* resolve to a different restored principal (auth middleware binds a token to
|
||||
* `decoded.id`; IP is only logged and the backup controls each row's
|
||||
* `password_changed_at`). Revoking the single importing token is not enough —
|
||||
* every pre-restore admin, customer, and gallery session must stop being
|
||||
* honoured.
|
||||
*
|
||||
* We record a single unix-second cutoff in app_settings and reject any token
|
||||
* whose `iat` predates it, across all three JWT auth paths. The operator's
|
||||
* forced re-login mints a token with `iat >= cutoff`, so it passes; everything
|
||||
* issued earlier is refused. The value is cached briefly so the common auth
|
||||
* path stays a single in-memory comparison.
|
||||
*/
|
||||
const { db } = require('../database/db');
|
||||
const logger = require('./logger');
|
||||
|
||||
const CUTOFF_KEY = 'security_sessions_valid_after';
|
||||
const CACHE_MS = 30 * 1000; // restores are rare; a short TTL keeps auth cheap
|
||||
|
||||
let cache = null; // { value: number, expiry: number }
|
||||
|
||||
async function readCutoffFromDb() {
|
||||
const row = await db('app_settings')
|
||||
.where('setting_key', CUTOFF_KEY)
|
||||
.first()
|
||||
.timeout(5000);
|
||||
if (!row || row.setting_value == null) return 0;
|
||||
let value = row.setting_value;
|
||||
// pg `json` returns a parsed number; sqlite returns the stored string.
|
||||
if (typeof value === 'string') {
|
||||
try { value = JSON.parse(value); } catch (_) { /* fall through to parseInt */ }
|
||||
}
|
||||
const seconds = parseInt(value, 10);
|
||||
return Number.isFinite(seconds) ? seconds : 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cutoff as unix seconds (0 = no cutoff set). Cached for CACHE_MS. On a
|
||||
* transient DB error, returns the last known value (or 0) rather than blocking
|
||||
* auth — the cutoff is defence-in-depth layered on top of per-token revocation.
|
||||
*/
|
||||
async function getSessionsValidAfter() {
|
||||
const now = Date.now();
|
||||
if (cache && now < cache.expiry) return cache.value;
|
||||
try {
|
||||
const value = await readCutoffFromDb();
|
||||
cache = { value, expiry: now + CACHE_MS };
|
||||
return value;
|
||||
} catch (err) {
|
||||
logger.warn('[sessionCutoff] failed to read cutoff:', err.message);
|
||||
return cache ? cache.value : 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Persist a new cutoff (unix seconds) and refresh the in-process cache. */
|
||||
async function setSessionsValidAfter(unixSeconds) {
|
||||
await db('app_settings')
|
||||
.insert({
|
||||
setting_key: CUTOFF_KEY,
|
||||
setting_value: JSON.stringify(unixSeconds),
|
||||
setting_type: 'number',
|
||||
updated_at: new Date(),
|
||||
})
|
||||
.onConflict('setting_key')
|
||||
.merge({ setting_value: JSON.stringify(unixSeconds), setting_type: 'number', updated_at: new Date() });
|
||||
cache = { value: unixSeconds, expiry: Date.now() + CACHE_MS };
|
||||
}
|
||||
|
||||
/**
|
||||
* True when this token was issued before the global cutoff. Fail-open on any
|
||||
* error: the cutoff is defence-in-depth on top of per-token revocation and the
|
||||
* post-restore cookie clear, and must never turn a transient read failure into
|
||||
* an auth outage.
|
||||
*/
|
||||
async function isTokenBeforeCutoff(decoded) {
|
||||
try {
|
||||
if (!decoded || !decoded.iat) return false;
|
||||
const cutoff = await getSessionsValidAfter();
|
||||
if (!cutoff) return false;
|
||||
return decoded.iat < cutoff;
|
||||
} catch (err) {
|
||||
logger.warn('[sessionCutoff] check failed, allowing token:', err.message);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Test-only: drop the in-process cache. */
|
||||
function _resetCache() { cache = null; }
|
||||
|
||||
module.exports = {
|
||||
CUTOFF_KEY,
|
||||
getSessionsValidAfter,
|
||||
setSessionsValidAfter,
|
||||
isTokenBeforeCutoff,
|
||||
_resetCache,
|
||||
};
|
||||
Reference in New Issue
Block a user