From cfd41b4fbff7d058572eed42b5f2a47c4a9721b9 Mon Sep 17 00:00:00 2001 From: AI Bot Date: Sat, 6 Jun 2026 19:21:02 +0800 Subject: [PATCH] fix: harden backup verification before restore --- src/lib/boss-state-backups.ts | 41 +++++++++++++++------ tests/admin-backups-route.test.ts | 61 ++++++++++++++++++++++++++++++- 2 files changed, 90 insertions(+), 12 deletions(-) diff --git a/src/lib/boss-state-backups.ts b/src/lib/boss-state-backups.ts index cce2df3..382eba2 100644 --- a/src/lib/boss-state-backups.ts +++ b/src/lib/boss-state-backups.ts @@ -47,7 +47,7 @@ export interface BossStateBackupVerification { message: string; } -export interface BossStateBackupProjection extends BossStateBackupSnapshot { +export interface BossStateBackupProjection extends Omit { scope: BossBackupScope; storageKind: "file"; status: "ready" | "error"; @@ -215,7 +215,8 @@ function buildRestorePreviewSummary(impact: BossStateRestorePreview["impact"]) { function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification { const sha256 = createHash("sha256").update(text).digest("hex"); - const checksumOk = snapshot.sha256 === sha256; + const hasExpectedSha = Boolean(snapshot.sha256); + const checksumOk = hasExpectedSha && snapshot.sha256 === sha256; let stateOk = true; try { parseStateText(text, snapshot.absolutePath); @@ -228,8 +229,10 @@ function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): Bo checkedAt: new Date().toISOString(), ok: checksumOk && stateOk, sha256, - expectedSha256: snapshot.sha256, - message: checksumOk && stateOk + expectedSha256: hasExpectedSha ? snapshot.sha256 : undefined, + message: !hasExpectedSha + ? "snapshot metadata is missing" + : checksumOk && stateOk ? "checksum verified" : stateOk ? "checksum mismatch" @@ -255,7 +258,7 @@ async function loadBossStateBackupSnapshot(snapshotId: string): Promise { - const snapshot = await loadBossStateBackupSnapshot(input.snapshotId); - const snapshotState = parseStateText(await readSnapshotText(input.snapshotId), snapshot.absolutePath); + const { snapshot, text } = await assertVerifiedBossStateBackup(input.snapshotId); + const snapshotState = parseStateText(text, snapshot.absolutePath); const currentSummary = summarizeBusinessState(await readState()); const afterSummary = summarizeBusinessState(snapshotState); const impact = { @@ -429,9 +449,8 @@ export async function restoreBossStateBackup(input: { restored: BossStateBackupSnapshot; preRestoreSnapshot: BossStateBackupSnapshot; }> { - const absolutePath = snapshotPath(input.snapshotId); - const text = await fs.readFile(absolutePath, "utf8"); - const parsed = parseStateText(text, absolutePath); + const { snapshot, text } = await assertVerifiedBossStateBackup(input.snapshotId); + const parsed = parseStateText(text, snapshot.absolutePath); const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId); if (!restored) { throw new Error("BACKUP_SNAPSHOT_NOT_FOUND"); diff --git a/tests/admin-backups-route.test.ts b/tests/admin-backups-route.test.ts index ef227cf..09e7f3e 100644 --- a/tests/admin-backups-route.test.ts +++ b/tests/admin-backups-route.test.ts @@ -2,7 +2,7 @@ import test from "node:test"; import assert from "node:assert/strict"; import os from "node:os"; import path from "node:path"; -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdtemp, readFile, rm, unlink, writeFile } from "node:fs/promises"; import { NextRequest } from "next/server"; let runtimeRoot = ""; @@ -125,6 +125,7 @@ function assertBackupProjection(snapshot: { assert.equal(snapshot.verification?.ok, true); assert.equal(typeof snapshot.businessSummary?.projectCount, "number"); assert.equal(typeof snapshot.businessSummary?.accountCount, "number"); + assert.equal("absolutePath" in snapshot, false); } test("admin backups require highest admin", async () => { @@ -208,6 +209,64 @@ test("highest admin can verify a backup snapshot checksum", async () => { assert.equal(verifyPayload.verification.sha256, createPayload.snapshot.sha256); }); +test("backup actions reject snapshots with missing or mismatched metadata", async () => { + const createResponse = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "tamper test" }), + }), + ); + assert.equal(createResponse.status, 200); + const createPayload = await createResponse.json(); + const snapshotId = createPayload.snapshot.snapshotId as string; + const snapshotPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${snapshotId}.json`); + const metaPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${snapshotId}.meta.json`); + + await unlink(metaPath); + const missingMetaVerify = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "verify_snapshot", snapshotId }), + }), + ); + assert.equal(missingMetaVerify.status, 200); + const missingMetaPayload = await missingMetaVerify.json(); + assert.equal(missingMetaPayload.verification.ok, false); + assert.equal(missingMetaPayload.verification.message, "snapshot metadata is missing"); + + const missingMetaPreview = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "preview_restore", snapshotId }), + }), + ); + assert.equal(missingMetaPreview.status, 400); + assert.equal((await missingMetaPreview.json()).message, "BACKUP_SNAPSHOT_VERIFICATION_FAILED"); + + const repaired = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "create_snapshot", reason: "tamper mismatch" }), + }), + ); + const repairedPayload = await repaired.json(); + const mismatchId = repairedPayload.snapshot.snapshotId as string; + const mismatchPath = path.join(process.env.BOSS_STATE_BACKUP_DIR!, `${mismatchId}.json`); + await writeFile(mismatchPath, (await readFile(mismatchPath, "utf8")).replace("备份前项目", "被篡改项目"), "utf8"); + + const mismatchRestore = await postBackups( + await requestFor("owner@boss.com", "highest_admin", { + method: "POST", + body: JSON.stringify({ action: "restore_snapshot", snapshotId: mismatchId }), + }), + ); + assert.equal(mismatchRestore.status, 400); + assert.equal((await mismatchRestore.json()).message, "BACKUP_SNAPSHOT_VERIFICATION_FAILED"); + + // Keep the local variable used so the test clearly documents both paths under the same backup dir. + assert.match(snapshotPath, /state-snapshot-/); +}); + test("restore preview and dry run report impact without writing state", async () => { const createResponse = await postBackups( await requestFor("owner@boss.com", "highest_admin", {