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 12:31:28 +02:00
parent dcfcb67f9b
commit b00a16159e
4 changed files with 222 additions and 13 deletions
@@ -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);