fix(security): prevent tool call path traversal outside project root#136
fix(security): prevent tool call path traversal outside project root#136haoyu-haoyu wants to merge 4 commits intoOpenLAIR:mainfrom
Conversation
LLM-driven tool calls (Read, Write, Glob, Grep, LS) previously resolved paths via path.resolve() without boundary checking. A prompt-injected model could read arbitrary host files (e.g. /etc/passwd, ~/.ssh/id_rsa) via absolute paths or .. traversal, and these read-only tools were auto-approved without user consent. Add safePath() utility that: - Rejects absolute paths outright (they bypass the project root) - Resolves .. components and verifies result stays within root - Follows symlinks via realpathSync to prevent symlink escapes - Throws a clear error message on traversal attempts Replace the unconstrained resolve() in both openrouter.js and cli-chat.js executeTool() functions with safePath(). Includes 7 unit tests covering: relative paths, empty input, absolute paths, .. traversal, disguised traversal, in-root .., and home directory references.
…andling - Allow absolute paths that resolve inside the project root (LLM may reference workspace files by absolute path) - For non-existent targets (Write), resolve the nearest existing ancestor via realpathSync to catch symlink-parent escapes: if repo/link -> /external/, then repo/link/new.txt is blocked because realpath(repo/link) = /external/ which is outside the root - Add test cases for: absolute-in-root, null bytes, home dir traversal
Previous fix only checked one parent level, so repo/link/missing/file.txt could bypass the check when link -> /external/. Now walks upward until finding the first existing ancestor, resolves it via realpathSync, and reconstructs the full path for containment checking. Also fix tests to use realpathSync on ROOT (macOS /var -> /private/var).
Zhang-Henry
left a comment
There was a problem hiding this comment.
Good fix for a real vulnerability. The symlink ancestor-walk logic is well thought out. Two issues to consider before merging.
server/utils/safePath.js
Outdated
| real = fs.realpathSync(resolved); | ||
| } catch { | ||
| // Target doesn't exist — walk up to the nearest existing ancestor | ||
| // and resolve it. This catches symlink escapes at any depth: |
There was a problem hiding this comment.
Issue: Legitimate symlinks inside the project that point outside the root are blocked
The realpathSync resolution means that if a project has an internal symlink like data -> /mnt/storage/data (common for large datasets, shared assets, etc.), then:
safePath('data/file.csv', root)
→ realpathSync resolves to /mnt/storage/data/file.csv
→ outside normalizedRoot
→ throws Error
This blocks Read, Write, Edit, Glob, Grep, and LS on any symlinked path that points outside the project root. This is a behavioral regression that could break legitimate workflows.
Suggested fix: Consider either:
- An allowlist mechanism (e.g.
SAFEPATH_ALLOWED_ROOTSenv var or a.safepath.jsonconfig) for trusted external symlink targets. - Only check the logical resolved path (without
realpathSync) for..traversal and absolute-path escapes, and skip the symlink resolution — this blocks the../../../etc/passwdattack without blocking legitimate symlinks. The symlink-parent-escape scenario (repo/link -> /external/) is much rarer and arguably less of a threat since it requires the user to have created the symlink themselves.
| */ | ||
| export function safePath(userPath, allowedRoot) { | ||
| if (!userPath) return allowedRoot; | ||
|
|
There was a problem hiding this comment.
Minor: Inconsistent return format for falsy input
When userPath is falsy, this returns allowedRoot as-is (the raw path). But for all other inputs, the function returns a realpathSync-resolved path via normalizedRoot.
If the project root itself is a symlink (e.g. /home/user/project -> /mnt/projects/foo), then:
safePath('', root)→/home/user/project(symlink path)safePath('src/a.js', root)→/mnt/projects/foo/src/a.js(real path)
This could cause subtle path-comparison mismatches downstream. Consider normalizing the early return too:
if (!userPath) {
try { return fs.realpathSync(allowedRoot); } catch { return allowedRoot; }
}Address both review comments from @Zhang-Henry: 1. Legitimate symlinks blocked — remove realpathSync resolution and ancestor-walk logic. safePath now performs only a lexical check (path.resolve + prefix comparison), so symlinks inside the project that point outside the root (e.g. data -> /mnt/storage/data) work as expected. The main attack vectors (.. traversal and absolute paths outside root) are still blocked by path.resolve normalization. 2. Inconsistent return for falsy input — now returns path.resolve(root) consistently, matching the normalized format of all other code paths. Added tests for symlink allowance and non-normalized root normalization. Assisted-by: Claude Code
|
Thanks @Zhang-Henry — both issues addressed in 6eeb58f: 1. Legitimate symlinks blocked → fixed Adopted your option 2:
The trade-off (symlink-based escapes not caught) is acceptable since creating a malicious symlink requires the Bash tool which needs user approval. Added a test that creates a symlink to 2. Inconsistent return for falsy input → fixed Now returns The implementation went from 86 lines to 52 lines — much simpler to audit. All 11 tests pass. |
Summary
Add
safePath()utility to constrain all LLM-driven tool call paths within the project root, preventing prompt-injection attacks from reading or writing files outside the workspace.The vulnerability
When an LLM is prompt-injected (e.g., via a malicious README), it can generate tool calls like
Read({ path: "/etc/passwd" })orRead({ path: "../../../.ssh/id_rsa" }). Since read-only tools (Read, Glob, Grep, LS) are auto-approved without user consent, the file contents are silently exfiltrated through the conversation.The fix
New:
server/utils/safePath.js..traversal: Resolved and verified to stay within rootrealpathSync; for non-existent targets, walks up to the nearest existing ancestor to catchrepo/link/missing/deep/file.txtwherelink -> /external/"Path traversal blocked: ... resolves to ... which is outside the project root"Modified:
server/openrouter.js+server/cli-chat.jsReplace the unconstrained
resolve()function in bothexecuteTool()implementations:Tests: 9 cases covering
..traversal above root (blocked) ✅..traversal (blocked) ✅..that stays within root (allowed) ✅Codex review (3 rounds)
Note
This does NOT restrict
Bashtool execution (which has its own permission model viapermissionMode). It only constrains file-system tool calls (Read, Write, Edit, Glob, Grep, LS).