diff --git a/src/providers/alibaba-region-backup.ts b/src/providers/alibaba-region-backup.ts index d200e3df1..609025058 100644 --- a/src/providers/alibaba-region-backup.ts +++ b/src/providers/alibaba-region-backup.ts @@ -1,10 +1,12 @@ -import { copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs"; +import { chmodSync, copyFileSync, existsSync, linkSync, readFileSync, rmSync } from "node:fs"; import { getConfigPath } from "../config"; +import { hardenSecretPath } from "../lib/windows-secret-acl"; export interface AlibabaBackupIO { exists: (path: string) => boolean; read: (path: string) => Buffer; copy: (source: string, destination: string) => void; + harden: (path: string) => void; /** Publish with no-replace semantics: fails with EEXIST if the destination exists. */ publishNoReplace: (temp: string, destination: string) => void; remove: (path: string) => void; @@ -14,6 +16,10 @@ const DEFAULT_IO: AlibabaBackupIO = { exists: existsSync, read: path => readFileSync(path), copy: (source, destination) => copyFileSync(source, destination), + harden: path => { + try { chmodSync(path, 0o600); } catch { /* platform may ignore chmod */ } + if (process.platform === "win32") hardenSecretPath(path, { required: true }); + }, publishNoReplace: linkSync, remove: path => rmSync(path, { force: true }), }; @@ -57,6 +63,9 @@ export function backupConfigBeforeAlibabaRegionMigration( const temp = `${backup}.${process.pid}.tmp`; try { io.copy(configPath, temp); + // The snapshot contains credentials. Harden it before publication so the + // stable backup path is never exposed with inherited permissions or ACLs. + io.harden(temp); // Verify before publishing: a short copy must never become the snapshot. if (!io.read(temp).equals(source)) { throw new AlibabaBackupIntegrityError(`failed to write a complete backup to ${temp}`); diff --git a/tests/alibaba-region-backup.test.ts b/tests/alibaba-region-backup.test.ts index 6c49478c3..52a02b3ca 100644 --- a/tests/alibaba-region-backup.test.ts +++ b/tests/alibaba-region-backup.test.ts @@ -1,5 +1,5 @@ import { expect, test } from "bun:test"; -import { existsSync, linkSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { existsSync, linkSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { @@ -22,6 +22,7 @@ test("creates a snapshot, then never replaces it", () => { writeFileSync(configPath, '{"before":true}', "utf8"); expect(backupConfigBeforeAlibabaRegionMigration(configPath)).toBe("created"); expect(readFileSync(backupPath, "utf8")).toBe('{"before":true}'); + if (process.platform !== "win32") expect(statSync(backupPath).mode & 0o777).toBe(0o600); expect(backupConfigBeforeAlibabaRegionMigration(configPath)).toBe("reused"); expect(readFileSync(backupPath, "utf8")).toBe('{"before":true}'); } finally { rmSync(dir, { recursive: true, force: true }); } @@ -52,6 +53,7 @@ test("a short copy is never published", () => { exists: existsSync, read: path => readFileSync(path), copy: (_source, destination) => { writeFileSync(destination, '{"bef', "utf8"); }, + harden: () => {}, publishNoReplace: linkSync, remove: path => rmSync(path, { force: true }), })).toThrow(AlibabaBackupIntegrityError); @@ -69,6 +71,7 @@ test("a failed copy leaves no snapshot and no temp file", () => { exists: existsSync, read: path => readFileSync(path), copy: () => { throw new Error("disk full"); }, + harden: () => {}, publishNoReplace: linkSync, remove: path => { removed.push(path); rmSync(path, { force: true }); }, })).toThrow("disk full"); @@ -76,3 +79,22 @@ test("a failed copy leaves no snapshot and no temp file", () => { expect(removed).toHaveLength(1); } finally { rmSync(dir, { recursive: true, force: true }); } }); + +test("a failed harden never publishes the secret-bearing snapshot", () => { + const dir = mkdtempSync(join(tmpdir(), "ocx-bak-")); + const configPath = join(dir, "config.json"); + const backupPath = `${configPath}.pre-alibaba-region-v1.bak`; + try { + writeFileSync(configPath, '{"before":true}', "utf8"); + expect(() => backupConfigBeforeAlibabaRegionMigration(configPath, { + exists: existsSync, + read: path => readFileSync(path), + copy: (source, destination) => writeFileSync(destination, readFileSync(source)), + harden: () => { throw new Error("ACL hardening failed"); }, + publishNoReplace: linkSync, + remove: path => rmSync(path, { force: true }), + })).toThrow("ACL hardening failed"); + expect(existsSync(backupPath)).toBe(false); + expect(existsSync(`${backupPath}.${process.pid}.tmp`)).toBe(false); + } finally { rmSync(dir, { recursive: true, force: true }); } +});