fix: harden backup verification before restore
This commit is contained in:
@@ -47,7 +47,7 @@ export interface BossStateBackupVerification {
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface BossStateBackupProjection extends BossStateBackupSnapshot {
|
||||
export interface BossStateBackupProjection extends Omit<BossStateBackupSnapshot, "absolutePath"> {
|
||||
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<BossStat
|
||||
fileName: `${snapshotId}.json`,
|
||||
absolutePath,
|
||||
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(),
|
||||
actorAccount: typeof meta.actorAccount === "string" ? meta.actorAccount : 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: {
|
||||
actorAccount: string;
|
||||
reason?: string;
|
||||
@@ -319,7 +332,14 @@ export async function projectBossStateBackupSnapshot(snapshot: BossStateBackupSn
|
||||
}
|
||||
|
||||
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",
|
||||
storageKind: "file",
|
||||
status: verification.ok ? "ready" : "error",
|
||||
@@ -398,8 +418,8 @@ export async function previewBossStateRestore(input: {
|
||||
snapshotId: string;
|
||||
willWriteState?: boolean;
|
||||
}): Promise<BossStateRestorePreview> {
|
||||
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");
|
||||
|
||||
@@ -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", {
|
||||
|
||||
Reference in New Issue
Block a user