fix(backup): seal backups and require step-up auth to restore
Backups carried every password hash, encrypted MFA secret and webhook secret in plain JSON, and an import upserted arbitrary rows - including users - so anyone holding backup:import, or tricking an admin into importing a file, could overwrite the admin account with a known password. - Export (download and scheduled) is AES-256-GCM over the gzipped bundle, key from BACKUP_ENCRYPTION_KEY or derived from JWT_ACCESS_SECRET via HKDF; header is authenticated, decompression is capped. Scheduled files are written 0600 in a 0700 directory; run-now returns only the file name. - Import requires the current password (and TOTP when MFA is on). - Only a sealed file restores users, roles, grants, webhooks and audit entries; an old unsealed file restores business data only and reports what it skipped. - Audit entries are append-only on import. - Imported documents: entityType checked against the enum, MIME type allowlisted and matched against the file signature, path kept under the storage root (no more directory traversal via entityType). - Permission cache is cleared after an import. Co-Authored-By: Claude Opus 5.5 (1M context) <[email protected]>
This commit is contained in:
1 parent
a25265b88a
commit
bd592adf66
15 files changed
+636
-38
No files matched your search
@@ -2,6 +2,7 @@ import { BadRequestException } from '@nestjs/common';
|
||||
import { BackupService } from './backup.service';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import { AuditLogService } from '../audit/audit-log.service';
|
||||
import { PermissionResolverService } from '../auth/permissions/permission-resolver.service';
|
||||
|
||||
jest.mock('node:fs/promises', () => ({
|
||||
readFile: jest.fn(),
|
||||
@@ -69,6 +70,8 @@ describe('BackupService', () => {
|
||||
user: { upsert: jest.Mock; findMany: jest.Mock };
|
||||
};
|
||||
let auditLog: { record: jest.Mock };
|
||||
let permissionResolver: { invalidateAll: jest.Mock };
|
||||
const TRUSTED = { trusted: true };
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
@@ -92,6 +95,9 @@ describe('BackupService', () => {
|
||||
findUnique: jest.fn().mockResolvedValue({ id: 'permission-1' }),
|
||||
};
|
||||
tx.user.findMany = jest.fn().mockResolvedValue([{ id: 'known-user' }]);
|
||||
(tx.role as unknown as { findMany: jest.Mock }).findMany = jest
|
||||
.fn()
|
||||
.mockResolvedValue([]);
|
||||
|
||||
prisma = {
|
||||
user: {
|
||||
@@ -114,9 +120,11 @@ describe('BackupService', () => {
|
||||
|
||||
auditLog = { record: jest.fn().mockResolvedValue(undefined) };
|
||||
|
||||
permissionResolver = { invalidateAll: jest.fn() };
|
||||
service = new BackupService(
|
||||
prisma as unknown as PrismaService,
|
||||
auditLog as unknown as AuditLogService,
|
||||
permissionResolver as unknown as PermissionResolverService,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -198,7 +206,7 @@ describe('BackupService', () => {
|
||||
describe('importAll', () => {
|
||||
it('rejects a bundle with no data object', async () => {
|
||||
await expect(
|
||||
service.importAll({ version: 2 } as never, 'admin-1'),
|
||||
service.importAll({ version: 2 } as never, 'admin-1', TRUSTED),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(prisma.$transaction).not.toHaveBeenCalled();
|
||||
});
|
||||
@@ -211,6 +219,7 @@ describe('BackupService', () => {
|
||||
data: { role: [{ id: 'source-id', key: 'ADMIN', name: 'Admin' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.role.upsert.mock.calls as [
|
||||
@@ -233,6 +242,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.user.upsert.mock.calls as [
|
||||
@@ -252,6 +262,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
});
|
||||
@@ -275,6 +286,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.rolePermission.upsert).toHaveBeenCalledTimes(1);
|
||||
@@ -293,6 +305,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(tx.queen.upsert).toHaveBeenCalledTimes(2);
|
||||
@@ -326,6 +339,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -352,6 +366,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.inspection.upsert.mock.calls as [
|
||||
@@ -372,6 +387,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.stockMovement.upsert.mock.calls as [
|
||||
@@ -396,6 +412,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
const [[call]] = tx.automationRule.upsert.mock.calls as [
|
||||
@@ -422,14 +439,14 @@ describe('BackupService', () => {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-1',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: 'known-user',
|
||||
contentBase64: Buffer.from('hello').toString('base64'),
|
||||
contentBase64: Buffer.from('%PDF-1.4 hello').toString('base64'),
|
||||
},
|
||||
{
|
||||
id: 'doc-2',
|
||||
entityType: 'Colony',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
uploadedByUserId: null,
|
||||
contentBase64: null,
|
||||
@@ -438,6 +455,7 @@ describe('BackupService', () => {
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(fs.writeFile).toHaveBeenCalledTimes(1);
|
||||
@@ -457,6 +475,7 @@ describe('BackupService', () => {
|
||||
data: { apiary: [{ id: 'apiary-1', name: 'Stand Wald' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
|
||||
expect(auditLog.record).toHaveBeenCalledWith(
|
||||
@@ -464,9 +483,111 @@ describe('BackupService', () => {
|
||||
entityType: 'Backup',
|
||||
action: 'CREATE',
|
||||
userId: 'admin-1',
|
||||
afterData: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
afterData: expect.objectContaining({
|
||||
imported: expect.objectContaining({ apiary: 1 }) as unknown,
|
||||
}) as unknown,
|
||||
}),
|
||||
);
|
||||
expect(permissionResolver.invalidateAll).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose entityType would leave the storage root', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-evil',
|
||||
entityType: '../../app/dist',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('%PDF-1.4').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.mkdir).not.toHaveBeenCalled();
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('refuses a document whose bytes do not match its declared type', async () => {
|
||||
await expect(
|
||||
service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
document: [
|
||||
{
|
||||
id: 'doc-html',
|
||||
entityType: 'COLONY',
|
||||
mimeType: 'application/pdf',
|
||||
contentBase64: Buffer.from('<script>').toString('base64'),
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
),
|
||||
).rejects.toBeInstanceOf(BadRequestException);
|
||||
expect(fs.writeFile).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('never rewrites audit entries that already exist', async () => {
|
||||
await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: { auditLog: [{ id: 'audit-1', action: 'LOGIN' }] },
|
||||
},
|
||||
'admin-1',
|
||||
TRUSTED,
|
||||
);
|
||||
const [[call]] = tx.auditLog.upsert.mock.calls as [
|
||||
[{ update: Record<string, unknown> }],
|
||||
];
|
||||
expect(call.update).toEqual({});
|
||||
});
|
||||
|
||||
it('takes no users, roles, webhooks or audit entries from an unsealed file', async () => {
|
||||
const summary = await service.importAll(
|
||||
{
|
||||
version: 2,
|
||||
exportedAt: '2026-01-01T00:00:00.000Z',
|
||||
data: {
|
||||
role: [{ key: 'ADMIN', name: 'Admin' }],
|
||||
user: [
|
||||
{
|
||||
id: 'admin-victim',
|
||||
email: '[email protected]',
|
||||
passwordHash: 'attacker-hash',
|
||||
roleKey: 'ADMIN',
|
||||
},
|
||||
],
|
||||
webhookSubscription: [{ id: 'hook-1', url: 'https://evil' }],
|
||||
auditLog: [{ id: 'audit-1' }],
|
||||
apiary: [{ id: 'apiary-1', name: 'Stand Wald' }],
|
||||
},
|
||||
},
|
||||
'admin-1',
|
||||
{ trusted: false },
|
||||
);
|
||||
|
||||
expect(tx.role.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.user.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.webhookSubscription.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.auditLog.upsert).not.toHaveBeenCalled();
|
||||
expect(tx.apiary.upsert).toHaveBeenCalledTimes(1);
|
||||
expect(summary.skipped).toEqual(
|
||||
expect.arrayContaining(['role', 'user', 'webhookSubscription']),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user