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

> Source: <https://dev.to/mcpsecnotes/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks-2654>
> Published: 2026-08-11 11:07:34+00:00

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.

```
# 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
```

`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.

``` php
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
```

Two rules that matter more than the code:

`user_path.replace("..","")`

) is where the regression re-opens six months later. Return `None`

and error out.`.resolve()`

on the target, not just the base.`..`

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

``` python
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
```

If `test_symlink_escape_refused`

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

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
```

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](https://smeltworks.com/mcp-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.
