diff --git a/scripts/package-openclaw-for-docker.mjs b/scripts/package-openclaw-for-docker.mjs index 7a23a072517e..0a6d52c88463 100644 --- a/scripts/package-openclaw-for-docker.mjs +++ b/scripts/package-openclaw-for-docker.mjs @@ -84,6 +84,28 @@ function validateOutputName(value) { } } +function resolvePackedOpenClawFileName(value) { + const filename = value.trim(); + if ( + !filename.endsWith(".tgz") || + (!filename.startsWith("openclaw-") && + !filename.includes(":") && + !filename.includes("/") && + !filename.includes("\\")) + ) { + return ""; + } + if ( + !/^openclaw-[A-Za-z0-9._-]+\.tgz$/u.test(filename) || + filename.includes("\0") || + filename !== path.basename(filename) || + filename !== path.win32.basename(filename) + ) { + throw new Error(`npm pack reported unsafe OpenClaw tarball filename: ${filename}`); + } + return filename; +} + export function parseArgs(argv) { const options = { outputDir: "", @@ -283,9 +305,9 @@ async function runCapture(command, args, cwd, options = {}) { async function newestOpenClawTarball(outputDir, packOutput) { let fromOutput = ""; for (const line of packOutput.split(/\r?\n/u)) { - const trimmed = line.trim(); - if (/^openclaw-.*\.tgz$/u.test(trimmed)) { - fromOutput = trimmed; + const filename = resolvePackedOpenClawFileName(line); + if (filename) { + fromOutput = filename; } } if (fromOutput) { @@ -294,7 +316,13 @@ async function newestOpenClawTarball(outputDir, packOutput) { const entries = await fs.readdir(outputDir); const packed = entries - .filter((entry) => /^openclaw-.*\.tgz$/u.test(entry)) + .filter((entry) => { + try { + return resolvePackedOpenClawFileName(entry) === entry; + } catch { + return false; + } + }) .toSorted() .at(-1); if (!packed) { diff --git a/test/scripts/package-openclaw-for-docker.test.ts b/test/scripts/package-openclaw-for-docker.test.ts index bec7015c84d9..037453ed2a0b 100644 --- a/test/scripts/package-openclaw-for-docker.test.ts +++ b/test/scripts/package-openclaw-for-docker.test.ts @@ -215,6 +215,51 @@ describe("package-openclaw-for-docker", () => { ]); }); + it("rejects path-like npm pack stdout before resolving Docker package tarballs", async () => { + for (const filename of [ + "../openclaw-2026.6.17.tgz", + "/tmp/openclaw-2026.6.17.tgz", + String.raw`C:\temp\openclaw-2026.6.17.tgz`, + "openclaw-nested/evil.tgz", + String.raw`openclaw-nested\evil.tgz`, + "openclaw-C:evil.tgz", + ]) { + await expect( + packOpenClawPackageForDocker("/repo", "/out", { + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async () => `${filename}\n`, + }), + ).rejects.toThrow("npm pack reported unsafe OpenClaw tarball filename"); + } + }); + + it("ignores unsafe output directory tarball names when npm stdout is not usable", async () => { + const outputDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-docker-pack-")); + try { + fs.writeFileSync(path.join(outputDir, "openclaw-C:evil.tgz"), ""); + fs.writeFileSync(path.join(outputDir, String.raw`openclaw-nested\evil.tgz`), ""); + await expect( + packOpenClawPackageForDocker("/repo", outputDir, { + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async () => "npm notice\n", + }), + ).rejects.toThrow("missing packed OpenClaw tarball"); + + fs.writeFileSync(path.join(outputDir, "openclaw-2026.6.17.tgz"), ""); + await expect( + packOpenClawPackageForDocker("/repo", outputDir, { + prepareChangelog: async () => {}, + restoreChangelog: async () => {}, + runCaptureImpl: async () => "npm notice\n", + }), + ).resolves.toBe(path.join(outputDir, "openclaw-2026.6.17.tgz")); + } finally { + fs.rmSync(outputDir, { recursive: true, force: true }); + } + }); + it("restores the changelog when ignore-scripts packaging fails", async () => { const calls: string[] = [];