fix: harden backup verification before restore

This commit is contained in:
AI Bot
2026-06-06 19:21:02 +08:00
parent 58cc4a1a5a
commit cfd41b4fbf
2 changed files with 90 additions and 12 deletions

View File

@@ -47,7 +47,7 @@ export interface BossStateBackupVerification {
message: string; message: string;
} }
export interface BossStateBackupProjection extends BossStateBackupSnapshot { export interface BossStateBackupProjection extends Omit<BossStateBackupSnapshot, "absolutePath"> {
scope: BossBackupScope; scope: BossBackupScope;
storageKind: "file"; storageKind: "file";
status: "ready" | "error"; status: "ready" | "error";
@@ -215,7 +215,8 @@ function buildRestorePreviewSummary(impact: BossStateRestorePreview["impact"]) {
function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification { function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): BossStateBackupVerification {
const sha256 = createHash("sha256").update(text).digest("hex"); 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; let stateOk = true;
try { try {
parseStateText(text, snapshot.absolutePath); parseStateText(text, snapshot.absolutePath);
@@ -228,8 +229,10 @@ function verifySnapshotText(snapshot: BossStateBackupSnapshot, text: string): Bo
checkedAt: new Date().toISOString(), checkedAt: new Date().toISOString(),
ok: checksumOk && stateOk, ok: checksumOk && stateOk,
sha256, sha256,
expectedSha256: snapshot.sha256, expectedSha256: hasExpectedSha ? snapshot.sha256 : undefined,
message: checksumOk && stateOk message: !hasExpectedSha
? "snapshot metadata is missing"
: checksumOk && stateOk
? "checksum verified" ? "checksum verified"
: stateOk : stateOk
? "checksum mismatch" ? "checksum mismatch"
@@ -255,7 +258,7 @@ async function loadBossStateBackupSnapshot(snapshotId: string): Promise<BossStat
fileName: `${snapshotId}.json`, fileName: `${snapshotId}.json`,
absolutePath, absolutePath,
bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size, bytes: typeof meta.bytes === "number" ? meta.bytes : stat.size,
sha256: typeof meta.sha256 === "string" ? meta.sha256 : createHash("sha256").update(text).digest("hex"), sha256: typeof meta.sha256 === "string" ? meta.sha256 : "",
createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(), createdAt: typeof meta.createdAt === "string" ? meta.createdAt : stat.mtime.toISOString(),
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined, actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : undefined,
reason: typeof meta.reason === "string" ? meta.reason : undefined, reason: typeof meta.reason === "string" ? meta.reason : undefined,
@@ -263,6 +266,16 @@ async function loadBossStateBackupSnapshot(snapshotId: string): Promise<BossStat
}; };
} }
async function assertVerifiedBossStateBackup(snapshotId: string) {
const snapshot = await loadBossStateBackupSnapshot(snapshotId);
const text = await readSnapshotText(snapshotId);
const verification = verifySnapshotText(snapshot, text);
if (!verification.ok) {
throw new Error("BACKUP_SNAPSHOT_VERIFICATION_FAILED");
}
return { snapshot, text, verification };
}
export async function createBossStateBackup(input: { export async function createBossStateBackup(input: {
actorAccount: string; actorAccount: string;
reason?: string; reason?: string;
@@ -319,7 +332,14 @@ export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSn
} }
return { return {
...snapshot, snapshotId: snapshot.snapshotId,
fileName: snapshot.fileName,
bytes: snapshot.bytes,
sha256: snapshot.sha256,
createdAt: snapshot.createdAt,
actorAccount: snapshot.actorAccount,
reason: snapshot.reason,
schemaVersion: snapshot.schemaVersion,
scope: "global", scope: "global",
storageKind: "file", storageKind: "file",
status: verification.ok ? "ready" : "error", status: verification.ok ? "ready" : "error",
@@ -398,8 +418,8 @@ export async function previewBossStateRestore(input: {
snapshotId: string; snapshotId: string;
willWriteState?: boolean; willWriteState?: boolean;
}): Promise<BossStateRestorePreview> { }): Promise<BossStateRestorePreview> {
const snapshot = await loadBossStateBackupSnapshot(input.snapshotId); const { snapshot, text } = await assertVerifiedBossStateBackup(input.snapshotId);
const snapshotState = parseStateText(await readSnapshotText(input.snapshotId), snapshot.absolutePath); const snapshotState = parseStateText(text, snapshot.absolutePath);
const currentSummary = summarizeBusinessState(await readState()); const currentSummary = summarizeBusinessState(await readState());
const afterSummary = summarizeBusinessState(snapshotState); const afterSummary = summarizeBusinessState(snapshotState);
const impact = { const impact = {
@@ -429,9 +449,8 @@ export async function restoreBossStateBackup(input: {
restored: BossStateBackupSnapshot; restored: BossStateBackupSnapshot;
preRestoreSnapshot: BossStateBackupSnapshot; preRestoreSnapshot: BossStateBackupSnapshot;
}> { }> {
const absolutePath = snapshotPath(input.snapshotId); const { snapshot, text } = await assertVerifiedBossStateBackup(input.snapshotId);
const text = await fs.readFile(absolutePath, "utf8"); const parsed = parseStateText(text, snapshot.absolutePath);
const parsed = parseStateText(text, absolutePath);
const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId); const restored = (await listBossStateBackups(100)).find((snapshot) => snapshot.snapshotId === input.snapshotId);
if (!restored) { if (!restored) {
throw new Error("BACKUP_SNAPSHOT_NOT_FOUND"); throw new Error("BACKUP_SNAPSHOT_NOT_FOUND");

View File

@@ -2,7 +2,7 @@ import test from "node:test";
import assert from "node:assert/strict"; import assert from "node:assert/strict";
import os from "node:os"; import os from "node:os";
import path from "node:path"; 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"; import { NextRequest } from "next/server";
let runtimeRoot = ""; let runtimeRoot = "";
@@ -125,6 +125,7 @@ function assertBackupProjection(snapshot: {
assert.equal(snapshot.verification?.ok, true); assert.equal(snapshot.verification?.ok, true);
assert.equal(typeof snapshot.businessSummary?.projectCount, "number"); assert.equal(typeof snapshot.businessSummary?.projectCount, "number");
assert.equal(typeof snapshot.businessSummary?.accountCount, "number"); assert.equal(typeof snapshot.businessSummary?.accountCount, "number");
assert.equal("absolutePath" in snapshot, false);
} }
test("admin backups require highest admin", async () => { 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); 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 () => { test("restore preview and dry run report impact without writing state", async () => {
const createResponse = await postBackups( const createResponse = await postBackups(
await requestFor("owner@boss.com", "highest_admin", { await requestFor("owner@boss.com", "highest_admin", {