DEV Community

MCP Security Notes
MCP Security Notes

Posted on

A path-traversal guard for MCP file tools that actually survives symlinks

If your MCP server exposes a read_file / write_file / list_dir tool, it is one clever prompt away from serving /etc/passwd to whoever controls the model's input. The naive fixes — prefix checks, os.path.normpath, stripping .. — all fail against symlinks and absolute paths. Here is a guard that holds, plus the regression test that keeps it holding.

Why the obvious fixes leak

# BROKEN 1: prefix check on the raw string
if not user_path.startswith(BASE):   # "/base/../etc/passwd".startswith("/base") is True
    reject()

# BROKEN 2: normpath, then join
open(os.path.join(BASE, os.path.normpath(user_path)))  # normpath doesn't resolve symlinks
Enter fullscreen mode Exit fullscreen mode

normpath is pure string math. A symlink inside BASE that points to / turns a "safe" relative path into a full-filesystem read. Absolute paths (/etc/passwd) sail straight through a join in many languages.

The guard: resolve first, then contain

from pathlib import Path

def resolve_within(base: str, user_path: str) -> Path | None:
    base_p = Path(base).resolve(strict=True)      # canonical, symlinks followed
    target = (base_p / user_path).resolve(strict=False)
    # containment check that works for base itself and everything under it
    if target == base_p or base_p in target.parents:
        return target
    return None                                   # REFUSE — never clamp/repair
Enter fullscreen mode Exit fullscreen mode

Two rules that matter more than the code:

  1. Refuse, don't repair. Sanitizing (user_path.replace("..","")) is where the regression re-opens six months later. Return None and error out.
  2. .resolve() on the target, not just the base. Resolving the target is what collapses .. and dereferences symlinks so the containment check sees the real destination.

The test that keeps it closed

A guard without a regression test rots. Fire the actual attacker payloads at it:

import pytest
from mymcp.paths import resolve_within

BASE = "/srv/sandbox"

@pytest.mark.parametrize("evil", [
    "../../../../etc/passwd",
    "/etc/passwd",
    "..%2f..%2fetc%2fpasswd",     # if you url-decode before calling, test the decoded form too
    "sub/../../etc/passwd",
    "./././../etc/shadow",
])
def test_traversal_refused(evil):
    assert resolve_within(BASE, evil) is None

def test_symlink_escape_refused(tmp_path):
    base = tmp_path / "sandbox"; base.mkdir()
    (base / "link").symlink_to("/etc")            # symlink out of the sandbox
    assert resolve_within(str(base), "link/passwd") is None

def test_legit_path_allowed(tmp_path):
    base = tmp_path / "sandbox"; base.mkdir()
    (base / "notes.txt").write_text("ok")
    assert resolve_within(str(base), "notes.txt") is not None
Enter fullscreen mode Exit fullscreen mode

If test_symlink_escape_refused passes, you have beaten the class of bug that string-based guards miss.

One more: this is per-call-site, not per-app

The regression that bites is a second file tool added later that opens paths directly and forgets to route through resolve_within. Grep every release:

grep -rnE "open\(|Path\(|send_file|shutil\.(copy|move)" src/ | grep -v resolve_within
Enter fullscreen mode Exit fullscreen mode

Every hit is a call site to audit.


This is one guard of six I keep in a hardening kit for MCP servers — path containment, argv-only subprocess, safe deserialization, an SSRF resolver for fetch_url tools, input bounds, and a pre-deploy grep+payload checklist with tests. If you want the whole set as copy-paste code, it's the MCP Server Security Hardening Kit ($19). The guard and tests above are yours free — ship them today.


Free tool: paste your MCP server's tool code into the MCP Server Security Scanner and get instant findings across all six vuln classes — path traversal, command injection, unsafe deserialization, SSRF, hardcoded secrets and input bounds. 100% client-side, your code never leaves the browser.

Top comments (0)