Skip to content

[Security] Workspace-only apply_patch can write outside the workspace through a dangling symlink leaf #566

Description

@YLChen-007

Advisory Details

Title: Workspace-only apply_patch can write outside the workspace through a dangling symlink leaf

Description:

Summary

The experimental apply_patch tool is documented as workspace-contained by default, but its path guard can be bypassed when the target path is a dangling symlink leaf located inside the workspace. In that case, the lexical path check succeeds, the symlink escape check returns early on ENOENT, and the final fs.writeFile(...) follows the symlink target outside the workspace root. A caller that can reach a normal embedded agent session with apply_patch enabled can therefore create or overwrite files outside the intended workspace boundary.

Details

The affected boundary is the embedded coding-tool surface, not the public /tools/invoke HTTP endpoint. createOpenClawCodingTools(...) conditionally exposes apply_patch when tools.exec.applyPatch.enabled is true and the current model is an allowed OpenAI model. The tool is explicitly documented as workspace-contained by default.

The defect sits in the interaction between resolvePatchPath(...) and assertNoSymlinkEscape(...):

  • resolvePatchPath(...) routes *** Add File: targets through assertSandboxPath(...).
  • assertSandboxPath(...) first enforces lexical root containment.
  • assertNoSymlinkEscape(...) then walks path components with lstat().
  • If the final leaf lookup hits ENOENT, the function returns success immediately instead of resolving from the nearest existing ancestor.
  • applyPatch(...) then performs fs.writeFile(...) on the symlink path, and the OS follows the dangling symlink target outside the workspace.

The result is that a path like jump appears safe because it is lexically inside the workspace, while the actual write lands at the symlink target outside the workspace.

Relevant code:

// src/agents/sandbox-paths.ts
try {
  const stat = await fs.lstat(current);
  if (stat.isSymbolicLink()) {
    const target = await tryRealpath(current);
    if (!isPathInside(rootReal, target)) {
      throw new Error(...);
    }
    current = target;
  }
} catch (err) {
  if (isNotFoundPathError(err)) {
    return;
  }
  throw err;
}
// src/agents/apply-patch.ts
if (hunk.kind === "add") {
  const target = await resolvePatchPath(hunk.path, options);
  await ensureDir(target.resolved, fileOps);
  await fileOps.writeFile(target.resolved, hunk.contents);
}

PoC

Prerequisites

  • A deployment that enables tools.exec.applyPatch.enabled
  • An allowed OpenAI / OpenAI Codex model for the affected session
  • A session that reaches the embedded coding-tool surface with workspace write access
  • A workspace that already contains a dangling symlink leaf pointing outside the workspace root
  • Bun and Python 3 available locally to run the provided PoC scripts

Reproduction Steps

  1. Download the verification harness from: verification_test.py
  2. Download the matching control from: control-direct-traversal.py
  3. Download the minimal driver that invokes the real exported tool path from: tool_driver.ts
  4. From the repository root, run the verification PoC:
    python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/verification_test.py
  5. Observe that the script creates a temporary workspace, plants workspace/jump -> /tmp/.../outside/owned.txt, invokes the real apply_patch tool with *** Add File: jump, and reports that the outside file now contains pwned\n.
  6. Run the control:
    python3 llm-enhance/cve-finding/similar/workspace-boundary-bypass/Advisory-GHSA-qcc4-p59m-p54m-dangling-symlink-apply-patch-exp/control-direct-traversal.py
  7. Observe that direct ../escape.txt traversal is rejected with Path escapes sandbox root, confirming the bypass is specific to the dangling symlink alias rather than a total absence of path validation.

Log of Evidence

Verification run:

[Verification Mode] Integration-Test
[workspace symlink]
/tmp/ghsa-qcc4-dangling-ehvh1uhc/workspace/jump -> /tmp/ghsa-qcc4-dangling-ehvh1uhc/outside/owned.txt
[patch stdout]
PATCH_OUTSIDE:"pwned\n"
[independent observation]
{"outside_file": "/tmp/ghsa-qcc4-dangling-ehvh1uhc/outside/owned.txt", "contents": "pwned\n"}
[DEFECT CONFIRMED] apply_patch follows a dangling in-workspace symlink and writes outside the claimed workspace-only boundary.

Control run:

[Verification Mode] Integration-Test Control
[control stdout]
CONTROL_BLOCKED:Path escapes sandbox root (/tmp/ghsa-qcc4-control-5o5g3w4h/workspace): ../escape.txt
[independent observation]
{"escaped_path_exists": false, "outside_exists": false}
[CONTROL PASSED] direct traversal stays blocked on the same interface.

Impact

This is a filesystem boundary bypass on the embedded agent tool surface. Any deployment that enables apply_patch and relies on workspaceOnly=true as a safety guarantee can be tricked into creating or overwriting files outside the intended workspace root, as long as the workspace contains a dangling symlink leaf chosen by the attacker-controlled patch path. The impact is not “public unauthenticated gateway takeover,” but it does break the advertised confinement guarantee for a mutating file tool. Depending on what exists outside the workspace, this can corrupt adjacent project files, host-side config, scripts, or other mounted content that operators reasonably believed was out of reach.

Affected products

  • Ecosystem: npm
  • Package name: openclaw-cn
  • Affected versions: <= 0.2.1
  • Patched versions:

Severity

  • Severity: Medium
  • Vector string: CVSS:3.1/AV:L/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:L

Weaknesses

  • CWE: CWE-59: Improper Link Resolution Before File Access ('Link Following')

Occurrences

Permalink Description
async function assertNoSymlinkEscape(
relative: string,
root: string,
options?: { allowFinalSymlink?: boolean },
) {
if (!relative) {
return;
}
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) {
const part = parts[idx];
const isLast = idx === parts.length - 1;
current = path.join(current, part);
try {
const stat = await fs.lstat(current);
if (stat.isSymbolicLink()) {
// Unlinking a symlink itself is safe even if it points outside the root. What we
// must prevent is traversing through a symlink to reach targets outside root.
if (options?.allowFinalSymlink && isLast) {
return;
}
const target = await tryRealpath(current);
if (!isPathInside(rootReal, target)) {
throw new Error(
`Symlink escapes sandbox root (${shortPath(rootReal)}): ${shortPath(current)}`,
);
}
current = target;
}
} catch (err) {
if (isNotFoundPathError(err)) {
return;
}
throw err;
}
}
}
assertNoSymlinkEscape(...) walks the candidate path, but returns success on isNotFoundPathError(err) without resolving a dangling final symlink leaf through the nearest existing ancestor.
if (hunk.kind === "add") {
const target = await resolvePatchPath(hunk.path, options);
await ensureDir(target.resolved, fileOps);
await fileOps.writeFile(target.resolved, hunk.contents);
applyPatch(...) uses the validated target path from resolvePatchPath(...) and immediately performs fs.writeFile(...), so any unresolved dangling symlink alias is followed by the OS at write time.
const applyPatchConfig = options?.config?.tools?.exec?.applyPatch;
const applyPatchEnabled =
!!applyPatchConfig?.enabled &&
isOpenAIProvider(options?.modelProvider) &&
isApplyPatchAllowedForModel({
modelProvider: options?.modelProvider,
modelId: options?.modelId,
allowModels: applyPatchConfig?.allowModels,
});
createOpenClawCodingTools(...) enables apply_patch for OpenAI models when tools.exec.applyPatch.enabled is set, making the vulnerable file-write surface reachable in normal embedded agent sessions.
const applyPatchTool =
!applyPatchEnabled || (sandboxRoot && !allowWorkspaceWrites)
? // @ts-ignore -- cherry-pick upstream type mismatch
null
: createApplyPatchTool({
cwd: sandboxRoot ?? workspaceRoot,
// @ts-ignore -- cherry-pick upstream type mismatch
sandboxRoot: sandboxRoot && allowWorkspaceWrites ? sandboxRoot : undefined,
});
The constructed applyPatchTool is added to the live tool list with cwd rooted to the workspace/sandbox root, so callers are led to trust the workspace-only boundary for this mutating tool.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions