fix: harden backup verification before restore
This commit is contained in:
@@ -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");
|
||||||
|
|||||||
@@ -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", {
|
||||||
|
|||||||
Reference in New Issue
Block a user