From 22689b9dc93175c7db6843bd3d1cee169d62ed68 Mon Sep 17 00:00:00 2001 From: Brian Mendonca Date: Tue, 24 Feb 2026 14:26:17 -0700 Subject: [PATCH] fix(sandbox): reject hardlinked tmp media aliases --- src/agents/sandbox-paths.test.ts | 76 ++++++++++++++++++++++++++++++++ src/agents/sandbox-paths.ts | 21 +++++++++ 2 files changed, 97 insertions(+) diff --git a/src/agents/sandbox-paths.test.ts b/src/agents/sandbox-paths.test.ts index fd6998994b2..e25fd2b3a89 100644 --- a/src/agents/sandbox-paths.test.ts +++ b/src/agents/sandbox-paths.test.ts @@ -150,6 +150,82 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("rejects hardlinked OpenClaw tmp paths to outside files", async () => { + if (process.platform === "win32") { + return; + } + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-hardlink-outside-"), + ); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + try { + if (isPathInside(openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(hardlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + } finally { + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + + it("rejects symlinked OpenClaw tmp paths to hardlinked outside files", async () => { + if (process.platform === "win32") { + return; + } + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-hardlink-outside-"), + ); + const outsideFile = path.join(outsideDir, "outside-secret.txt"); + const hardlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-target-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + const symlinkPath = path.join( + openClawTmpDir, + `sandbox-media-hardlink-symlink-${Date.now()}-${Math.random().toString(16).slice(2)}.txt`, + ); + try { + if (isPathInside(openClawTmpDir, outsideFile)) { + return; + } + await fs.writeFile(outsideFile, "secret", "utf8"); + await fs.mkdir(openClawTmpDir, { recursive: true }); + try { + await fs.link(outsideFile, hardlinkPath); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === "EXDEV") { + return; + } + throw err; + } + await fs.symlink(hardlinkPath, symlinkPath); + await withSandboxRoot(async (sandboxDir) => { + await expectSandboxRejection(symlinkPath, sandboxDir, /hard.?link|sandbox/i); + }); + } finally { + await fs.rm(symlinkPath, { force: true }); + await fs.rm(hardlinkPath, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + // Group 4: Passthrough it("passes HTTP URLs through unchanged", async () => { const result = await resolveSandboxedMediaSource({ diff --git a/src/agents/sandbox-paths.ts b/src/agents/sandbox-paths.ts index f5ae24ac16a..04f0230750c 100644 --- a/src/agents/sandbox-paths.ts +++ b/src/agents/sandbox-paths.ts @@ -187,9 +187,30 @@ async function resolveAllowedTmpMediaPath(params: { return undefined; } await assertNoSymlinkEscape(path.relative(openClawTmpDir, resolved), openClawTmpDir); + await assertNoHardlinkedFinalPath(resolved, openClawTmpDir); return resolved; } +async function assertNoHardlinkedFinalPath(filePath: string, root: string): Promise { + let stat: Awaited>; + try { + stat = await fs.stat(filePath); + } catch (err) { + if (isNotFoundPathError(err)) { + return; + } + throw err; + } + if (!stat.isFile()) { + return; + } + if (stat.nlink > 1) { + throw new Error( + `Hardlinked tmp media path is not allowed under sandbox root (${shortPath(root)}): ${shortPath(filePath)}`, + ); + } +} + async function assertNoSymlinkEscape( relative: string, root: string,