-
Notifications
You must be signed in to change notification settings - Fork 3.1k
fix(snapshot): allow /sandbox/.openclaw-data symlinks in safeTarExtract #2488
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
21c0c17
9f614c5
26391f3
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -279,11 +279,39 @@ function auditExtractedSymlinks(dirPath: string, allowedRoots: string[]): string | |
| const stat = lstatSync(fullPath); | ||
| if (stat.isSymbolicLink()) { | ||
| const linkTarget = readlinkSync(fullPath); | ||
| const resolvedTarget = path.resolve(path.dirname(fullPath), linkTarget); | ||
| const inAnyAllowedRoot = allowedRoots.some((root) => isWithinRoot(resolvedTarget, root)); | ||
|
|
||
| // Resolve relative to the symlink's containing directory (standard). | ||
| const resolvedRelative = path.resolve(path.dirname(fullPath), linkTarget); | ||
|
|
||
| // For absolute symlinks that point into the canonical sandbox data | ||
| // directory (/sandbox/.openclaw-data/** or /sandbox/.hermes-data/**), | ||
| // also check whether the target falls within the extraction root when | ||
| // the leading /sandbox/ prefix is mapped onto the archive root. This | ||
| // mirrors how the symlink resolves once the backup is restored inside | ||
| // the sandbox container (where /sandbox/.openclaw-data/* exists). | ||
| // | ||
| // Only /sandbox/ prefixed targets receive this treatment so that | ||
| // symlinks pointing to arbitrary absolute paths (e.g. /etc/passwd) | ||
| // are still rejected. Fixes #2317. | ||
| const SANDBOX_DATA_PREFIXES = ["/sandbox/.openclaw-data/", "/sandbox/.hermes-data/"]; | ||
| // Normalize the target first to collapse any .. traversal segments | ||
| // (e.g. /sandbox/.openclaw-data/../../etc/passwd → /etc/passwd). | ||
| // Only then check the prefix — this prevents a traversal bypass | ||
| // where a crafted target starts with an allowed prefix but escapes it. | ||
| const normalizedTarget = path.posix.normalize(linkTarget); | ||
| const resolvedInArchive = | ||
| path.isAbsolute(normalizedTarget) && | ||
| SANDBOX_DATA_PREFIXES.some((p) => normalizedTarget.startsWith(p)) | ||
| ? path.resolve(dirPath, normalizedTarget.replace(/^\//, "")) | ||
| : null; | ||
|
|
||
| const inAnyAllowedRoot = | ||
| allowedRoots.some((root) => isWithinRoot(resolvedRelative, root)) || | ||
| (resolvedInArchive !== null && isWithinRoot(resolvedInArchive, dirPath)); | ||
|
|
||
|
Comment on lines
+296
to
+311
|
||
| if (!inAnyAllowedRoot) { | ||
| violations.push( | ||
| `symlink escape: ${fullPath} -> ${linkTarget} (resolves to ${resolvedTarget})`, | ||
| `symlink escape: ${fullPath} -> ${linkTarget} (resolves to ${resolvedRelative})`, | ||
| ); | ||
| } | ||
| } else if (stat.isDirectory()) { | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -385,6 +385,82 @@ describe("Fix: safeTarExtract blocks malicious archives and extracts safe ones", | |
| fs.rmSync(workDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| // Regression #2317: /sandbox/.openclaw-data/* symlinks are created by | ||
| // Dockerfile.base for the .openclaw / .openclaw-data split. When a backup | ||
| // is extracted on the host, these absolute targets don't exist on the host | ||
| // and were falsely rejected as escapes. The fix maps /sandbox/ paths onto | ||
| // the extraction root before checking, matching the sandbox-internal view. | ||
| it("regression #2317: allows known-safe /sandbox/.openclaw-data symlinks in backup archives", async () => { | ||
| const { safeTarExtract } = await loadSandboxState(); | ||
| const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-2317-")); | ||
| try { | ||
| const targetDir = path.join(workDir, "backup"); | ||
| fs.mkdirSync(targetDir, { recursive: true }); | ||
|
|
||
| // Simulate the workspace/media symlink created by Dockerfile.base | ||
| const tar = buildTar([ | ||
| { | ||
| path: "workspace/media", | ||
| type: "2", | ||
| linkTarget: "/sandbox/.openclaw-data/media", | ||
| }, | ||
| ]); | ||
|
|
||
| const result = safeTarExtract(tar, targetDir); | ||
| expect(result.success).toBe(true); | ||
| } finally { | ||
| fs.rmSync(workDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
|
||
| it("regression #2317: still blocks absolute symlinks outside /sandbox/.openclaw-data", async () => { | ||
| const { safeTarExtract } = await loadSandboxState(); | ||
| const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-2317-block-")); | ||
| try { | ||
| const targetDir = path.join(workDir, "backup"); | ||
| fs.mkdirSync(targetDir, { recursive: true }); | ||
|
|
||
| // /etc/passwd should still be rejected — not in /sandbox/.openclaw-data/ | ||
| const tar = buildTar([ | ||
| { | ||
| path: "evil-link", | ||
| type: "2", | ||
| linkTarget: "/etc/passwd", | ||
| }, | ||
| ]); | ||
|
|
||
| const result = safeTarExtract(tar, targetDir); | ||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("symlink"); | ||
| } finally { | ||
| fs.rmSync(workDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
|
Comment on lines
+417
to
+439
|
||
|
|
||
| it("regression #2317: blocks path traversal within allowed prefix (/sandbox/.openclaw-data/../../etc/passwd)", async () => { | ||
| const { safeTarExtract } = await loadSandboxState(); | ||
| const workDir = fs.mkdtempSync(path.join(os.tmpdir(), "nemoclaw-2317-traversal-")); | ||
| try { | ||
| const targetDir = path.join(workDir, "backup"); | ||
| fs.mkdirSync(targetDir, { recursive: true }); | ||
|
|
||
| // Crafted target starts with allowed prefix but traverses out of it | ||
| const tar = buildTar([ | ||
| { | ||
| path: "evil-traversal", | ||
| type: "2", | ||
| linkTarget: "/sandbox/.openclaw-data/../../etc/passwd", | ||
| }, | ||
| ]); | ||
|
|
||
| const result = safeTarExtract(tar, targetDir); | ||
| expect(result.success).toBe(false); | ||
| expect(result.error).toContain("symlink"); | ||
| } finally { | ||
| fs.rmSync(workDir, { recursive: true, force: true }); | ||
| } | ||
| }); | ||
| }); | ||
|
|
||
| describe("Fix: rejectHardLinks blocks hard-link entries at validation time", () => { | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Prefix allowlist is effectively bypassed by generic
/sandboxroot allowance.The new allowlist check can still be bypassed because
inAnyAllowedRootaccepts absolute/sandbox/*via the genericallowedRootsbranch. Also, the archive remap strips only/, not/sandbox/, which does not match the intended mapping semantics.🔧 Proposed fix
🤖 Prompt for AI Agents