From 1aef45bc060b28a0af45a67dc66acd36aef763c9 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Thu, 26 Feb 2026 13:43:23 +0100 Subject: [PATCH] fix: harden boundary-path canonical alias handling --- CHANGELOG.md | 1 + src/infra/boundary-path.test.ts | 31 +++++++++++++++++++++++++++++++ src/infra/boundary-path.ts | 24 ++++++++++++++++++++---- 3 files changed, 52 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b135204e..32c6fd800 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -41,6 +41,7 @@ Docs: https://docs.openclaw.ai - Docker/GCP onboarding: reduce first-build OOM risk by capping Node heap during `pnpm install`, reuse existing gateway token during `docker-setup.sh` reruns so `.env` stays aligned with config, auto-bootstrap Control UI allowed origins for non-loopback Docker binds, and add GCP docs guidance for tokenized dashboard links + pairing recovery commands. (#26253) Thanks @pandego. - Pairing/Multi-account isolation: keep non-default account pairing allowlists and pending requests strictly account-scoped, while default account continues to use channel-scoped pairing allowlist storage. Thanks @gumadeiras. - Security/Config includes: harden `$include` file loading with verified-open reads, reject hardlinked include aliases, and enforce include file-size guardrails so config include resolution remains bounded to trusted in-root files. This ships in the next npm release (`2026.2.26`). Thanks @zpbrent for reporting. +- Security/Workspace FS boundary aliases: harden canonical boundary resolution for non-existent-leaf symlink aliases while preserving valid in-root aliases, preventing first-write workspace escapes via out-of-root symlink targets. This ships in the next npm release (`2026.2.26`). Thanks @tdjackey for reporting. ## 2026.2.25 diff --git a/src/infra/boundary-path.test.ts b/src/infra/boundary-path.test.ts index caafdc070..a2aefc73c 100644 --- a/src/infra/boundary-path.test.ts +++ b/src/infra/boundary-path.test.ts @@ -117,6 +117,37 @@ describe("resolveBoundaryPath", () => { }); }); + it("allows canonical aliases that still resolve inside root", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const aliasRoot = path.join(base, "workspace-alias"); + const fileName = "plugin.js"; + await fs.mkdir(root, { recursive: true }); + await fs.writeFile(path.join(root, fileName), "export default {}", "utf8"); + await fs.symlink(root, aliasRoot); + + const resolved = await resolveBoundaryPath({ + absolutePath: path.join(aliasRoot, fileName), + rootPath: await fs.realpath(root), + boundaryLabel: "plugin root", + }); + expect(resolved.exists).toBe(true); + expect(isPathInside(resolved.rootCanonicalPath, resolved.canonicalPath)).toBe(true); + + const resolvedSync = resolveBoundaryPathSync({ + absolutePath: path.join(aliasRoot, fileName), + rootPath: await fs.realpath(root), + boundaryLabel: "plugin root", + }); + expect(resolvedSync.exists).toBe(true); + expect(isPathInside(resolvedSync.rootCanonicalPath, resolvedSync.canonicalPath)).toBe(true); + }); + }); + it("maintains containment invariant across randomized alias cases", async () => { if (process.platform === "win32") { return; diff --git a/src/infra/boundary-path.ts b/src/infra/boundary-path.ts index eb5715e8d..9921295f4 100644 --- a/src/infra/boundary-path.ts +++ b/src/infra/boundary-path.ts @@ -53,8 +53,16 @@ export async function resolveBoundaryPath( ? path.resolve(params.rootCanonicalPath) : await resolvePathViaExistingAncestor(rootPath); const lexicalInside = isPathInside(rootPath, absolutePath); + const outsideLexicalCanonicalPath = lexicalInside + ? undefined + : await resolvePathViaExistingAncestor(absolutePath); + const canonicalOutsideLexicalPath = outsideLexicalCanonicalPath ?? absolutePath; - if (!params.skipLexicalRootCheck && !lexicalInside) { + if ( + !params.skipLexicalRootCheck && + !lexicalInside && + !isPathInside(rootCanonicalPath, canonicalOutsideLexicalPath) + ) { throw pathEscapeError({ boundaryLabel: params.boundaryLabel, rootPath, @@ -63,7 +71,7 @@ export async function resolveBoundaryPath( } if (!lexicalInside) { - const canonicalPath = await resolvePathViaExistingAncestor(absolutePath); + const canonicalPath = canonicalOutsideLexicalPath; assertInsideBoundary({ boundaryLabel: params.boundaryLabel, rootCanonicalPath, @@ -97,8 +105,16 @@ export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): Reso ? path.resolve(params.rootCanonicalPath) : resolvePathViaExistingAncestorSync(rootPath); const lexicalInside = isPathInside(rootPath, absolutePath); + const outsideLexicalCanonicalPath = lexicalInside + ? undefined + : resolvePathViaExistingAncestorSync(absolutePath); + const canonicalOutsideLexicalPath = outsideLexicalCanonicalPath ?? absolutePath; - if (!params.skipLexicalRootCheck && !lexicalInside) { + if ( + !params.skipLexicalRootCheck && + !lexicalInside && + !isPathInside(rootCanonicalPath, canonicalOutsideLexicalPath) + ) { throw pathEscapeError({ boundaryLabel: params.boundaryLabel, rootPath, @@ -107,7 +123,7 @@ export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): Reso } if (!lexicalInside) { - const canonicalPath = resolvePathViaExistingAncestorSync(absolutePath); + const canonicalPath = canonicalOutsideLexicalPath; assertInsideBoundary({ boundaryLabel: params.boundaryLabel, rootCanonicalPath,