mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-08 22:38:26 +00:00
fix: harden backup verify path validation
This commit is contained in:
@@ -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 () => {
|
it("ignores payload manifest.json files when locating the backup manifest", async () => {
|
||||||
const stateDir = path.join(tempHome.home, ".openclaw");
|
const stateDir = path.join(tempHome.home, ".openclaw");
|
||||||
const externalWorkspace = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-workspace-"));
|
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 });
|
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 });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -67,6 +67,9 @@ function normalizeArchivePath(entryPath: string, label: string): string {
|
|||||||
if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) {
|
if (trimmed.startsWith("/") || WINDOWS_ABSOLUTE_ARCHIVE_PATH_RE.test(trimmed)) {
|
||||||
throw new Error(`${label} must be relative: ${entryPath}`);
|
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 === "..")) {
|
if (trimmed.split("/").some((segment) => segment === "." || segment === "..")) {
|
||||||
throw new Error(`${label} contains path traversal segments: ${entryPath}`);
|
throw new Error(`${label} contains path traversal segments: ${entryPath}`);
|
||||||
}
|
}
|
||||||
@@ -260,6 +263,19 @@ function formatResult(result: BackupVerifyResult): string {
|
|||||||
].join("\n");
|
].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(
|
export async function backupVerifyCommand(
|
||||||
runtime: RuntimeEnv,
|
runtime: RuntimeEnv,
|
||||||
opts: BackupVerifyOptions,
|
opts: BackupVerifyOptions,
|
||||||
@@ -280,6 +296,10 @@ export async function backupVerifyCommand(
|
|||||||
if (manifestMatches.length !== 1) {
|
if (manifestMatches.length !== 1) {
|
||||||
throw new Error(`Expected exactly one backup manifest entry, found ${manifestMatches.length}.`);
|
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;
|
const manifestEntryPath = manifestMatches[0]?.raw;
|
||||||
if (!manifestEntryPath) {
|
if (!manifestEntryPath) {
|
||||||
throw new Error("Backup archive manifest entry could not be resolved.");
|
throw new Error("Backup archive manifest entry could not be resolved.");
|
||||||
|
|||||||
Reference in New Issue
Block a user