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.
if not user_path.startswith(BASE): # "/base/../etc/passwd".startswith("/base") is True
reject()
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.
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)
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:
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 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.