The Problem: MCP File Tools and Path Traversal

Large language model (LLM) servers often expose file I/O tools like read_file, write_file, or list_dir. These tools, while powerful, present a significant security risk if not properly secured. A malicious actor can craft input to the model that tricks these tools into accessing or manipulating files outside their intended scope. The most common and dangerous attack vector is path traversal, where an attacker attempts to access sensitive system files such as /etc/passwd.

The danger is acute: if your MCP server has a read_file tool, it's a single, carefully constructed prompt away from leaking critical system information. The model, acting as an intermediary, can be manipulated to output the contents of any file the server's process has read access to. This isn't a hypothetical risk; it's a direct consequence of exposing file system operations without robust safeguards.

Why Naive Fixes Fail

Many developers attempt to secure these file I/O operations with seemingly straightforward checks. However, these methods frequently fall short when faced with sophisticated attack techniques, particularly those involving symbolic links (symlinks) and absolute paths.

Consider the common approach of checking if a user-provided path starts with a predefined base directory. This seems logical: if the path doesn't begin with the allowed base, reject it. However, this fails spectacularly. An attacker can provide a path like /base/../etc/passwd. While /base/../etc/passwd, when normalized, becomes /etc/passwd, the initial string check /base/../etc/passwd.startswith('/base') evaluates to True. The naive prefix check is bypassed because it operates on the raw, unnormalized input string, not the resolved path.

Another common but insufficient defense is using os.path.normpath. This function normalizes a path, resolving components like . and ... While useful, it doesn't inherently prevent traversal if the target path is an absolute path or if symlinks are involved. For instance, if the base directory is /var/www/data and an attacker provides /etc/passwd, os.path.normpath will correctly resolve it to /etc/passwd, but the initial check might not prevent this absolute path from being used if the normalization happens too late.

The most insidious bypass comes from symlinks. Imagine a scenario where the legitimate base directory /var/www/data contains a symlink named config that points to /etc/myapp/config. An attacker could request /var/www/data/config/../sensitive.conf. If the server blindly follows the symlink and then processes the path, it could inadvertently read a file within /etc/myapp/ or worse, traverse up from there. These "obvious" fixes are like putting a lock on your front door while leaving the back window wide open.

Diagram illustrating path traversal attack with symlink bypass

A Robust Guard: Resolving Paths and Checking Against the Base

A guard that truly survives symlinks and absolute path attacks requires a more rigorous approach. The core idea is to resolve the user-provided path to its absolute, canonical form after ensuring it's not an absolute path itself, and then checking if this resolved path falls within the intended base directory. This involves several steps:

  1. Reject Absolute Paths Early: The first line of defense is to reject any user-provided path that is already an absolute path (i.e., starts with '/'). This prevents direct attempts to specify system root directories.
  2. Construct the Full Path: Combine the base directory with the user-provided relative path.
  3. Resolve Symlinks and Normalize: Use a function that not only normalizes the path (like os.path.normpath) but also resolves any symbolic links. This is crucial. Functions like os.path.realpath in Python do this. They follow symlinks until they reach a non-symlink path component.
  4. Canonicalize and Check: After resolving the path, ensure it's in a canonical form and then check if this fully resolved, canonical path starts with the canonicalized base directory.

Let's illustrate with Python code. The key is to use os.path.realpath and ensure the base directory is also resolved to its real path for a fair comparison.

import os

def is_safe_path(base_dir, user_path):
    # Ensure base_dir is absolute and resolved once
    real_base_dir = os.path.realpath(base_dir)

    # 1. Reject absolute paths from user input immediately
    if os.path.isabs(user_path):
        return False

    # 2. Construct the full path
    full_path = os.path.join(base_dir, user_path)

    # 3. Resolve symlinks and normalize
    real_full_path = os.path.realpath(full_path)

    # 4. Canonicalize and Check: Ensure the resolved path starts with the resolved base directory
    return real_full_path.startswith(real_base_dir)

This function ensures that even if user_path contains .. sequences or points to a symlink, the final real_full_path, which represents the actual file system location after all resolutions, is firmly within the intended real_base_dir. It's like checking not just the address on the envelope, but also following the directions on the map to make sure the destination is within the allowed neighborhood.

Regression Testing: Keeping the Guard Honest

Security is not a one-time fix; it's an ongoing process. To ensure this path traversal guard remains effective, robust regression testing is essential. The original article highlights the importance of a comprehensive test suite that covers various attack vectors.

These tests should include:

  • Basic traversals: ../etc/passwd, ../../etc/passwd.
  • Symlink traversals: Paths that use symlinks to point outside the base directory. This requires setting up test directories with symlinks pointing to sensitive locations (e.g., /etc/passwd, or other files within the test suite itself).
  • Absolute paths: Direct attempts to access /etc/passwd or similar.
  • Edge cases: Paths with unusual characters, empty paths, or paths that normalize to the base directory itself.
  • Windows paths: If applicable, tests for Windows-style path separators and drive letters.

A particularly clever test case involves creating a symlink within the base directory that points to a file *outside* the base directory. For example, if BASE = '/data/files', you could create a symlink /data/files/etc_passwd_link pointing to /etc/passwd. A malicious request could then be /data/files/etc_passwd_link/../../../../etc/passwd. The guard must correctly identify this as an attempt to traverse out of /data/files, even though the initial part of the path involves a symlink.

The regression test suite acts as a digital watchdog. It continuously verifies that the security guard behaves as expected, catching any regressions or new vulnerabilities introduced by future code changes. Without this, even the most well-intentioned security measure can become a false sense of security.

Implications for LLM Security

The vulnerability addressed here is not unique to MCP file tools. Any system that exposes file system operations through an LLM interface, or indeed any API, must implement similar robust path validation. This includes tools for code execution, data retrieval, or any function that interacts with the file system based on user-provided input.

The trend towards more capable LLMs with access to system tools necessitates a heightened focus on security. Developers building these tools must understand that LLMs can be manipulated in ways traditional software interfaces cannot. The input is not just data; it's a potential command. Therefore, input sanitization and path validation must be exceptionally rigorous, treating every user-provided path as potentially malicious until proven otherwise through deep, context-aware checks.

This robust guard, combined with comprehensive regression testing, provides a strong defense against path traversal attacks. It's a critical step for any developer exposing file system access through LLM-driven interfaces, ensuring that powerful tools remain secure and do not become vectors for data breaches.