OpenClaw < 2026.2.26 - Workspace Path Boundary Bypass via Non-existent Symlink
Description
OpenClaw versions prior to 2026.2.26 contain a path traversal vulnerability in workspace boundary validation that allows attackers to write files outside the workspace through in-workspace symlinks pointing to non-existent out-of-root targets. The vulnerability exists because the boundary check improperly resolves aliases, permitting the first write operation to escape the workspace boundary and create files in arbitrary locations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
openclawnpm | < 2026.2.26 | 2026.2.26 |
Affected products
1Patches
21aef45bc060bfix: harden boundary-path canonical alias handling
3 files changed · +54 −6
CHANGELOG.md+1 −0 modified@@ -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
src/infra/boundary-path.test.ts+31 −0 modified@@ -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;
src/infra/boundary-path.ts+22 −6 modified@@ -53,8 +53,16 @@ export async function resolveBoundaryPath( ? path.resolve(params.rootCanonicalPath) : await resolvePathViaExistingAncestor(rootPath); const lexicalInside = isPathInside(rootPath, absolutePath); - - if (!params.skipLexicalRootCheck && !lexicalInside) { + const outsideLexicalCanonicalPath = lexicalInside + ? undefined + : await resolvePathViaExistingAncestor(absolutePath); + const canonicalOutsideLexicalPath = outsideLexicalCanonicalPath ?? absolutePath; + + 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); - - if (!params.skipLexicalRootCheck && !lexicalInside) { + const outsideLexicalCanonicalPath = lexicalInside + ? undefined + : resolvePathViaExistingAncestorSync(absolutePath); + const canonicalOutsideLexicalPath = outsideLexicalCanonicalPath ?? absolutePath; + + 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,
46eba86b45e9fix: harden workspace boundary path resolution
8 files changed · +767 −177
src/agents/sandbox/host-paths.ts+2 −28 modified@@ -1,5 +1,5 @@ -import { existsSync, realpathSync } from "node:fs"; import { posix } from "node:path"; +import { resolvePathViaExistingAncestorSync } from "../../infra/boundary-path.js"; /** * Normalize a POSIX host path: resolve `.`, `..`, collapse `//`, strip trailing `/`. @@ -17,31 +17,5 @@ export function resolveSandboxHostPathViaExistingAncestor(sourcePath: string): s if (!sourcePath.startsWith("/")) { return sourcePath; } - - const normalized = normalizeSandboxHostPath(sourcePath); - let current = normalized; - const missingSegments: string[] = []; - - while (current !== "/" && !existsSync(current)) { - missingSegments.unshift(posix.basename(current)); - const parent = posix.dirname(current); - if (parent === current) { - break; - } - current = parent; - } - - if (!existsSync(current)) { - return normalized; - } - - try { - const resolvedAncestor = normalizeSandboxHostPath(realpathSync.native(current)); - if (missingSegments.length === 0) { - return resolvedAncestor; - } - return normalizeSandboxHostPath(posix.join(resolvedAncestor, ...missingSegments)); - } catch { - return normalized; - } + return normalizeSandboxHostPath(resolvePathViaExistingAncestorSync(sourcePath)); }
src/agents/sandbox-paths.test.ts+20 −0 modified@@ -195,6 +195,26 @@ describe("resolveSandboxedMediaSource", () => { }); }); + it("rejects sandbox symlink escapes when the outside leaf does not exist yet", async () => { + if (process.platform === "win32") { + return; + } + await withSandboxRoot(async (sandboxDir) => { + const outsideDir = await fs.mkdtemp( + path.join(process.cwd(), "sandbox-media-outside-missing-"), + ); + const linkDir = path.join(sandboxDir, "escape-link"); + await fs.symlink(outsideDir, linkDir); + try { + const missingOutsidePath = path.join(linkDir, "new-file.txt"); + await expectSandboxRejection(missingOutsidePath, sandboxDir, /symlink|sandbox/i); + } finally { + await fs.rm(linkDir, { force: true }); + await fs.rm(outsideDir, { recursive: true, force: true }); + } + }); + }); + it("rejects hardlinked OpenClaw tmp paths to outside files", async () => { if (process.platform === "win32") { return;
src/agents/sandbox-paths.ts+1 −1 modified@@ -71,7 +71,7 @@ export async function assertSandboxPath(params: { }; await assertNoPathAliasEscape({ absolutePath: resolved.resolved, - rootPath: path.resolve(params.root), + rootPath: params.root, boundaryLabel: "sandbox root", policy, });
src/gateway/server-methods/agents.ts+31 −21 modified@@ -30,7 +30,8 @@ import { loadConfig, writeConfigFile } from "../../config/config.js"; import { resolveSessionTranscriptsDirForAgent } from "../../config/sessions/paths.js"; import { sameFileIdentity } from "../../infra/file-identity.js"; import { SafeOpenError, readLocalFileSafely } from "../../infra/fs-safe.js"; -import { isNotFoundPathError, isPathInside } from "../../infra/path-guards.js"; +import { assertNoPathAliasEscape } from "../../infra/path-alias-guards.js"; +import { isNotFoundPathError } from "../../infra/path-guards.js"; import { DEFAULT_AGENT_ID, normalizeAgentId } from "../../routing/session-key.js"; import { resolveUserPath } from "../../utils.js"; import { @@ -143,8 +144,19 @@ async function resolveAgentWorkspaceFilePath(params: { const requestPath = path.join(params.workspaceDir, params.name); const workspaceReal = await resolveWorkspaceRealPath(params.workspaceDir); const candidatePath = path.resolve(workspaceReal, params.name); - if (!isPathInside(workspaceReal, candidatePath)) { - return { kind: "invalid", requestPath, reason: "path escapes workspace root" }; + + try { + await assertNoPathAliasEscape({ + absolutePath: candidatePath, + rootPath: workspaceReal, + boundaryLabel: "workspace root", + }); + } catch (error) { + return { + kind: "invalid", + requestPath, + reason: error instanceof Error ? error.message : "path escapes workspace root", + }; } let candidateLstat: Awaited<ReturnType<typeof fs.lstat>>; @@ -169,27 +181,28 @@ async function resolveAgentWorkspaceFilePath(params: { if (params.allowMissing) { return { kind: "missing", requestPath, ioPath: candidatePath, workspaceReal }; } - return { kind: "invalid", requestPath, reason: "symlink target not found" }; + return { kind: "invalid", requestPath, reason: "file not found" }; } throw err; } - if (!isPathInside(workspaceReal, targetReal)) { - return { kind: "invalid", requestPath, reason: "symlink target escapes workspace root" }; - } + let targetStat: Awaited<ReturnType<typeof fs.stat>>; try { - const targetStat = await fs.stat(targetReal); - if (!targetStat.isFile()) { - return { kind: "invalid", requestPath, reason: "symlink target is not a file" }; - } - if (targetStat.nlink > 1) { - return { kind: "invalid", requestPath, reason: "hardlinked file target not allowed" }; - } + targetStat = await fs.stat(targetReal); } catch (err) { - if (isNotFoundPathError(err) && params.allowMissing) { - return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal }; + if (isNotFoundPathError(err)) { + if (params.allowMissing) { + return { kind: "missing", requestPath, ioPath: targetReal, workspaceReal }; + } + return { kind: "invalid", requestPath, reason: "file not found" }; } throw err; } + if (!targetStat.isFile()) { + return { kind: "invalid", requestPath, reason: "path is not a regular file" }; + } + if (targetStat.nlink > 1) { + return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" }; + } return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal }; } @@ -200,11 +213,8 @@ async function resolveAgentWorkspaceFilePath(params: { return { kind: "invalid", requestPath, reason: "hardlinked file path not allowed" }; } - const candidateReal = await fs.realpath(candidatePath).catch(() => candidatePath); - if (!isPathInside(workspaceReal, candidateReal)) { - return { kind: "invalid", requestPath, reason: "resolved file escapes workspace root" }; - } - return { kind: "ready", requestPath, ioPath: candidateReal, workspaceReal }; + const targetReal = await fs.realpath(candidatePath).catch(() => candidatePath); + return { kind: "ready", requestPath, ioPath: targetReal, workspaceReal }; } async function statFileSafely(filePath: string): Promise<FileMeta | null> {
src/infra/boundary-file-read.ts+17 −54 modified@@ -1,7 +1,7 @@ import fs from "node:fs"; import path from "node:path"; -import { assertNoPathAliasEscape, type PathAliasPolicy } from "./path-alias-guards.js"; -import { isNotFoundPathError, isPathInside } from "./path-guards.js"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; +import type { PathAliasPolicy } from "./path-alias-guards.js"; import { openVerifiedFileSync, type SafeOpenSyncFailureReason } from "./safe-open-sync.js"; type BoundaryReadFs = Pick< @@ -36,14 +36,6 @@ export type OpenBoundaryFileParams = OpenBoundaryFileSyncParams & { aliasPolicy?: PathAliasPolicy; }; -function safeRealpathSync(ioFs: Pick<typeof fs, "realpathSync">, value: string): string { - try { - return path.resolve(ioFs.realpathSync(value)); - } catch { - return path.resolve(value); - } -} - export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { return ( typeof ioFs.openSync === "function" && @@ -60,52 +52,21 @@ export function canUseBoundaryFileOpen(ioFs: typeof fs): boolean { export function openBoundaryFileSync(params: OpenBoundaryFileSyncParams): BoundaryFileOpenResult { const ioFs = params.ioFs ?? fs; const absolutePath = path.resolve(params.absolutePath); - const rootPath = path.resolve(params.rootPath); - const rootRealPath = params.rootRealPath - ? path.resolve(params.rootRealPath) - : safeRealpathSync(ioFs, rootPath); - let resolvedPath = absolutePath; - const lexicalInsideRoot = isPathInside(rootPath, absolutePath); + let resolvedPath: string; + let rootRealPath: string; try { - const candidateRealPath = path.resolve(ioFs.realpathSync(absolutePath)); - if ( - !params.skipLexicalRootCheck && - !lexicalInsideRoot && - !isPathInside(rootRealPath, candidateRealPath) - ) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path escapes ${params.boundaryLabel}: ${absolutePath} (root: ${rootPath})`, - ), - }; - } - if (!isPathInside(rootRealPath, candidateRealPath)) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path resolves outside ${params.boundaryLabel}: ${absolutePath} (root: ${rootRealPath})`, - ), - }; - } - resolvedPath = candidateRealPath; + const resolved = resolveBoundaryPathSync({ + absolutePath, + rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, + boundaryLabel: params.boundaryLabel, + skipLexicalRootCheck: params.skipLexicalRootCheck, + }); + resolvedPath = resolved.canonicalPath; + rootRealPath = resolved.rootCanonicalPath; } catch (error) { - if (!params.skipLexicalRootCheck && !lexicalInsideRoot) { - return { - ok: false, - reason: "validation", - error: new Error( - `Path escapes ${params.boundaryLabel}: ${absolutePath} (root: ${rootPath})`, - ), - }; - } - if (!isNotFoundPathError(error)) { - // Keep resolvedPath as lexical path; openVerifiedFileSync below will produce - // a canonical error classification for missing/unreadable targets. - } + return { ok: false, reason: "validation", error }; } const opened = openVerifiedFileSync({ @@ -131,11 +92,13 @@ export async function openBoundaryFile( params: OpenBoundaryFileParams, ): Promise<BoundaryFileOpenResult> { try { - await assertNoPathAliasEscape({ + await resolveBoundaryPath({ absolutePath: params.absolutePath, rootPath: params.rootPath, + rootCanonicalPath: params.rootRealPath, boundaryLabel: params.boundaryLabel, policy: params.aliasPolicy, + skipLexicalRootCheck: params.skipLexicalRootCheck, }); } catch (error) { return { ok: false, reason: "validation", error };
src/infra/boundary-path.test.ts+167 −0 added@@ -0,0 +1,167 @@ +import fs from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { describe, expect, it } from "vitest"; +import { resolveBoundaryPath, resolveBoundaryPathSync } from "./boundary-path.js"; +import { isPathInside } from "./path-guards.js"; + +async function withTempRoot<T>(prefix: string, run: (root: string) => Promise<T>): Promise<T> { + const root = await fs.mkdtemp(path.join(os.tmpdir(), prefix)); + try { + return await run(root); + } finally { + await fs.rm(root, { recursive: true, force: true }); + } +} + +function createSeededRandom(seed: number): () => number { + let state = seed >>> 0; + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} + +describe("resolveBoundaryPath", () => { + it("resolves symlink parents with non-existent leafs inside root", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const targetDir = path.join(root, "target-dir"); + const linkPath = path.join(root, "alias"); + await fs.mkdir(targetDir, { recursive: true }); + await fs.symlink(targetDir, linkPath); + + const unresolved = path.join(linkPath, "missing.txt"); + const result = await resolveBoundaryPath({ + absolutePath: unresolved, + rootPath: root, + boundaryLabel: "sandbox root", + }); + + const targetReal = await fs.realpath(targetDir); + expect(result.exists).toBe(false); + expect(result.kind).toBe("missing"); + expect(result.canonicalPath).toBe(path.join(targetReal, "missing.txt")); + expect(isPathInside(result.rootCanonicalPath, result.canonicalPath)).toBe(true); + }); + }); + + it("blocks dangling symlink leaf escapes outside root", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const linkPath = path.join(root, "alias-out"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.symlink(outside, linkPath); + const dangling = path.join(linkPath, "missing.txt"); + + await expect( + resolveBoundaryPath({ + absolutePath: dangling, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + expect(() => + resolveBoundaryPathSync({ + absolutePath: dangling, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).toThrow(/Symlink escapes sandbox root/i); + }); + }); + + it("allows final symlink only when unlink policy opts in", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const outsideFile = path.join(outside, "target.txt"); + const linkPath = path.join(root, "link.txt"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.writeFile(outsideFile, "x", "utf8"); + await fs.symlink(outsideFile, linkPath); + + await expect( + resolveBoundaryPath({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + + const allowed = await resolveBoundaryPath({ + absolutePath: linkPath, + rootPath: root, + boundaryLabel: "sandbox root", + policy: { allowFinalSymlinkForUnlink: true }, + }); + const rootReal = await fs.realpath(root); + expect(allowed.exists).toBe(true); + expect(allowed.kind).toBe("symlink"); + expect(allowed.canonicalPath).toBe(path.join(rootReal, "link.txt")); + }); + }); + + it("maintains containment invariant across randomized alias cases", async () => { + if (process.platform === "win32") { + return; + } + + await withTempRoot("openclaw-boundary-path-fuzz-", async (base) => { + const root = path.join(base, "workspace"); + const outside = path.join(base, "outside"); + const safeTarget = path.join(root, "safe-target"); + await fs.mkdir(root, { recursive: true }); + await fs.mkdir(outside, { recursive: true }); + await fs.mkdir(safeTarget, { recursive: true }); + + const rand = createSeededRandom(0x5eed1234); + for (let idx = 0; idx < 64; idx += 1) { + const token = Math.floor(rand() * 1_000_000) + .toString(16) + .padStart(5, "0"); + const safeName = `safe-${idx}-${token}`; + const useLink = rand() > 0.5; + const safeBase = useLink ? path.join(root, `safe-link-${idx}`) : path.join(root, safeName); + if (useLink) { + await fs.symlink(safeTarget, safeBase); + } else { + await fs.mkdir(safeBase, { recursive: true }); + } + const safeCandidate = path.join(safeBase, `new-${token}.txt`); + const safeResolved = await resolveBoundaryPath({ + absolutePath: safeCandidate, + rootPath: root, + boundaryLabel: "sandbox root", + }); + expect(isPathInside(safeResolved.rootCanonicalPath, safeResolved.canonicalPath)).toBe(true); + + const escapeLink = path.join(root, `escape-${idx}`); + await fs.symlink(outside, escapeLink); + const unsafeCandidate = path.join(escapeLink, `new-${token}.txt`); + await expect( + resolveBoundaryPath({ + absolutePath: unsafeCandidate, + rootPath: root, + boundaryLabel: "sandbox root", + }), + ).rejects.toThrow(/Symlink escapes sandbox root/i); + } + }); + }); +});
src/infra/boundary-path.ts+511 −0 added@@ -0,0 +1,511 @@ +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import os from "node:os"; +import path from "node:path"; +import { isNotFoundPathError, isPathInside } from "./path-guards.js"; + +export type BoundaryPathIntent = "read" | "write" | "create" | "delete" | "stat"; + +export type BoundaryPathAliasPolicy = { + allowFinalSymlinkForUnlink?: boolean; + allowFinalHardlinkForUnlink?: boolean; +}; + +export const BOUNDARY_PATH_ALIAS_POLICIES = { + strict: Object.freeze({ + allowFinalSymlinkForUnlink: false, + allowFinalHardlinkForUnlink: false, + }), + unlinkTarget: Object.freeze({ + allowFinalSymlinkForUnlink: true, + allowFinalHardlinkForUnlink: true, + }), +} as const; + +export type ResolveBoundaryPathParams = { + absolutePath: string; + rootPath: string; + boundaryLabel: string; + intent?: BoundaryPathIntent; + policy?: BoundaryPathAliasPolicy; + skipLexicalRootCheck?: boolean; + rootCanonicalPath?: string; +}; + +export type ResolvedBoundaryPathKind = "missing" | "file" | "directory" | "symlink" | "other"; + +export type ResolvedBoundaryPath = { + absolutePath: string; + canonicalPath: string; + rootPath: string; + rootCanonicalPath: string; + relativePath: string; + exists: boolean; + kind: ResolvedBoundaryPathKind; +}; + +export async function resolveBoundaryPath( + params: ResolveBoundaryPathParams, +): Promise<ResolvedBoundaryPath> { + const rootPath = path.resolve(params.rootPath); + const absolutePath = path.resolve(params.absolutePath); + const rootCanonicalPath = params.rootCanonicalPath + ? path.resolve(params.rootCanonicalPath) + : await resolvePathViaExistingAncestor(rootPath); + const lexicalInside = isPathInside(rootPath, absolutePath); + + if (!params.skipLexicalRootCheck && !lexicalInside) { + throw pathEscapeError({ + boundaryLabel: params.boundaryLabel, + rootPath, + absolutePath, + }); + } + + if (!lexicalInside) { + const canonicalPath = await resolvePathViaExistingAncestor(absolutePath); + assertInsideBoundary({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath, + candidatePath: canonicalPath, + absolutePath, + }); + const kind = await getPathKind(absolutePath, false); + return { + absolutePath, + canonicalPath, + rootPath, + rootCanonicalPath, + relativePath: relativeInsideRoot(rootCanonicalPath, canonicalPath), + exists: kind.exists, + kind: kind.kind, + }; + } + + return resolveBoundaryPathLexicalAsync({ + params, + absolutePath, + rootPath, + rootCanonicalPath, + }); +} + +export function resolveBoundaryPathSync(params: ResolveBoundaryPathParams): ResolvedBoundaryPath { + const rootPath = path.resolve(params.rootPath); + const absolutePath = path.resolve(params.absolutePath); + const rootCanonicalPath = params.rootCanonicalPath + ? path.resolve(params.rootCanonicalPath) + : resolvePathViaExistingAncestorSync(rootPath); + const lexicalInside = isPathInside(rootPath, absolutePath); + + if (!params.skipLexicalRootCheck && !lexicalInside) { + throw pathEscapeError({ + boundaryLabel: params.boundaryLabel, + rootPath, + absolutePath, + }); + } + + if (!lexicalInside) { + const canonicalPath = resolvePathViaExistingAncestorSync(absolutePath); + assertInsideBoundary({ + boundaryLabel: params.boundaryLabel, + rootCanonicalPath, + candidatePath: canonicalPath, + absolutePath, + }); + const kind = getPathKindSync(absolutePath, false); + return { + absolutePath, + canonicalPath, + rootPath, + rootCanonicalPath, + relativePath: relativeInsideRoot(rootCanonicalPath, canonicalPath), + exists: kind.exists, + kind: kind.kind, + }; + } + + return resolveBoundaryPathLexicalSync({ + params, + absolutePath, + rootPath, + rootCanonicalPath, + }); +} + +async function resolveBoundaryPathLexicalAsync(params: { + params: ResolveBoundaryPathParams; + absolutePath: string; + rootPath: string; + rootCanonicalPath: string; +}): Promise<ResolvedBoundaryPath> { + const relative = path.relative(params.rootPath, params.absolutePath); + const segments = relative.split(path.sep).filter(Boolean); + const allowFinalSymlink = params.params.policy?.allowFinalSymlinkForUnlink === true; + let canonicalCursor = params.rootCanonicalPath; + let lexicalCursor = params.rootPath; + let preserveFinalSymlink = false; + + for (let idx = 0; idx < segments.length; idx += 1) { + const segment = segments[idx] ?? ""; + const isLast = idx === segments.length - 1; + lexicalCursor = path.join(lexicalCursor, segment); + + let stat: Awaited<ReturnType<typeof fsp.lstat>>; + try { + stat = await fsp.lstat(lexicalCursor); + } catch (error) { + if (isNotFoundPathError(error)) { + const missingSuffix = segments.slice(idx); + canonicalCursor = path.resolve(canonicalCursor, ...missingSuffix); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + throw error; + } + + if (!stat.isSymbolicLink()) { + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + continue; + } + + if (allowFinalSymlink && isLast) { + preserveFinalSymlink = true; + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + + const linkCanonical = await resolveSymlinkHopPath(lexicalCursor); + if (!isPathInside(params.rootCanonicalPath, linkCanonical)) { + throw symlinkEscapeError({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + symlinkPath: lexicalCursor, + }); + } + canonicalCursor = linkCanonical; + lexicalCursor = linkCanonical; + } + + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + const kind = await getPathKind(params.absolutePath, preserveFinalSymlink); + return { + absolutePath: params.absolutePath, + canonicalPath: canonicalCursor, + rootPath: params.rootPath, + rootCanonicalPath: params.rootCanonicalPath, + relativePath: relativeInsideRoot(params.rootCanonicalPath, canonicalCursor), + exists: kind.exists, + kind: kind.kind, + }; +} + +function resolveBoundaryPathLexicalSync(params: { + params: ResolveBoundaryPathParams; + absolutePath: string; + rootPath: string; + rootCanonicalPath: string; +}): ResolvedBoundaryPath { + const relative = path.relative(params.rootPath, params.absolutePath); + const segments = relative.split(path.sep).filter(Boolean); + const allowFinalSymlink = params.params.policy?.allowFinalSymlinkForUnlink === true; + let canonicalCursor = params.rootCanonicalPath; + let lexicalCursor = params.rootPath; + let preserveFinalSymlink = false; + + for (let idx = 0; idx < segments.length; idx += 1) { + const segment = segments[idx] ?? ""; + const isLast = idx === segments.length - 1; + lexicalCursor = path.join(lexicalCursor, segment); + + let stat: fs.Stats; + try { + stat = fs.lstatSync(lexicalCursor); + } catch (error) { + if (isNotFoundPathError(error)) { + const missingSuffix = segments.slice(idx); + canonicalCursor = path.resolve(canonicalCursor, ...missingSuffix); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + throw error; + } + + if (!stat.isSymbolicLink()) { + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + continue; + } + + if (allowFinalSymlink && isLast) { + preserveFinalSymlink = true; + canonicalCursor = path.resolve(canonicalCursor, segment); + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + break; + } + + const linkCanonical = resolveSymlinkHopPathSync(lexicalCursor); + if (!isPathInside(params.rootCanonicalPath, linkCanonical)) { + throw symlinkEscapeError({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + symlinkPath: lexicalCursor, + }); + } + canonicalCursor = linkCanonical; + lexicalCursor = linkCanonical; + } + + assertInsideBoundary({ + boundaryLabel: params.params.boundaryLabel, + rootCanonicalPath: params.rootCanonicalPath, + candidatePath: canonicalCursor, + absolutePath: params.absolutePath, + }); + const kind = getPathKindSync(params.absolutePath, preserveFinalSymlink); + return { + absolutePath: params.absolutePath, + canonicalPath: canonicalCursor, + rootPath: params.rootPath, + rootCanonicalPath: params.rootCanonicalPath, + relativePath: relativeInsideRoot(params.rootCanonicalPath, canonicalCursor), + exists: kind.exists, + kind: kind.kind, + }; +} + +export async function resolvePathViaExistingAncestor(targetPath: string): Promise<string> { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !(await pathExists(cursor))) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!(await pathExists(cursor))) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(await fsp.realpath(cursor)); + if (missingSuffix.length === 0) { + return resolvedAncestor; + } + return path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} + +export function resolvePathViaExistingAncestorSync(targetPath: string): string { + const normalized = path.resolve(targetPath); + let cursor = normalized; + const missingSuffix: string[] = []; + + while (!isFilesystemRoot(cursor) && !fs.existsSync(cursor)) { + missingSuffix.unshift(path.basename(cursor)); + const parent = path.dirname(cursor); + if (parent === cursor) { + break; + } + cursor = parent; + } + + if (!fs.existsSync(cursor)) { + return normalized; + } + + try { + const resolvedAncestor = path.resolve(fs.realpathSync.native(cursor)); + if (missingSuffix.length === 0) { + return resolvedAncestor; + } + return path.resolve(resolvedAncestor, ...missingSuffix); + } catch { + return normalized; + } +} + +async function getPathKind( + absolutePath: string, + preserveFinalSymlink: boolean, +): Promise<{ exists: boolean; kind: ResolvedBoundaryPathKind }> { + try { + const stat = preserveFinalSymlink + ? await fsp.lstat(absolutePath) + : await fsp.stat(absolutePath); + return { exists: true, kind: toResolvedKind(stat) }; + } catch (error) { + if (isNotFoundPathError(error)) { + return { exists: false, kind: "missing" }; + } + throw error; + } +} + +function getPathKindSync( + absolutePath: string, + preserveFinalSymlink: boolean, +): { exists: boolean; kind: ResolvedBoundaryPathKind } { + try { + const stat = preserveFinalSymlink ? fs.lstatSync(absolutePath) : fs.statSync(absolutePath); + return { exists: true, kind: toResolvedKind(stat) }; + } catch (error) { + if (isNotFoundPathError(error)) { + return { exists: false, kind: "missing" }; + } + throw error; + } +} + +function toResolvedKind(stat: fs.Stats): ResolvedBoundaryPathKind { + if (stat.isFile()) { + return "file"; + } + if (stat.isDirectory()) { + return "directory"; + } + if (stat.isSymbolicLink()) { + return "symlink"; + } + return "other"; +} + +function relativeInsideRoot(rootPath: string, targetPath: string): string { + const relative = path.relative(path.resolve(rootPath), path.resolve(targetPath)); + if (!relative || relative === ".") { + return ""; + } + if (relative.startsWith("..") || path.isAbsolute(relative)) { + return ""; + } + return relative; +} + +function assertInsideBoundary(params: { + boundaryLabel: string; + rootCanonicalPath: string; + candidatePath: string; + absolutePath: string; +}): void { + if (isPathInside(params.rootCanonicalPath, params.candidatePath)) { + return; + } + throw new Error( + `Path resolves outside ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.absolutePath)}`, + ); +} + +function pathEscapeError(params: { + boundaryLabel: string; + rootPath: string; + absolutePath: string; +}): Error { + return new Error( + `Path escapes ${params.boundaryLabel} (${shortPath(params.rootPath)}): ${shortPath(params.absolutePath)}`, + ); +} + +function symlinkEscapeError(params: { + boundaryLabel: string; + rootCanonicalPath: string; + symlinkPath: string; +}): Error { + return new Error( + `Symlink escapes ${params.boundaryLabel} (${shortPath(params.rootCanonicalPath)}): ${shortPath(params.symlinkPath)}`, + ); +} + +function shortPath(value: string): string { + const home = os.homedir(); + if (value.startsWith(home)) { + return `~${value.slice(home.length)}`; + } + return value; +} + +function isFilesystemRoot(candidate: string): boolean { + return path.parse(candidate).root === candidate; +} + +async function pathExists(targetPath: string): Promise<boolean> { + try { + await fsp.lstat(targetPath); + return true; + } catch (error) { + if (isNotFoundPathError(error)) { + return false; + } + throw error; + } +} + +async function resolveSymlinkHopPath(symlinkPath: string): Promise<string> { + try { + return path.resolve(await fsp.realpath(symlinkPath)); + } catch (error) { + if (!isNotFoundPathError(error)) { + throw error; + } + const linkTarget = await fsp.readlink(symlinkPath); + const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget); + return resolvePathViaExistingAncestor(linkAbsolute); + } +} + +function resolveSymlinkHopPathSync(symlinkPath: string): string { + try { + return path.resolve(fs.realpathSync.native(symlinkPath)); + } catch (error) { + if (!isNotFoundPathError(error)) { + throw error; + } + const linkTarget = fs.readlinkSync(symlinkPath); + const linkAbsolute = path.resolve(path.dirname(symlinkPath), linkTarget); + return resolvePathViaExistingAncestorSync(linkAbsolute); + } +}
src/infra/path-alias-guards.ts+18 −73 modified@@ -1,89 +1,34 @@ -import fs from "node:fs/promises"; -import os from "node:os"; -import path from "node:path"; +import { + BOUNDARY_PATH_ALIAS_POLICIES, + resolveBoundaryPath, + type BoundaryPathAliasPolicy, +} from "./boundary-path.js"; import { assertNoHardlinkedFinalPath } from "./hardlink-guards.js"; -import { isNotFoundPathError, isPathInside } from "./path-guards.js"; -export type PathAliasPolicy = { - allowFinalSymlinkForUnlink?: boolean; - allowFinalHardlinkForUnlink?: boolean; -}; +export type PathAliasPolicy = BoundaryPathAliasPolicy; -export const PATH_ALIAS_POLICIES = { - strict: Object.freeze({ - allowFinalSymlinkForUnlink: false, - allowFinalHardlinkForUnlink: false, - }), - unlinkTarget: Object.freeze({ - allowFinalSymlinkForUnlink: true, - allowFinalHardlinkForUnlink: true, - }), -} as const; +export const PATH_ALIAS_POLICIES = BOUNDARY_PATH_ALIAS_POLICIES; export async function assertNoPathAliasEscape(params: { absolutePath: string; rootPath: string; boundaryLabel: string; policy?: PathAliasPolicy; }): Promise<void> { - const root = path.resolve(params.rootPath); - const target = path.resolve(params.absolutePath); - if (!isPathInside(root, target)) { - throw new Error( - `Path escapes ${params.boundaryLabel} (${shortPath(root)}): ${shortPath(params.absolutePath)}`, - ); - } - const relative = path.relative(root, target); - if (relative) { - const rootReal = await tryRealpath(root); - const parts = relative.split(path.sep).filter(Boolean); - let current = root; - for (let idx = 0; idx < parts.length; idx += 1) { - current = path.join(current, parts[idx] ?? ""); - const isLast = idx === parts.length - 1; - try { - const stat = await fs.lstat(current); - if (!stat.isSymbolicLink()) { - continue; - } - if (params.policy?.allowFinalSymlinkForUnlink && isLast) { - return; - } - const symlinkTarget = await tryRealpath(current); - if (!isPathInside(rootReal, symlinkTarget)) { - throw new Error( - `Symlink escapes ${params.boundaryLabel} (${shortPath(rootReal)}): ${shortPath(current)}`, - ); - } - current = symlinkTarget; - } catch (error) { - if (isNotFoundPathError(error)) { - break; - } - throw error; - } - } + const resolved = await resolveBoundaryPath({ + absolutePath: params.absolutePath, + rootPath: params.rootPath, + boundaryLabel: params.boundaryLabel, + policy: params.policy, + }); + const allowFinalSymlink = params.policy?.allowFinalSymlinkForUnlink === true; + if (allowFinalSymlink && resolved.kind === "symlink") { + return; } - await assertNoHardlinkedFinalPath({ - filePath: target, - root, + filePath: resolved.absolutePath, + root: resolved.rootPath, boundaryLabel: params.boundaryLabel, allowFinalHardlinkForUnlink: params.policy?.allowFinalHardlinkForUnlink, }); } - -async function tryRealpath(value: string): Promise<string> { - try { - return await fs.realpath(value); - } catch { - return path.resolve(value); - } -} - -function shortPath(value: string) { - if (value.startsWith(os.homedir())) { - return `~${value.slice(os.homedir().length)}`; - } - return value; -}
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/1aef45bc060b28a0af45a67dc66acd36aef763c9ghsapatchWEB
- github.com/openclaw/openclaw/commit/46eba86b45e9db05b7b792e914c4fe0de1b40a23ghsapatchWEB
- github.com/advisories/GHSA-mgrq-9f93-wpp5ghsaADVISORY
- github.com/openclaw/openclaw/security/advisories/GHSA-mgrq-9f93-wpp5ghsathird-party-advisoryWEB
- nvd.nist.gov/vuln/detail/CVE-2026-32055ghsaADVISORY
- www.vulncheck.com/advisories/openclaw-workspace-path-boundary-bypass-via-non-existent-symlinkghsathird-party-advisoryWEB
News mentions
0No linked articles in our index yet.