Merge pull request #813 from PicPeak/feat/harden-picpeak-restore-robustness
feat(security): harden .picpeak restore robustness — sessions, roles, sequences
This commit is contained in:
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* PostgreSQL integration tests for the .picpeak restore robustness fixes.
|
||||
* Gated: runs only when PICPEAK_PG_TEST_URL points at a throwaway Postgres DB,
|
||||
* e.g.
|
||||
* PICPEAK_PG_TEST_URL="postgres://picpeak:picpeak_secure_pass_2024@127.0.0.1:7102/picpeak_restore_test" \
|
||||
* npx jest __tests__/integration/picpeakRestorePg.test.js
|
||||
*
|
||||
* Validates the Postgres-specific paths that SQLite can't exercise: identity
|
||||
* sequences left stale by explicit-id inserts, pg_get_serial_sequence raising on
|
||||
* id-less tables, reinject/role-recreate explicit-id inserts, and FK integrity.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
const fs = require('fs');
|
||||
const os = require('os');
|
||||
const path = require('path');
|
||||
|
||||
const PG_URL = process.env.PICPEAK_PG_TEST_URL;
|
||||
const maybe = PG_URL ? describe : describe.skip;
|
||||
|
||||
maybe('picpeak restore on Postgres', () => {
|
||||
let pgDb;
|
||||
let svc;
|
||||
|
||||
beforeAll(async () => {
|
||||
pgDb = knex({ client: 'pg', connection: PG_URL });
|
||||
|
||||
await pgDb.raw('DROP TABLE IF EXISTS role_permissions, events, admin_users, roles, permissions, app_settings CASCADE');
|
||||
await pgDb.schema.createTable('roles', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name', 50).notNullable().unique();
|
||||
t.string('display_name', 100);
|
||||
t.integer('priority').defaultTo(0);
|
||||
t.boolean('is_system').defaultTo(false);
|
||||
});
|
||||
await pgDb.schema.createTable('permissions', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name', 100).notNullable().unique();
|
||||
t.string('display_name', 150);
|
||||
t.string('category', 50);
|
||||
});
|
||||
await pgDb.schema.createTable('role_permissions', (t) => {
|
||||
t.integer('role_id').notNullable().references('id').inTable('roles').onDelete('CASCADE');
|
||||
t.integer('permission_id').notNullable().references('id').inTable('permissions').onDelete('CASCADE');
|
||||
t.primary(['role_id', 'permission_id']);
|
||||
});
|
||||
await pgDb.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').references('id').inTable('roles').onDelete('SET NULL');
|
||||
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
t.boolean('two_factor_enabled').defaultTo(false);
|
||||
t.string('two_factor_secret');
|
||||
t.text('two_factor_recovery_codes');
|
||||
});
|
||||
await pgDb.schema.createTable('events', (t) => {
|
||||
t.increments('id');
|
||||
t.string('slug');
|
||||
t.integer('created_by').references('id').inTable('admin_users').onDelete('SET NULL');
|
||||
});
|
||||
await pgDb.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.json('setting_value');
|
||||
t.string('setting_type');
|
||||
t.timestamp('updated_at').defaultTo(pgDb.fn.now());
|
||||
});
|
||||
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'pg' }));
|
||||
jest.doMock('../../src/database/db', () => ({ db: pgDb }));
|
||||
svc = require('../../src/services/picpeakImportService');
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
if (pgDb) await pgDb.destroy();
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
await pgDb('role_permissions').del();
|
||||
await pgDb('events').del();
|
||||
await pgDb('admin_users').del();
|
||||
await pgDb('roles').del();
|
||||
await pgDb('permissions').del();
|
||||
});
|
||||
|
||||
test('resyncSequences fast-forwards stale sequences and skips id-less tables', async () => {
|
||||
// Simulate a restore: explicit-id inserts leave the sequence at 1.
|
||||
await pgDb('roles').insert([{ id: 5, name: 'super_admin', display_name: 'SA' }]);
|
||||
await pgDb('admin_users').insert([{ id: 9, username: 'a', email: 'a@x.io', password_hash: 'h' }]);
|
||||
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
await pgDb('role_permissions').insert([{ role_id: 5, permission_id: 3 }]); // id-less table
|
||||
|
||||
// Must not throw on role_permissions (no `id` column → pg_get_serial_sequence raises unguarded).
|
||||
await expect(svc.resyncSequences(['roles', 'admin_users', 'permissions', 'role_permissions'])).resolves.toBeUndefined();
|
||||
|
||||
// Natural inserts (no explicit id) now avoid the restored ids.
|
||||
const [adminId] = await pgDb('admin_users').insert({ username: 'b', email: 'b@x.io', password_hash: 'h' }).returning('id');
|
||||
expect(Number(adminId.id || adminId)).toBe(10); // max(9)+1, no duplicate-key error
|
||||
const [roleId] = await pgDb('roles').insert({ name: 'editor', display_name: 'Ed' }).returning('id');
|
||||
expect(Number(roleId.id || roleId)).toBe(6);
|
||||
});
|
||||
|
||||
test('reinjectCurrentAdmin insert branch works with a stale sequence (explicit max+1)', async () => {
|
||||
await pgDb('admin_users').insert({ id: 9, username: 'backup', email: 'backup@x.io', password_hash: 'h' });
|
||||
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, created_by: 42 };
|
||||
|
||||
await pgDb.transaction((trx) => svc.reinjectCurrentAdmin(trx, operator));
|
||||
|
||||
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
|
||||
expect(op.id).toBe(10); // max(9)+1
|
||||
expect(op.password_hash).toBe('OP');
|
||||
expect(op.created_by).toBeNull(); // self-ref FK nulled so the insert can't dangle
|
||||
});
|
||||
|
||||
test('preserveOperatorRole re-creates a missing role on Postgres and keeps FK integrity', async () => {
|
||||
await pgDb('permissions').insert([{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
await pgDb('roles').insert([{ id: 2, name: 'viewer', display_name: 'V' }]);
|
||||
await pgDb('admin_users').insert({ id: 1, username: 'admin', email: 'op@x.io', password_hash: 'h', role_id: null });
|
||||
const snapshot = { role: { name: 'super_admin', display_name: 'SA', priority: 100, is_system: true }, permissions: ['events.create', 'missing.perm'] };
|
||||
|
||||
await pgDb.transaction((trx) => svc.preserveOperatorRole(trx, 1, snapshot));
|
||||
await svc.resyncSequences(['roles']); // post-commit, mirrors importFromPicpeak
|
||||
|
||||
const role = await pgDb('roles').where({ name: 'super_admin' }).first();
|
||||
expect(role).toBeTruthy();
|
||||
const op = await pgDb('admin_users').where({ id: 1 }).first();
|
||||
expect(op.role_id).toBe(role.id); // FK valid, operator not downgraded
|
||||
const grants = await pgDb('role_permissions').where({ role_id: role.id }).pluck('permission_id');
|
||||
expect(grants).toEqual([3]); // existing perm granted, missing.perm skipped
|
||||
});
|
||||
|
||||
test('full replaceAllTables: cross-instance backup preserves the operator, role, FKs, and sequences', async () => {
|
||||
// A backup from ANOTHER instance: omits the operator's email AND their
|
||||
// super_admin role; uses explicit ids that leave sequences stale.
|
||||
const staging = fs.mkdtempSync(path.join(os.tmpdir(), 'picpeak-pgtest-'));
|
||||
const dataDir = path.join(staging, 'data');
|
||||
fs.mkdirSync(dataDir);
|
||||
const write = (t, rows) => fs.writeFileSync(path.join(dataDir, `${t}.ndjson`), rows.map((r) => JSON.stringify(r)).join('\n'));
|
||||
write('roles', [{ id: 5, name: 'admin', display_name: 'Admin', priority: 50, is_system: true }]);
|
||||
write('permissions', [{ id: 3, name: 'events.create', display_name: 'C', category: 'events' }]);
|
||||
write('role_permissions', [{ role_id: 5, permission_id: 3 }]);
|
||||
write('admin_users', [{ id: 9, username: 'backupadmin', email: 'backup@x.io', password_hash: 'h', role_id: 5, is_active: true }]);
|
||||
write('events', [{ id: 2, slug: 'restored-ev', created_by: 9 }]);
|
||||
|
||||
const operator = { id: 1, username: 'admin', email: 'op@x.io', password_hash: 'OP', is_active: true, role_id: 999, created_by: null };
|
||||
const roleSnapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
|
||||
const tables = ['roles', 'permissions', 'role_permissions', 'admin_users', 'events'];
|
||||
|
||||
// replaceAllTables isn't exported, so drive its exact transaction sequence
|
||||
// (suspend FKs, wipe, batchInsert, reinject, preserve role) through the
|
||||
// exported units against real Postgres.
|
||||
const importSvc = svc;
|
||||
await pgDb.transaction(async (trx) => {
|
||||
await trx.raw('SET session_replication_role = \'replica\'');
|
||||
for (const t of tables) await trx(t).del();
|
||||
for (const t of tables) {
|
||||
const rows = fs.readFileSync(path.join(dataDir, `${t}.ndjson`), 'utf8').split('\n').filter(Boolean).map((l) => JSON.parse(l));
|
||||
if (rows.length) await trx.batchInsert(t, rows, 100);
|
||||
}
|
||||
const opId = await importSvc.reinjectCurrentAdmin(trx, operator);
|
||||
await importSvc.preserveOperatorRole(trx, opId, roleSnapshot);
|
||||
await trx.raw('SET session_replication_role = \'origin\'');
|
||||
});
|
||||
await importSvc.resyncSequences(tables);
|
||||
|
||||
// Operator preserved (inserted, since email absent from backup).
|
||||
const op = await pgDb('admin_users').where({ email: 'op@x.io' }).first();
|
||||
expect(op).toBeTruthy();
|
||||
expect(op.password_hash).toBe('OP');
|
||||
// super_admin role re-created and the operator bound to it.
|
||||
const sa = await pgDb('roles').where({ name: 'super_admin' }).first();
|
||||
expect(sa).toBeTruthy();
|
||||
expect(op.role_id).toBe(sa.id);
|
||||
expect(await pgDb('role_permissions').where({ role_id: sa.id }).pluck('permission_id')).toEqual([3]);
|
||||
// Restored event's created_by FK to the backup admin still valid.
|
||||
const ev = await pgDb('events').where({ slug: 'restored-ev' }).first();
|
||||
expect(ev.created_by).toBe(9);
|
||||
// Sequences resynced → natural inserts don't collide.
|
||||
const [newAdmin] = await pgDb('admin_users').insert({ username: 'fresh', email: 'fresh@x.io', password_hash: 'h' }).returning('id');
|
||||
expect(Number(newAdmin.id || newAdmin)).toBeGreaterThan(op.id);
|
||||
|
||||
fs.rmSync(staging, { recursive: true, force: true });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,105 @@
|
||||
/**
|
||||
* Tests for preserveOperatorRole — re-establishing the operator's authorization
|
||||
* after a restore replaces the roles / permissions / role_permissions tables.
|
||||
* Real in-memory SQLite so the joins and inserts behave as in production.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let svc;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.schema.createTable('roles', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name').notNullable().unique();
|
||||
t.string('display_name');
|
||||
t.integer('priority').defaultTo(0);
|
||||
t.boolean('is_system').defaultTo(false);
|
||||
});
|
||||
await db.schema.createTable('permissions', (t) => {
|
||||
t.increments('id');
|
||||
t.string('name').notNullable().unique();
|
||||
t.string('display_name');
|
||||
t.string('category');
|
||||
});
|
||||
await db.schema.createTable('role_permissions', (t) => {
|
||||
t.integer('role_id').notNullable();
|
||||
t.integer('permission_id').notNullable();
|
||||
t.primary(['role_id', 'permission_id']);
|
||||
});
|
||||
await db.schema.createTable('admin_users', (t) => {
|
||||
t.increments('id');
|
||||
t.string('email');
|
||||
t.integer('role_id');
|
||||
});
|
||||
jest.resetModules();
|
||||
jest.doMock('../../knexfile', () => ({ client: 'sqlite3' }));
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
svc = require('../../src/services/picpeakImportService');
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
jest.dontMock('../../knexfile');
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('captureOperatorRole returns the role + its permission names', async () => {
|
||||
await db('roles').insert({ id: 1, name: 'super_admin', display_name: 'Super Admin', priority: 100 });
|
||||
await db('permissions').insert([
|
||||
{ id: 1, name: 'events.create', display_name: 'Create', category: 'events' },
|
||||
{ id: 2, name: 'users.manage', display_name: 'Manage', category: 'users' },
|
||||
]);
|
||||
await db('role_permissions').insert([{ role_id: 1, permission_id: 1 }, { role_id: 1, permission_id: 2 }]);
|
||||
|
||||
const snap = await svc.captureOperatorRole(1);
|
||||
expect(snap.role.name).toBe('super_admin');
|
||||
expect(snap.permissions.sort()).toEqual(['events.create', 'users.manage']);
|
||||
});
|
||||
|
||||
test('preserveOperatorRole binds to a restored role of the same NAME (ids remapped)', async () => {
|
||||
const snapshot = { role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true }, permissions: ['events.create'] };
|
||||
// Simulate post-restore RBAC where super_admin now has a DIFFERENT id.
|
||||
await db('roles').insert({ id: 7, name: 'super_admin', display_name: 'Super Admin (restored)', priority: 100 });
|
||||
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
|
||||
|
||||
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
|
||||
|
||||
const op = await db('admin_users').where({ id: 3 }).first();
|
||||
expect(op.role_id).toBe(7); // bound to restored super_admin by name
|
||||
expect(await db('roles').count({ c: '*' }).first()).toEqual({ c: 1 }); // no duplicate role created
|
||||
});
|
||||
|
||||
test('preserveOperatorRole re-creates the role + grants when the backup omits it', async () => {
|
||||
const snapshot = {
|
||||
role: { name: 'super_admin', display_name: 'Super Admin', priority: 100, is_system: true },
|
||||
permissions: ['events.create', 'users.manage', 'gone.permission'],
|
||||
};
|
||||
// Post-restore RBAC WITHOUT super_admin; only some permissions exist.
|
||||
await db('roles').insert({ id: 2, name: 'viewer', display_name: 'Viewer', priority: 10 });
|
||||
await db('permissions').insert([
|
||||
{ id: 5, name: 'events.create', display_name: 'Create', category: 'events' },
|
||||
{ id: 6, name: 'users.manage', display_name: 'Manage', category: 'users' },
|
||||
]);
|
||||
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
|
||||
|
||||
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, snapshot));
|
||||
|
||||
const recreated = await db('roles').where({ name: 'super_admin' }).first();
|
||||
expect(recreated).toBeTruthy(); // role re-created, not left missing
|
||||
expect(recreated.id).toBe(3); // max(2)+1
|
||||
|
||||
const op = await db('admin_users').where({ id: 3 }).first();
|
||||
expect(op.role_id).toBe(recreated.id); // operator not locked out / downgraded
|
||||
|
||||
const grants = await db('role_permissions').where({ role_id: recreated.id }).pluck('permission_id');
|
||||
expect(grants.sort()).toEqual([5, 6]); // existing perms re-granted; 'gone.permission' skipped
|
||||
});
|
||||
|
||||
test('preserveOperatorRole no-ops when the operator had no role', async () => {
|
||||
await db('admin_users').insert({ id: 3, email: 'op@example.com', role_id: null });
|
||||
await db.transaction((trx) => svc.preserveOperatorRole(trx, 3, null));
|
||||
const op = await db('admin_users').where({ id: 3 }).first();
|
||||
expect(op.role_id).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* Unit tests for the global session cutoff (utils/sessionCutoff.js). Uses a
|
||||
* real in-memory SQLite `app_settings` table so the read/write/parse path is
|
||||
* exercised exactly as in production.
|
||||
*/
|
||||
const knex = require('knex');
|
||||
|
||||
let db;
|
||||
let cutoff;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = knex({ client: 'sqlite3', connection: { filename: ':memory:' }, useNullAsDefault: true });
|
||||
await db.schema.createTable('app_settings', (t) => {
|
||||
t.increments('id');
|
||||
t.string('setting_key').notNullable().unique();
|
||||
t.text('setting_value');
|
||||
t.string('setting_type');
|
||||
t.timestamp('updated_at');
|
||||
});
|
||||
jest.resetModules();
|
||||
jest.doMock('../../src/database/db', () => ({ db }));
|
||||
cutoff = require('../../src/utils/sessionCutoff');
|
||||
cutoff._resetCache();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
jest.dontMock('../../src/database/db');
|
||||
await db.destroy();
|
||||
});
|
||||
|
||||
test('no cutoff set → nothing is invalidated', async () => {
|
||||
expect(await cutoff.getSessionsValidAfter()).toBe(0);
|
||||
expect(await cutoff.isTokenBeforeCutoff({ iat: 1000 })).toBe(false);
|
||||
});
|
||||
|
||||
test('token issued before the cutoff is rejected, at/after is accepted', async () => {
|
||||
await cutoff.setSessionsValidAfter(2000);
|
||||
expect(await cutoff.isTokenBeforeCutoff({ iat: 1999 })).toBe(true); // pre-restore session
|
||||
expect(await cutoff.isTokenBeforeCutoff({ iat: 2000 })).toBe(false); // same second → kept
|
||||
expect(await cutoff.isTokenBeforeCutoff({ iat: 2001 })).toBe(false); // post-restore login
|
||||
});
|
||||
|
||||
test('setSessionsValidAfter upserts a single row and refreshes the cache', async () => {
|
||||
await cutoff.setSessionsValidAfter(1000);
|
||||
await cutoff.setSessionsValidAfter(3000);
|
||||
const rows = await db('app_settings').where('setting_key', 'security_sessions_valid_after');
|
||||
expect(rows).toHaveLength(1);
|
||||
cutoff._resetCache();
|
||||
expect(await cutoff.getSessionsValidAfter()).toBe(3000);
|
||||
});
|
||||
|
||||
test('a token without iat is never treated as before the cutoff', async () => {
|
||||
await cutoff.setSessionsValidAfter(2000);
|
||||
expect(await cutoff.isTokenBeforeCutoff({})).toBe(false);
|
||||
expect(await cutoff.isTokenBeforeCutoff(null)).toBe(false);
|
||||
});
|
||||
@@ -11,6 +11,7 @@
|
||||
"generate:watermarks": "node scripts/generate-watermarks.js",
|
||||
"test": "jest",
|
||||
"test:s3": "SKIP_S3_TESTS=false jest __tests__/integration/backup-s3",
|
||||
"test:pg": "jest __tests__/integration/picpeakRestorePg",
|
||||
"lint": "eslint src/"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -197,18 +197,15 @@ router.post('/picpeak/import', adminAuth, requirePermission('backup.restore'), p
|
||||
// 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).
|
||||
// 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) {
|
||||
|
||||
@@ -23,6 +23,7 @@ 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');
|
||||
|
||||
@@ -105,7 +106,7 @@ function parseNdjson(filePath) {
|
||||
// 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;
|
||||
if (!currentAdmin) return null;
|
||||
|
||||
const emailMatch = await trx('admin_users')
|
||||
.whereRaw('lower(email) = lower(?)', [currentAdmin.email])
|
||||
@@ -135,6 +136,7 @@ async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
if (field in currentAdmin) authUpdate[field] = currentAdmin[field];
|
||||
}
|
||||
await trx('admin_users').where({ id: emailMatch.id }).update(authUpdate);
|
||||
return emailMatch.id;
|
||||
} else {
|
||||
// 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
|
||||
@@ -149,6 +151,84 @@ async function reinjectCurrentAdmin(trx, currentAdmin) {
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -188,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 {
|
||||
@@ -218,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'");
|
||||
@@ -288,6 +371,9 @@ 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 {
|
||||
@@ -316,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();
|
||||
|
||||
@@ -334,4 +429,7 @@ module.exports = {
|
||||
readManifestFromZip,
|
||||
validateManifest,
|
||||
reinjectCurrentAdmin,
|
||||
captureOperatorRole,
|
||||
preserveOperatorRole,
|
||||
resyncSequences,
|
||||
};
|
||||
|
||||
@@ -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