Skip to content

security: confine OpenClaw skills dir to its workspace via realpath#419

Open
garagon wants to merge 1 commit into
garrytan:masterfrom
garagon:security/openclaw-skills-symlink-canonicalize
Open

security: confine OpenClaw skills dir to its workspace via realpath#419
garagon wants to merge 1 commit into
garrytan:masterfrom
garagon:security/openclaw-skills-symlink-canonicalize

Conversation

@garagon

@garagon garagon commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

resolveWorkspaceSkillsDir in src/core/repo-root.ts builds the
skills directory using path.join + existsSync but never
canonicalizes the result. Downstream readers (check-resolvable.ts,
skillify.ts, skillpack.ts, routing-eval.ts) follow symlinks by
default, so an attacker who controls \$OPENCLAW_WORKSPACE can plant
workspace/skills as a symlink to an arbitrary directory and every
skill loader trusts the result as if it lived under the declared
workspace.

Threat model

Any process that can set OPENCLAW_WORKSPACE is in scope: a multi-
user host where another user owns the env-var target, an npm
postinstall on a shared workspace, shell-rc poisoning, a container
with a bind-mounted workspace whose host-side has been tampered with.
None of these require root or container escape.

This is orthogonal to PR #156 (path traversal inside manifest
entries). That PR validates .. segments in parsed manifest strings.
The attack here never needs .. — a single symlink at
workspace/skills is enough to escape the boundary.

What changes

A new helper isPathContained(parent, child) in src/core/repo-root.ts
is called against both skills-subdir layouts before
resolveWorkspaceSkillsDir hands back a result:

  • realpath both ends
  • require path.relative(realParent, realChild) to produce a non-..-
    prefixed, non-absolute result

Same idiom that src/core/operations.ts:92-100 already uses for
file_upload's strict mode, so the security pattern is already in
the codebase, just not applied here.

On any realpathSync failure we fall back to lexical containment
(resolved absolute paths via path.relative) — better to refuse a
legit-but-unstattable workspace than to silently trust an escape via
a symlink whose target does not yet exist.

Backwards compatibility

Legit local-dev pattern of symlinking workspace/skills to
workspace/_real-skills (or any other path INSIDE the workspace)
continues to work — the symlink target is contained, so
isPathContained returns true. The change only refuses symlinks
that escape the declared workspace boundary.

autoDetectSkillsDir's priority ordering is unchanged. The ~/.openclaw/
home fallback, the findRepoRoot walk, and the ./skills cwd
fallback all use the same hardened helper now, so they get the same
guarantee as the env-var path.

Test coverage

test/repo-root.test.ts adds four cases:

  • escape: workspace/skills symlinked to an outside directory →
    autoDetectSkillsDir returns dir=null (refusal)
  • legit: workspace/skills symlinked to workspace/_real-skills
    accepted, descendant containment respected
  • isPathContained unit: equal/descendant pass, sibling/parent fail
  • isPathContained unit: symlink that escapes via realpath rejected

Full repo-root.test.ts (16/16) and related check-resolvable-cli

  • regression-v0_16_4 tests (40/40 across 3 files) all pass.
    bun run typecheck clean.

Why this matters

The convenience of letting \$OPENCLAW_WORKSPACE point anywhere has
a cost: any process that can set the variable can substitute the skill
tree the resolver loads. The fix is small (one helper, two call sites)
and additive — every legitimate workspace, including symlinked
workspace/skills that stays inside its declared boundary, continues
to work. Only escapes are refused.

…ealpath

resolveWorkspaceSkillsDir built skillsDir using join() and existsSync()
but never canonicalized the result. Downstream readers
(check-resolvable.ts: readFileSync x5, readdirSync, plus
skillify.ts / skillpack.ts / routing-eval.ts) follow symlinks by
default. An attacker who controls $OPENCLAW_WORKSPACE — multi-user
host where another user can write to that path, npm postinstall on a
shared workspace, shell-rc poisoning, container with a bind-mounted
workspace — can plant `workspace/skills` as a symlink to an arbitrary
directory. Every skill loader trusted the result as if it lived under
the declared workspace.

This is orthogonal to PR garrytan#156 (path traversal in manifest entries).
That PR validates `..` segments inside parsed manifest strings; the
attack here never needs `..` — a single symlink at workspace/skills
is enough to escape the boundary. The threat model is any process
that can set OPENCLAW_WORKSPACE: the attacker does not need root or
container escape.

This change adds isPathContained(parent, child), called against both
skills-subdir layouts before returning. realpath both ends, then
path.relative must produce a non-`..`-prefixed, non-absolute result.
Same idiom that src/core/operations.ts:92-100 already uses for
file_upload's strict mode.

On any realpath failure we fall back to lexical containment (resolved
absolute paths via path.relative) — better to refuse a legit-but-
unstattable workspace than to silently trust an escape via a symlink
whose target does not yet exist.

Test coverage in test/repo-root.test.ts adds four cases:

  - escape: workspace/skills symlinked to an outside directory →
    autoDetectSkillsDir returns dir=null (refusal)
  - legit: workspace/skills symlinked to workspace/_real-skills →
    accepted, descendant containment respected
  - isPathContained unit: equal/descendant pass, sibling/parent fail
  - isPathContained unit: symlink that escapes via realpath rejected

Full repo-root.test.ts (16/16) and related check-resolvable + v0.16.4
regression tests (40/40 across 3 files) all pass. Typecheck clean.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant