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
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
Two rules that matter more than the code:
-
Refuse, don't repair. Sanitizing (
user_path.replace("..","")) is where the regression re-opens six months later. ReturnNoneand error out. -
.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
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
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)