Skip to content

🔒 Fix path traversal in FileSystemContext::write_content#130

Open
bashandbone wants to merge 1 commit intomainfrom
security-fix-path-traversal-write-content-11773844134454313898
Open

🔒 Fix path traversal in FileSystemContext::write_content#130
bashandbone wants to merge 1 commit intomainfrom
security-fix-path-traversal-write-content-11773844134454313898

Conversation

@bashandbone
Copy link
Contributor

@bashandbone bashandbone commented Mar 23, 2026

🎯 What: A path traversal vulnerability was identified in FileSystemContext::write_content and FileSystemContext::read_content. The destination/source parameters were joined to base_path without validation, allowing access to arbitrary files on the system using ".." or absolute paths.

⚠️ Risk: An attacker could read or overwrite sensitive files outside the intended base directory, potentially leading to information disclosure or remote code execution.

🛡️ Solution: Implemented a secure_path helper method that lexically validates input paths by:

  1. Blocking absolute paths (prefixes and root directories).
  2. Tracking path depth and rejecting components that would escape the base directory (e.g., leading ".." or "dir/../../").
  3. Refactored both read_content and write_content to use this validation.
  4. Added unit tests covering valid paths, absolute paths, and various traversal attack patterns.

PR created automatically by Jules for task 11773844134454313898 started by @bashandbone

Summary by Sourcery

Harden file reading and writing in FileSystemContext to prevent path traversal outside the configured base directory.

Bug Fixes:

  • Prevent read_content and write_content from accepting absolute or traversing paths that escape the base directory.

Enhancements:

  • Introduce a secure_path helper that lexically validates paths before joining them with the base directory.

Tests:

  • Add unit tests covering valid paths, absolute paths, and path traversal attack patterns for FileSystemContext.

Implement lexical path validation in `FileSystemContext` to prevent
unauthorized file access and overwrites outside the base directory.
Add unit tests to verify the fix against absolute paths and traversal
attacks.

Co-authored-by: bashandbone <89049923+bashandbone@users.noreply.github.com>
Copilot AI review requested due to automatic review settings March 23, 2026 14:48
@google-labs-jules
Copy link
Contributor

👋 Jules, reporting for duty! I'm here to lend a hand with this pull request.

When you start a review, I'll add a 👀 emoji to each comment to let you know I've read it. I'll focus on feedback directed at me and will do my best to stay out of conversations between you and other bots or reviewers to keep the noise down.

I'll push a commit with your requested changes shortly after. Please note there might be a delay between these steps, but rest assured I'm on the job!

For more direct control, you can switch me to Reactive Mode. When this mode is on, I will only act on comments where you specifically mention me with @jules. You can find this option in the Pull Request section of your global Jules UI settings. You can always switch back!

New to Jules? Learn more at jules.google/docs.


For security, I will only act on instructions from the user who triggered this task.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Mar 23, 2026

Reviewer's Guide

Adds lexical path validation to FileSystemContext to prevent path traversal and updates read/write operations and tests accordingly.

Sequence diagram for write_content with secure_path validation

sequenceDiagram
    participant Caller
    participant FileSystemContext
    participant secure_path
    participant FileSystem

    Caller->>FileSystemContext: write_content(destination, content)
    FileSystemContext->>secure_path: secure_path(destination)
    alt path is invalid
        secure_path-->>FileSystemContext: Err(ServiceError)
        FileSystemContext-->>Caller: Err(ServiceError)
    else path is valid
        secure_path-->>FileSystemContext: Ok(valid_path)
        FileSystemContext->>FileSystem: create_dir_all(parent_of(valid_path))
        FileSystem-->>FileSystemContext: Result<(), io::Error>
        FileSystemContext->>FileSystem: write(valid_path, content)
        FileSystem-->>FileSystemContext: Result<(), io::Error>
        FileSystemContext-->>Caller: Ok(())
    end
Loading

Class diagram for updated FileSystemContext with secure path validation

classDiagram
    class FileSystemContext {
        - base_path : PathBuf
        + new(base_path : &Path) FileSystemContext
        - secure_path(source : &str) Result<PathBuf, ServiceError>
        + read_content(source : &str) Result<String, ServiceError>
        + write_content(destination : &str, content : &str) Result<(), ServiceError>
    }

    class ExecutionContext {
        <<interface>> ExecutionContext
        + read_content(source : &str) Result<String, ServiceError>
        + write_content(destination : &str, content : &str) Result<(), ServiceError>
    }

    FileSystemContext ..|> ExecutionContext

    class ServiceError {
        + execution_dynamic(message : String) ServiceError
    }
Loading

Flow diagram for secure_path lexical validation logic

flowchart TD
    A["start secure_path(source)"] --> B["parse source into path components"]
    B --> C["set depth = 0"]
    C --> D{"next component?"}
    D -->|no| Z["return base_path.join(source)"]
    D -->|yes| E{"component type"}

    E -->|Prefix or RootDir| F["return Err(ServiceError: absolute paths not allowed)"]
    E -->|CurDir| D
    E -->|ParentDir| G{"depth == 0?"}
    G -->|yes| H["return Err(ServiceError: traversal outside base path)"]
    G -->|no| I["depth = depth - 1"]
    I --> D
    E -->|Normal| J["depth = depth + 1"]
    J --> D
Loading

File-Level Changes

Change Details Files
Introduce secure_path helper to lexically validate and join paths under base_path, preventing traversal and absolute path usage.
  • Add secure_path(&self, source: &str) -> Result<PathBuf, ServiceError> that iterates over std::path::Component values to enforce security rules.
  • Reject absolute paths by returning an execution_dynamic ServiceError when encountering Component::Prefix or Component::RootDir.
  • Track directory depth for Component::Normal and Component::ParentDir, rejecting paths that would escape the base directory when depth would go below zero.
  • Return a path constructed as base_path.join(source) after successful validation.
crates/services/src/lib.rs
Refactor FileSystemContext read/write operations to use secure_path and extend tests to cover valid and malicious paths.
  • Change ExecutionContext::read_content to resolve source using secure_path before reading file contents.
  • Change ExecutionContext::write_content to resolve destination using secure_path before creating parent directories and writing contents.
  • Add test_file_system_context_security test that constructs a FileSystemContext rooted at the temp directory and asserts behavior for valid relative paths, absolute paths (including Windows prefixes), and traversal attempts using .. segments.
crates/services/src/lib.rs

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

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

Hey - I've found 1 issue, and left some high level feedback:

  • In secure_path, you validate components but then join the original source string instead of a normalized/validated PathBuf; consider reconstructing the path from the iterated components to avoid any divergence between what was validated and what is ultimately used.
  • The current implementation allows an empty string as source/destination (resulting in base_path itself); if this is not intended, add an explicit check in secure_path to reject or handle empty paths.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `secure_path`, you validate components but then join the original `source` string instead of a normalized/validated `PathBuf`; consider reconstructing the path from the iterated components to avoid any divergence between what was validated and what is ultimately used.
- The current implementation allows an empty string as `source`/`destination` (resulting in `base_path` itself); if this is not intended, add an explicit check in `secure_path` to reject or handle empty paths.

## Individual Comments

### Comment 1
<location path="crates/services/src/lib.rs" line_range="151-160" />
<code_context>
+    /// Lexically validate path to prevent traversal attacks
</code_context>
<issue_to_address>
**🚨 issue (security):** Lexical validation still allows escapes via symlinks inside the base directory.

The lexical depth check only blocks `..` segments; it doesn’t stop escapes via symlinks under `base_path`. For instance, if `base_path/sub` points to `/etc`, then `source = "sub/passwd"` passes but resolves to `/etc/passwd`.

If untrusted content can exist under `base_path`, you should resolve the final path (e.g. with `canonicalize`) and confirm it still resides under a canonicalized `base_path` before using it. That closes the symlink escape while retaining the lexical checks.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +151 to +160
/// Lexically validate path to prevent traversal attacks
fn secure_path(&self, source: &str) -> Result<std::path::PathBuf, ServiceError> {
use std::path::Component;

let path = Path::new(source);
let mut depth = 0;

for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
Copy link
Contributor

Choose a reason for hiding this comment

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

🚨 issue (security): Lexical validation still allows escapes via symlinks inside the base directory.

The lexical depth check only blocks .. segments; it doesn’t stop escapes via symlinks under base_path. For instance, if base_path/sub points to /etc, then source = "sub/passwd" passes but resolves to /etc/passwd.

If untrusted content can exist under base_path, you should resolve the final path (e.g. with canonicalize) and confirm it still resides under a canonicalized base_path before using it. That closes the symlink escape while retaining the lexical checks.

Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

Pull request overview

Mitigates a reported path traversal issue in the FileSystemContext execution context by adding path validation before reading/writing files under a configured base directory.

Changes:

  • Added FileSystemContext::secure_path to reject absolute paths and lexical .. traversal.
  • Refactored read_content / write_content to use secure_path.
  • Added unit tests for valid paths, absolute paths, and common traversal patterns.
Comments suppressed due to low confidence (1)

crates/services/src/lib.rs:195

  • write_content calls create_dir_all(parent) on the path returned by secure_path. If any path component under base_path is a symlink, this can still create directories and write files outside base_path (symlink traversal). If you keep secure_path lexical, add a filesystem-level guard here (e.g., validate the parent path is within canonicalized base and that no intermediate component is a symlink) before creating dirs/writing.
        let path = self.secure_path(destination)?;
        if let Some(parent) = path.parent() {
            std::fs::create_dir_all(parent)?;
        }
        Ok(std::fs::write(path, content)?)

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +151 to +180
/// Lexically validate path to prevent traversal attacks
fn secure_path(&self, source: &str) -> Result<std::path::PathBuf, ServiceError> {
use std::path::Component;

let path = Path::new(source);
let mut depth = 0;

for component in path.components() {
match component {
Component::Prefix(_) | Component::RootDir => {
return Err(ServiceError::execution_dynamic(format!(
"Absolute paths are not allowed: {source}"
)));
}
Component::CurDir => {}
Component::ParentDir => {
if depth == 0 {
return Err(ServiceError::execution_dynamic(format!(
"Path traversal outside of base path is not allowed: {source}"
)));
}
depth -= 1;
}
Component::Normal(_) => {
depth += 1;
}
}
}

Ok(self.base_path.join(source))
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

secure_path only performs lexical ../absolute checks. It still allows escaping base_path via symlinks inside the base (e.g., base/link -> / then secure_path("link/etc/passwd") passes and read_to_string reads /etc/passwd). Consider resolving/validating against base_path on the filesystem (canonicalize + starts_with for reads, and for writes ensure each existing path segment isn’t a symlink / use no-follow semantics) before returning the final path.

Copilot uses AI. Check for mistakes.
assert!(ctx.secure_path("../test.txt").is_err());
assert!(ctx.secure_path("dir/../../test.txt").is_err());
assert!(ctx.secure_path("dir/../inc/../../test.txt").is_err());
}
Copy link

Copilot AI Mar 23, 2026

Choose a reason for hiding this comment

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

The new tests cover lexical .. and absolute paths, but don’t cover symlink-based traversal (e.g., link -> /) or assert the higher-level read_content/write_content behavior. Adding a #[cfg(unix)] test that creates a temp dir, a symlink inside it, and verifies reads/writes via that symlink are rejected would prevent regressions of the remaining traversal vector.

Suggested change
}
}
#[cfg(unix)]
#[test]
fn test_file_system_context_symlink_traversal() {
use std::fs;
use std::os::unix::fs as unix_fs;
// Create a temporary root directory for the FileSystemContext.
let root_dir = tempfile::tempdir().expect("failed to create temp root dir");
// Create a separate directory outside the root with a file in it.
let external_dir = tempfile::tempdir().expect("failed to create external temp dir");
let external_file_path = external_dir.path().join("secret.txt");
fs::write(&external_file_path, "top secret").expect("failed to write external file");
// Inside the root, create a symlink pointing to the external directory.
let link_path = root_dir.path().join("link");
unix_fs::symlink(external_dir.path(), &link_path)
.expect("failed to create symlink to external dir");
// Initialize the context rooted at the temporary directory.
let ctx = FileSystemContext::new(root_dir.path());
// Attempt to read a file via the symlink; this should be rejected.
let read_result = ctx.read_content("link/secret.txt");
assert!(
read_result.is_err(),
"reading via symlink that escapes root should be rejected"
);
// Attempt to write a file via the symlink; this should also be rejected.
let write_result = ctx.write_content("link/secret.txt", "hacked");
assert!(
write_result.is_err(),
"writing via symlink that escapes root should be rejected"
);
}

Copilot uses AI. Check for mistakes.
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.

2 participants