fix(security): harden .picpeak restore operator-preservation (GHSA-qxfx follow-up)

The req.admin.id fix activated reinjectCurrentAdmin(); hardening its preservation
logic (found across Codex review rounds of #811):

- MFA hijack: reinject wrote back only password_hash/is_active/
  must_change_password, leaving a crafted backup's two_factor_* on the
  operator's row — it could strip or replace their second factor. The email-
  matched row is now updated with the operator's full AUTH set (login identity,
  password, and all two_factor_* columns). Relationship/audit FKs (role_id,
  created_by) are deliberately NOT forced from the snapshot: on a cross-instance
  restore those pre-restore ids may be absent from the backup and would dangle
  the FK (SQLite rolls back at commit); the restored row keeps its own valid
  values.

- Cross-instance restore rollback / FK safety: reinject matched only by email,
  so a backup shipping a different admin with the default `admin` username hit
  UNIQUE(username) and rolled the whole restore back; email and username could
  even collide on two different rows. Reconciliation is now non-destructive:
  the email-matching row is updated in place (id preserved → restored FKs like
  events.created_by stay valid); any different row holding the operator's
  username is RENAMED, not deleted (deletion would fire ON DELETE actions /
  dangle references); only when no row has the operator's email is a fresh row
  inserted, with created_by nulled and an explicit max(id)+1 id (batchInsert
  left the Postgres identity sequence unadvanced, so a sequence-based insert
  could collide).

- Stale session after restore: admin_users ids shift on restore, but the
  operator's live JWT is bound only to decoded.id (IP logged not enforced; the
  backup controls password_changed_at). The route now revokes the token (result
  checked and logged) and clears the admin cookie; the client redirects to a
  fresh login via a sessionInvalidated flag. Cookie clear is the unconditional
  guarantee.

Adds SQLite-backed reinject regression tests (in-place login/MFA restore with id
and FK columns preserved, username-only rename, email+username on different rows,
clean insert with created_by nulled) and the frontend redirect on
sessionInvalidated.

Deferred (design decisions / pre-existing, need a Postgres test env — see PR
discussion): global "invalidate all pre-restore sessions" cutoff; preserving the
operator's ROLE semantics across an RBAC-table replace; and resyncing Postgres
identity sequences after any restore (batchInsert leaves them behind max(id) —
pre-existing, affects every restored table).
This commit is contained in:
Paul Nothaft
2026-07-16 11:57:54 +02:00
parent 31bc01cb4b
commit 38fd41aad3
4 changed files with 222 additions and 13 deletions
@@ -0,0 +1,111 @@
/**
* Regression tests for reinjectCurrentAdmin — the operator-preservation step of
* the .picpeak restore (GHSA-qxfx-4493-4v8f follow-up). Runs against a real
* in-memory SQLite DB so the UNIQUE(email)/UNIQUE(username) constraints behave
* as in production. Reconciliation is non-destructive (update-in-place / rename,
* never delete) so restored rows referenced by FKs keep their ids.
*/
const knex = require('knex');
let db;
let reinjectCurrentAdmin;
beforeAll(() => {
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }), { virtual: false });
reinjectCurrentAdmin = require('../../src/services/picpeakImportService').reinjectCurrentAdmin;
});
beforeEach(async () => {
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
await db.schema.createTable('admin_users', (t) => {
t.increments('id');
t.string('username').notNullable().unique();
t.string('email').notNullable().unique();
t.string('password_hash');
t.boolean('is_active').defaultTo(true);
t.boolean('must_change_password').defaultTo(false);
t.integer('role_id');
t.integer('created_by');
t.boolean('two_factor_enabled').defaultTo(false);
t.string('two_factor_secret');
t.text('two_factor_recovery_codes');
});
});
afterEach(async () => { await db.destroy(); });
const operator = {
id: 1, username: 'admin', email: 'op@example.com',
password_hash: 'OP_HASH', is_active: 1, must_change_password: 0, role_id: 1, created_by: 99,
two_factor_enabled: 1, two_factor_secret: 'OP_SECRET', two_factor_recovery_codes: '["a","b"]',
};
test('restores login + MFA in place, keeping the row id and its FK columns (FK-safe)', async () => {
await db('admin_users').insert({
id: 7, username: 'someoneelse', email: 'OP@example.com',
password_hash: 'ATTACKER', is_active: 1, must_change_password: 0, role_id: 4, created_by: 5,
two_factor_enabled: 0, two_factor_secret: 'ATTACKER_SECRET', two_factor_recovery_codes: null,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users');
expect(rows).toHaveLength(1);
const row = rows[0];
expect(row.id).toBe(7); // id preserved → FK refs hold
expect(row.username).toBe('admin');
expect(row.password_hash).toBe('OP_HASH');
expect(Boolean(row.two_factor_enabled)).toBe(true);
expect(row.two_factor_secret).toBe('OP_SECRET'); // attacker MFA secret gone
expect(row.two_factor_recovery_codes).toBe('["a","b"]');
// Relationship/audit FKs are NOT forced from the operator snapshot (avoids
// dangling role_id/created_by on a cross-instance restore) — the restored
// row keeps its own already-valid values.
expect(row.role_id).toBe(4);
expect(row.created_by).toBe(5);
});
test('renames (not deletes) a different row holding the operator username', async () => {
await db('admin_users').insert({
id: 3, username: 'admin', email: 'other@instance.test',
password_hash: 'OTHER', is_active: 1, role_id: 4,
});
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // the other admin survives (FK-safe)
const other = rows.find((r) => r.id === 3);
expect(other.username).toBe('admin__restored_3'); // renamed, id kept
expect(other.email).toBe('other@instance.test');
const op = rows.find((r) => r.username === 'admin');
expect(op.password_hash).toBe('OP_HASH');
});
test('reconciles email and username colliding with DIFFERENT rows without deleting either', async () => {
await db('admin_users').insert([
{ id: 4, username: 'someoneelse', email: 'op@example.com', password_hash: 'A', role_id: 4 },
{ id: 5, username: 'admin', email: 'other@instance.test', password_hash: 'B', role_id: 4 },
]);
await expect(db.transaction((trx) => reinjectCurrentAdmin(trx, operator))).resolves.not.toThrow();
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // both rows survive
const opRow = rows.find((r) => r.id === 4); // email match updated in place
expect(opRow.username).toBe('admin');
expect(opRow.password_hash).toBe('OP_HASH');
const renamed = rows.find((r) => r.id === 5); // username holder renamed, not deleted
expect(renamed.username).toBe('admin__restored_5');
});
test('inserts the operator with a non-colliding id when neither key exists in the backup', async () => {
await db('admin_users').insert({
id: 9, username: 'backupadmin', email: 'backup@instance.test', password_hash: 'B', role_id: 1,
});
await db.transaction((trx) => reinjectCurrentAdmin(trx, operator));
const rows = await db('admin_users').orderBy('id');
expect(rows).toHaveLength(2); // backup admin untouched
const opRow = rows.find((r) => r.username === 'admin');
expect(opRow.password_hash).toBe('OP_HASH');
expect(opRow.id).toBe(10); // max(9)+1, no collision
expect(opRow.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
});
+29
View File
@@ -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');
@@ -183,11 +185,38 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
// 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. The operator's
// current JWT is bound only to the pre-restore admin id (adminAuth trusts
// `decoded.id` — IP is logged, not enforced, and the backup controls
// password_changed_at), which could now resolve to a DIFFERENT restored
// account and silently grant its permissions. Force a fresh login instead
// of trusting the old session: revoke the token and clear the cookie.
// Clearing the cookie is the guarantee — it drops the operator's browser
// session unconditionally. Revocation is the extra layer that also kills a
// Bearer-header copy of the JWT; revokeToken() swallows DB errors and
// returns false, so check the result and log loudly if the denylist write
// didn't land (the operator should still re-login, 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;
+74 -13
View File
@@ -81,25 +81,85 @@ 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,
});
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);
} 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);
}
}
// 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
@@ -273,4 +333,5 @@ module.exports = {
importFromPicpeak,
readManifestFromZip,
validateManifest,
reinjectCurrentAdmin,
};
@@ -16,6 +16,7 @@ interface RestoreResult {
tables: number;
filesRestored: number;
usesExternalMedia: boolean;
sessionInvalidated?: boolean;
}
// ── Download half (Dashboard) ────────────────────────────────────────────────
@@ -114,6 +115,13 @@ export const PicpeakRestoreCard: React.FC = () => {
setResult(res.data);
setPendingFile(null);
toast.success(t('backup.picpeak.restoreDone', 'Backup restored.'));
// The restore rewrote admin_users and the backend revoked our session
// (ids may have shifted). Send the operator to a fresh login rather than
// letting the now-stale token resolve to a different restored account.
if (res.data?.sessionInvalidated) {
toast.success(t('backup.picpeak.reloginRequired', 'Restore complete — please sign in again.'));
setTimeout(() => { window.location.href = '/admin/login'; }, 1500);
}
} catch (e: any) {
const msg = e.response?.data?.error || t('backup.picpeak.restoreFailed', 'Restore failed.');
toast.error(msg);