Skip to content

fix: block symlink escapes in path policy#2034

Closed
LawyerLyu wants to merge 3 commits into
tinyhumansai:mainfrom
LawyerLyu:fix/security-policy-symlink-paths
Closed

fix: block symlink escapes in path policy#2034
LawyerLyu wants to merge 3 commits into
tinyhumansai:mainfrom
LawyerLyu:fix/security-policy-symlink-paths

Conversation

@LawyerLyu
Copy link
Copy Markdown
Contributor

@LawyerLyu LawyerLyu commented May 18, 2026

Summary

  • Harden SecurityPolicy::is_path_allowed against workspace symlink escapes.
  • Resolve the existing path, or nearest existing parent, for workspace-relative paths before allowing them.
  • Add a Unix regression test covering both a symlink path and a not-yet-created child under a symlinked directory.

Problem

Issue #1927 reports that is_path_allowed() validates the path string but does not resolve symlinks. A path such as escape_link can look workspace-relative while resolving outside the workspace, leaving callers dependent on remembering a separate is_resolved_path_allowed() check.

Solution

  • Add a small helper that canonicalizes the path if it exists, or walks upward to canonicalize the nearest existing parent.
  • In workspace-only mode, join relative paths to workspace_dir and reject them if the resolved existing prefix is outside the workspace.
  • Keep non-existing normal workspace paths allowed so write/create flows still work.

Submission Checklist

  • Tests added or updated (happy path + at least one failure / edge case) per Testing Strategy
  • Diff coverage ≥ 80% — focused regression test covers the new symlink guard; CI Coverage Gate will report the exact diff coverage.
  • Coverage matrix updated — N/A: security policy hardening; no feature row added/removed/renamed.
  • All affected feature IDs from the matrix are listed in the PR description under ## Related — N/A: no matrix feature ID applies.
  • No new external network dependencies introduced (mock backend used per Testing Strategy)
  • Manual smoke checklist updated if this touches release-cut surfaces (docs/RELEASE-MANUAL-SMOKE.md) — N/A: no release manual smoke surface changed.
  • Linked issue closed via Closes #NNN in the ## Related section

Impact

Workspace-only file policy now rejects symlink paths that resolve outside the configured workspace even when callers only use is_path_allowed(). Normal relative paths and non-existing create targets inside the workspace remain allowed.

No migration or dependency impact.

Related


AI Authored PR Metadata (required for Codex/Linear PRs)

Keep this section for AI-authored PRs. For human-only PRs, mark each field N/A.

Linear Issue

  • Key: N/A
  • URL: N/A

Commit & Branch

  • Branch: fix/security-policy-symlink-paths
  • Commit SHA: 4a43f9d

Validation Run

  • cargo test -p openhuman is_path_allowed_blocks_workspace_symlink_escape --lib
  • cargo fmt --manifest-path Cargo.toml --all --check
  • Full pre-push hook: git push -u fork fix/security-policy-symlink-paths (format, lint, compile, Rust check, command token lint)
  • Rust fmt/check (if changed): passed via focused fmt check and pre-push hook.
  • Tauri fmt/check (if changed): passed via pre-push hook; no Tauri files changed.

Validation Blocked

  • command: N/A
  • error: N/A
  • impact: N/A

Behavior Changes

  • Intended behavior change: reject workspace-relative symlink paths whose existing target/prefix resolves outside the workspace.
  • User-visible effect: tool calls that attempt to traverse symlinks out of the workspace are blocked earlier.

Parity Contract

  • Legacy behavior preserved: normal workspace-relative paths, dotfiles in the workspace, and not-yet-created files under real workspace parents remain allowed.
  • Guard/fallback/dispatch parity checks: is_resolved_path_allowed() remains available for callers that already canonicalize; this PR adds defense in depth to is_path_allowed().

Duplicate / Superseded PR Handling

  • Duplicate PR(s): N/A
  • Canonical PR: this PR
  • Resolution (closed/superseded/updated): N/A

Summary by CodeRabbit

  • Security

    • Enhanced path validation to block attempts to access files outside the configured workspace, including symlink-based escapes and traversal cases.
  • Tests

    • Added a regression test validating symlink escape prevention.
    • Updated existing file-read/write tests to accept broader error outcomes from earlier path validation while preserving checks that outside files remain unmodified.

Review Change Stack

@LawyerLyu LawyerLyu requested a review from a team May 18, 2026 02:35
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 18, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 85daca28-f488-4fea-a719-52e09f25be89

📥 Commits

Reviewing files that changed from the base of the PR and between 4a43f9d and 9df9c8a.

📒 Files selected for processing (2)
  • src/openhuman/tools/impl/filesystem/file_read.rs
  • src/openhuman/tools/impl/filesystem/file_write.rs
✅ Files skipped from review due to trivial changes (1)
  • src/openhuman/tools/impl/filesystem/file_read.rs

📝 Walkthrough

Walkthrough

Adds a helper that finds and canonicalizes the nearest existing parent of a path, and uses it in SecurityPolicy::is_path_allowed (when workspace_only) to reject resolved prefixes that escape the configured workspace. Adds a Unix-only regression test and relaxes several symlink-related test assertions to accept earlier generic denial messages.

Changes

Symlink Escape Prevention in Path Validation

Layer / File(s) Summary
Path resolution helper and workspace-boundary check
src/openhuman/security/policy.rs
Adds resolve_existing_path_prefix that climbs to the nearest existing filesystem entry and canonicalizes it; updates SecurityPolicy::is_path_allowed (workspace_only) to join candidate paths with workspace_dir, resolve the nearest existing prefix, and reject the path if the resolved prefix is not allowed via is_resolved_path_allowed.
Regression test for symlink escape blocking
src/openhuman/security/policy_tests.rs
Adds a Unix-only test is_path_allowed_blocks_workspace_symlink_escape that symlinks from the workspace to an outside directory and asserts is_path_allowed denies both the symlink and paths under it.
Test assertion relaxations for symlink rejection
src/openhuman/tools/impl/filesystem/file_read.rs, src/openhuman/tools/impl/filesystem/file_write.rs
Loosens several Unix test error-message assertions to accept either earlier generic "not allowed" rejections or the previous "escapes workspace"/"symlink" wording, reflecting earlier rejection when resolved-prefix checks run sooner.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • tinyhumansai/openhuman#1904: Both PRs tighten path-traversal prevention by adding/changing canonicalized within-root validation logic in path-checking functions to prevent escapes.

Suggested reviewers

  • senamakel

Poem

🐰 I hopped the path to check each link and stone,
I climbed the parents till a real root was known.
I canonicalized, then gave a tiny shout—
"No sneaky symlink can wriggle out!" 🥕

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'fix: block symlink escapes in path policy' directly and accurately summarizes the main change: adding symlink escape blocking to the path security policy.
Linked Issues check ✅ Passed The PR implementation resolves Issue #1927 by adding symlink canonicalization to is_path_allowed(), merging the checks and ensuring workspace-relative paths cannot escape via symlinks.
Out of Scope Changes check ✅ Passed All changes are directly related to the symlink escape security fix: policy.rs adds canonicalization logic, policy_tests.rs adds regression test, and filesystem tests update assertions for new error messages.
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🧹 Nitpick comments (1)
src/openhuman/security/policy.rs (1)

735-742: ⚡ Quick win

Log resolved-prefix rejections.

This new deny path returns false silently, so a symlink-escape block is hard to distinguish from traversal or forbidden-path failures during debugging. A structured debug!/trace! here with the candidate and resolved prefix would make these decisions much easier to audit.

As per coding guidelines, "Use log / tracing at debug or trace level on RPC entry and exit, error paths, state transitions, and any branch that is hard to infer from tests alone."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/security/policy.rs` around lines 735 - 742, Add a debug/trace
log when a resolved prefix is rejected in the workspace-only check: inside the
block where you compute candidate = self.workspace_dir.join(&expanded) and
obtain resolved via resolve_existing_path_prefix(&candidate), if
!self.is_resolved_path_allowed(&resolved) emit a debug! or trace! log that
includes the candidate, the resolved path (resolved), and a short reason like
"resolved prefix not allowed" before returning false; ensure you use the
existing crate tracing/log macros consistent with surrounding code and avoid
changing control flow or return value.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/openhuman/security/policy.rs`:
- Around line 386-395: The resolve_existing_path_prefix function currently uses
current.exists() which follows symlinks and treats dangling symlinks as
non-existent; change the loop to use current.symlink_metadata() to detect if the
path itself exists (including as a symlink), and if it is a symlink use
current.read_link() to determine whether the symlink points inside the allowed
prefix (or canonicalize the symlink target when possible) before deciding to
return current.canonicalize().ok(); if symlink_metadata() indicates a dangling
symlink, treat that path as an existing prefix (return its canonicalized parent
or the symlink path itself as appropriate) instead of climbing to parent(), and
update callers like is_path_allowed to rely on the revised
resolve_existing_path_prefix behavior; also add a regression test that creates a
dangling symlink (e.g., escape_link -> /tmp/outside.txt) and verifies
is_path_allowed("escape_link") no longer allows an escape.

---

Nitpick comments:
In `@src/openhuman/security/policy.rs`:
- Around line 735-742: Add a debug/trace log when a resolved prefix is rejected
in the workspace-only check: inside the block where you compute candidate =
self.workspace_dir.join(&expanded) and obtain resolved via
resolve_existing_path_prefix(&candidate), if
!self.is_resolved_path_allowed(&resolved) emit a debug! or trace! log that
includes the candidate, the resolved path (resolved), and a short reason like
"resolved prefix not allowed" before returning false; ensure you use the
existing crate tracing/log macros consistent with surrounding code and avoid
changing control flow or return value.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 8bb9c54b-cd2d-47fc-894e-905af6e60fe9

📥 Commits

Reviewing files that changed from the base of the PR and between 63ea7f6 and 4a43f9d.

📒 Files selected for processing (2)
  • src/openhuman/security/policy.rs
  • src/openhuman/security/policy_tests.rs

Comment on lines +386 to +395
fn resolve_existing_path_prefix(path: &Path) -> Option<PathBuf> {
let mut current = path;
loop {
if current.exists() {
return current.canonicalize().ok();
}

current = current.parent()?;
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

In Rust's standard library, does std::path::Path::exists()follow symlinks and returnfalse for dangling symlinks? What APIs should be used to detect an existing symlink itself (symlink_metadata) and read its target (read_link)?

💡 Result:

Yes, the std::path::Path::exists() method in Rust follows symbolic links [1]. If a path is a dangling symlink (a symlink pointing to a non-existent target), exists() will return false [1][2]. To detect a symlink itself without following it, or to read its target, you should use the following APIs from the std::fs module: 1. To detect if a path is a symlink: Use std::fs::symlink_metadata(path) to retrieve the file's metadata without following the link [2][3][4]. You can then call .file_type().is_symlink() on the returned Metadata object [3][4]. 2. To read the target of a symlink: Use std::fs::read_link(path), which returns a PathBuf containing the target path [2][3]. Note that this function will return an error if the path is not a symbolic link or does not exist [2]. For checking existence in a way that is less error-prone than Path::exists(), consider using std::fs::exists(path), which returns a Result<bool> and explicitly handles cases where existence cannot be determined (e.g., due to permission errors) [5]. Like Path::exists(), std::fs::exists() also follows symlinks and returns false for broken ones [5].

Citations:


Handle dangling symlinks as existing prefixes.

Line 389 uses current.exists(), which follows symlinks. A workspace symlink like escape_link -> /tmp/outside.txt is treated as "missing" when the target doesn't exist, causing the loop to climb to the workspace root and return true from is_path_allowed("escape_link"). This creates a write/create escape outside the workspace for dangling symlink targets. Use symlink_metadata() to detect the symlink itself and read_link() to resolve the target before falling back to parent(). Also add a regression test for dangling symlink targets.

Possible fix sketch
 fn resolve_existing_path_prefix(path: &Path) -> Option<PathBuf> {
     let mut current = path;
     loop {
-        if current.exists() {
-            return current.canonicalize().ok();
+        match std::fs::symlink_metadata(current) {
+            Ok(meta) if meta.file_type().is_symlink() => {
+                let target = std::fs::read_link(current).ok()?;
+                let target = if target.is_absolute() {
+                    target
+                } else {
+                    current.parent()?.join(target)
+                };
+                return resolve_existing_path_prefix(&target);
+            }
+            Ok(_) => return current.canonicalize().ok(),
+            Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
+            Err(_) => return None,
         }
 
         current = current.parent()?;
     }
 }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn resolve_existing_path_prefix(path: &Path) -> Option<PathBuf> {
let mut current = path;
loop {
if current.exists() {
return current.canonicalize().ok();
}
current = current.parent()?;
}
}
fn resolve_existing_path_prefix(path: &Path) -> Option<PathBuf> {
let mut current = path;
loop {
match std::fs::symlink_metadata(current) {
Ok(meta) if meta.file_type().is_symlink() => {
let target = std::fs::read_link(current).ok()?;
let target = if target.is_absolute() {
target
} else {
current.parent()?.join(target)
};
return resolve_existing_path_prefix(&target);
}
Ok(_) => return current.canonicalize().ok(),
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {}
Err(_) => return None,
}
current = current.parent()?;
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/openhuman/security/policy.rs` around lines 386 - 395, The
resolve_existing_path_prefix function currently uses current.exists() which
follows symlinks and treats dangling symlinks as non-existent; change the loop
to use current.symlink_metadata() to detect if the path itself exists (including
as a symlink), and if it is a symlink use current.read_link() to determine
whether the symlink points inside the allowed prefix (or canonicalize the
symlink target when possible) before deciding to return
current.canonicalize().ok(); if symlink_metadata() indicates a dangling symlink,
treat that path as an existing prefix (return its canonicalized parent or the
symlink path itself as appropriate) instead of climbing to parent(), and update
callers like is_path_allowed to rely on the revised resolve_existing_path_prefix
behavior; also add a regression test that creates a dangling symlink (e.g.,
escape_link -> /tmp/outside.txt) and verifies is_path_allowed("escape_link") no
longer allows an escape.

LawyerLyu and others added 2 commits May 18, 2026 05:53
…ssages

The new is_path_allowed() symlink check in policy.rs intercepts symlink
escapes before the canonicalize-based checks in file_read/file_write.
The resulting error message is "Path not allowed by security policy"
rather than "Resolved path escapes workspace" / "Refusing to write
through symlink". Update the three failing test assertions to accept
either message so they correctly validate the block regardless of which
guard fires.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
Copy link
Copy Markdown
Contributor

@graycyrus graycyrus left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Walkthrough

This PR adds symlink-escape protection to is_path_allowed() by resolving the nearest existing path prefix before checking workspace containment. The approach is sound and addresses issue #1927. CodeRabbit already flagged the dangling-symlink gap and the missing debug logging — I won't repeat those. I found one additional security concern in the fail-open logic and a minor test gap.

Change Summary

File Change type Description
src/openhuman/security/policy.rs Security fix Added resolve_existing_path_prefix() helper + integrated into is_path_allowed()
src/openhuman/security/policy_tests.rs Test New is_path_allowed_blocks_workspace_symlink_escape test
src/openhuman/tools/impl/filesystem/file_read.rs Test update Loosened assertion to accept both error messages
src/openhuman/tools/impl/filesystem/file_write.rs Test update Loosened assertions to accept both error messages

Per-file analysis

policy.rsresolve_existing_path_prefix + integration

The helper walks up the path tree until it finds an existing component, then canonicalizes it. Clean, focused logic. The integration into is_path_allowed() correctly gates on workspace_only mode and non-empty paths.

However, the integration uses if let Some(resolved) which silently falls through to return true when None is returned. This is a fail-open pattern in security-critical code. See inline comment.

policy_tests.rs — new regression test

Good test structure following the existing pattern (#[cfg(unix)], temp dirs, cleanup). Covers symlink-to-outside and child-under-symlinked-dir. The test cleanup uses the same pattern as resolved_path_blocks_symlink_escape above it.

file_read.rs / file_write.rs — assertion loosening

Appropriate — the security invariant (operation blocked) is still verified. The comments explaining why the assertions changed are a nice touch.

let candidate = self.workspace_dir.join(&expanded);
if let Some(resolved) = resolve_existing_path_prefix(&candidate) {
if !self.is_resolved_path_allowed(&resolved) {
return false;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[major] Fail-open on None — if resolve_existing_path_prefix returns None (canonicalization fails), this if let silently falls through to return true, allowing the path. Security code should fail closed.

On Unix this is practically unreachable (root always exists and canonicalizes), but a fail-open pattern in a security-sensitive path validation is a footgun. If canonicalize() ever fails (permissions, race condition, future platform), the entire symlink check is silently bypassed.

Suggestion — deny by default:

if self.workspace_only && !path.is_empty() {
    let candidate = self.workspace_dir.join(&expanded);
    match resolve_existing_path_prefix(&candidate) {
        Some(resolved) => {
            if !self.is_resolved_path_allowed(&resolved) {
                return false;
            }
        }
        None => return false, // fail closed
    }
}

let _ = std::fs::remove_dir_all(&root);
}

#[test]
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[minor] Missing positive test case — this test only verifies that escape paths are blocked, but doesn't verify that a normal workspace-relative path still passes after the change. Adding a quick assert!(policy.is_path_allowed("normal-file.txt")) would guard against over-restrictive regressions (e.g., if the None branch from resolve_existing_path_prefix were changed to deny).

@M3gA-Mind
Copy link
Copy Markdown
Contributor

Hey @LawyerLyu — thank you sincerely for taking the time to dig into this vulnerability and put together a fix. The resolve_existing_path_prefix helper is a clean piece of logic and your test structure (temp dirs, cleanup, #[cfg(unix)] gate) follows the existing patterns really well.

We're closing this PR in favour of #2111, which takes the same core idea further: it renames is_path_allowed to is_path_string_allowed to force a compile-time audit of every call site, then introduces a unified async validate_path() / validate_parent_path() API that always canonicalises before checking. That approach also covers two issues the reviewers surfaced here — the dangling-symlink bypass (because Path::exists() follows symlinks and returns false for dangling ones, the walker skips past them to an ancestor inside the workspace) and the fail-open if let Some(...) guard — as well as extending protection to all 8 filesystem tools and fixing a pre-create_dir_all race in file_write and csv_export.

Your PR was genuinely valuable: it helped sharpen exactly what a correct fix needed to handle, and the reviewer feedback it attracted directly informed the final solution. Really appreciate the contribution. 🙏

@M3gA-Mind M3gA-Mind closed this May 19, 2026
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.

Security: Symlink bypass in is_path_allowed() — no canonicalization

3 participants