security: confine OpenClaw skills dir to its workspace via realpath#419
Open
garagon wants to merge 1 commit into
Open
security: confine OpenClaw skills dir to its workspace via realpath#419garagon wants to merge 1 commit into
garagon wants to merge 1 commit into
Conversation
…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.
6 tasks
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
resolveWorkspaceSkillsDirinsrc/core/repo-root.tsbuilds theskills directory using
path.join+existsSyncbut nevercanonicalizes the result. Downstream readers (
check-resolvable.ts,skillify.ts,skillpack.ts,routing-eval.ts) follow symlinks bydefault, so an attacker who controls
\$OPENCLAW_WORKSPACEcan plantworkspace/skillsas a symlink to an arbitrary directory and everyskill loader trusts the result as if it lived under the declared
workspace.
Threat model
Any process that can set
OPENCLAW_WORKSPACEis 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 atworkspace/skillsis enough to escape the boundary.What changes
A new helper
isPathContained(parent, child)insrc/core/repo-root.tsis called against both skills-subdir layouts before
resolveWorkspaceSkillsDirhands back a result:realpathboth endspath.relative(realParent, realChild)to produce a non-..-prefixed, non-absolute result
Same idiom that
src/core/operations.ts:92-100already uses forfile_upload's strict mode, so the security pattern is already inthe codebase, just not applied here.
On any
realpathSyncfailure we fall back to lexical containment(resolved absolute paths via
path.relative) — better to refuse alegit-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/skillstoworkspace/_real-skills(or any other path INSIDE the workspace)continues to work — the symlink target is contained, so
isPathContainedreturns true. The change only refuses symlinksthat escape the declared workspace boundary.
autoDetectSkillsDir's priority ordering is unchanged. The~/.openclaw/home fallback, the
findRepoRootwalk, and the./skillscwdfallback all use the same hardened helper now, so they get the same
guarantee as the env-var path.
Test coverage
test/repo-root.test.tsadds four cases:workspace/skillssymlinked to an outside directory →autoDetectSkillsDirreturnsdir=null(refusal)workspace/skillssymlinked toworkspace/_real-skills→accepted, descendant containment respected
isPathContainedunit: equal/descendant pass, sibling/parent failisPathContainedunit: symlink that escapes via realpath rejectedFull
repo-root.test.ts(16/16) and relatedcheck-resolvable-cliregression-v0_16_4tests (40/40 across 3 files) all pass.bun run typecheckclean.Why this matters
The convenience of letting
\$OPENCLAW_WORKSPACEpoint anywhere hasa 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/skillsthat stays inside its declared boundary, continuesto work. Only escapes are refused.