DEV Community

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 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. ReturnNone and 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)

Read on DEV Community ↗ ← Back to News

Comments

No comments yet. Start the discussion.