fix: harden backup verify path validation

This commit is contained in:
Gustavo Madeira Santana
2026-03-08 16:53:44 -04:00
parent 64dd23eade
commit 09acbe6528
2 changed files with 138 additions and 0 deletions

View File

@@ -167,6 +167,64 @@ describe("backupVerifyCommand", () => {
}
});
it("fails when archive paths contain backslashes", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-backslash-"));
const archivePath = path.join(tempDir, "broken.tar.gz");
const manifestPath = path.join(tempDir, "manifest.json");
const payloadPath = path.join(tempDir, "payload.txt");
try {
const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup";
const invalidPath = `${rootName}/payload\\..\\escaped.txt`;
const manifest = {
schemaVersion: 1,
createdAt: "2026-03-09T00:00:00.000Z",
archiveRoot: rootName,
runtimeVersion: "test",
platform: process.platform,
nodeVersion: process.version,
assets: [
{
kind: "state",
sourcePath: "/tmp/.openclaw",
archivePath: invalidPath,
},
],
};
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
await fs.writeFile(payloadPath, "payload\n", "utf8");
await tar.c(
{
file: archivePath,
gzip: true,
portable: true,
preservePaths: true,
onWriteEntry: (entry) => {
if (entry.path === manifestPath) {
entry.path = `${rootName}/manifest.json`;
return;
}
if (entry.path === payloadPath) {
entry.path = invalidPath;
}
},
},
[manifestPath, payloadPath],
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow(
/forward slashes/i,
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("ignores payload manifest.json files when locating the backup manifest", async () => {
const stateDir = path.join(tempHome.home, ".openclaw");
const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
@@ -271,4 +329,64 @@ describe("backupVerifyCommand", () => {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
it("fails when the archive contains duplicate payload entries", async () => {
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-backup-duplicate-payload-"));
const archivePath = path.join(tempDir, "broken.tar.gz");
const manifestPath = path.join(tempDir, "manifest.json");
const payloadPathA = path.join(tempDir, "payload-a.txt");
const payloadPathB = path.join(tempDir, "payload-b.txt");
try {
const rootName = "2026-03-09T00-00-00.000Z-openclaw-backup";
const payloadArchivePath = `${rootName}/payload/posix/tmp/.openclaw/payload.txt`;
const manifest = {
schemaVersion: 1,
createdAt: "2026-03-09T00:00:00.000Z",
archiveRoot: rootName,
runtimeVersion: "test",
platform: process.platform,
nodeVersion: process.version,
assets: [
{
kind: "state",
sourcePath: "/tmp/.openclaw",
archivePath: payloadArchivePath,
},
],
};
await fs.writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`, "utf8");
await fs.writeFile(payloadPathA, "payload-a\n", "utf8");
await fs.writeFile(payloadPathB, "payload-b\n", "utf8");
await tar.c(
{
file: archivePath,
gzip: true,
portable: true,
preservePaths: true,
onWriteEntry: (entry) => {
if (entry.path === manifestPath) {
entry.path = `${rootName}/manifest.json`;
return;
}
if (entry.path === payloadPathA || entry.path === payloadPathB) {
entry.path = payloadArchivePath;
}
},
},
[manifestPath, payloadPathA, payloadPathB],
);
const runtime = {
log: vi.fn(),
error: vi.fn(),
exit: vi.fn(),
};
await expect(backupVerifyCommand(runtime, { archive: archivePath })).rejects.toThrow(
/duplicate entry path/i,
);
} finally {
await fs.rm(tempDir, { recursive: true, force: true });
}
});
});

View File

@@ -67,6 +67,9 @@ function normalizeArchivePath(entryPath: string, label: string): string {
if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) {
throw new Error(`${label} must be relative: ${entryPath}`);
}
if (trimmed.includes("\\")) {
throw new Error(`${label} must use forward slashes: ${entryPath}`);
}
if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) {
throw new Error(`${label} contains path traversal segments: ${entryPath}`);
}
@@ -260,6 +263,19 @@ function formatResult(result: BackupVerifyResult): string {
].join("\n");
}
function findDuplicateNormalizedEntryPath(
entries: Array<{ normalized: string }>,
): string | undefined {
const seen = new Set<string>();
for (const entry of entries) {
if (seen.has(entry.normalized)) {
return entry.normalized;
}
seen.add(entry.normalized);
}
return undefined;
}
export async function backupVerifyCommand(
runtime: RuntimeEnv,
opts: BackupVerifyOptions,
@@ -280,6 +296,10 @@ export async function backupVerifyCommand(
if (manifestMatches.length !== 1) {
throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`);
}
const duplicateEntryPath = findDuplicateNormalizedEntryPath(entries);
if (duplicateEntryPath) {
throw new Error(`Archive contains duplicate entry path: ${duplicateEntryPath}`);
}
const manifestEntryPath = manifestMatches[0]?.raw;
if (!manifestEntryPath) {
throw new Error("Backup archive manifest entry could not be resolved.");