{"slug": "a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks", "title": "A path-traversal guard for MCP file tools that actually survives symlinks", "summary": "A developer released a path-traversal guard for MCP file tools that withstands symlink-based escapes, addressing a critical security flaw in servers exposing read_file, write_file, or list_dir tools. The guard uses canonical path resolution and strict containment checks, refusing to clamp or repair malicious inputs. The developer also provides regression tests and a free security scanner for MCP servers.", "body_md": "If your MCP server exposes a `read_file`\n\n/ `write_file`\n\n/ `list_dir`\n\ntool, it is one clever prompt away from serving `/etc/passwd`\n\nto whoever controls the model's input. The naive fixes — prefix checks, `os.path.normpath`\n\n, stripping `..`\n\n— all fail against symlinks and absolute paths. Here is a guard that holds, plus the regression test that keeps it holding.\n\n```\n# BROKEN 1: prefix check on the raw string\nif not user_path.startswith(BASE):   # \"/base/../etc/passwd\".startswith(\"/base\") is True\n    reject()\n\n# BROKEN 2: normpath, then join\nopen(os.path.join(BASE, os.path.normpath(user_path)))  # normpath doesn't resolve symlinks\n```\n\n`normpath`\n\nis pure string math. A symlink inside `BASE`\n\nthat points to `/`\n\nturns a \"safe\" relative path into a full-filesystem read. Absolute paths (`/etc/passwd`\n\n) sail straight through a join in many languages.\n\n``` php\nfrom pathlib import Path\n\ndef resolve_within(base: str, user_path: str) -> Path | None:\n    base_p = Path(base).resolve(strict=True)      # canonical, symlinks followed\n    target = (base_p / user_path).resolve(strict=False)\n    # containment check that works for base itself and everything under it\n    if target == base_p or base_p in target.parents:\n        return target\n    return None                                   # REFUSE — never clamp/repair\n```\n\nTwo rules that matter more than the code:\n\n`user_path.replace(\"..\",\"\")`\n\n) is where the regression re-opens six months later. Return `None`\n\nand error out.`.resolve()`\n\non the target, not just the base.`..`\n\nA guard without a regression test rots. Fire the actual attacker payloads at it:\n\n``` python\nimport pytest\nfrom mymcp.paths import resolve_within\n\nBASE = \"/srv/sandbox\"\n\n@pytest.mark.parametrize(\"evil\", [\n    \"../../../../etc/passwd\",\n    \"/etc/passwd\",\n    \"..%2f..%2fetc%2fpasswd\",     # if you url-decode before calling, test the decoded form too\n    \"sub/../../etc/passwd\",\n    \"./././../etc/shadow\",\n])\ndef test_traversal_refused(evil):\n    assert resolve_within(BASE, evil) is None\n\ndef test_symlink_escape_refused(tmp_path):\n    base = tmp_path / \"sandbox\"; base.mkdir()\n    (base / \"link\").symlink_to(\"/etc\")            # symlink out of the sandbox\n    assert resolve_within(str(base), \"link/passwd\") is None\n\ndef test_legit_path_allowed(tmp_path):\n    base = tmp_path / \"sandbox\"; base.mkdir()\n    (base / \"notes.txt\").write_text(\"ok\")\n    assert resolve_within(str(base), \"notes.txt\") is not None\n```\n\nIf `test_symlink_escape_refused`\n\npasses, you have beaten the class of bug that string-based guards miss.\n\nThe regression that bites is a *second* file tool added later that opens paths directly and forgets to route through `resolve_within`\n\n. Grep every release:\n\n```\ngrep -rnE \"open\\(|Path\\(|send_file|shutil\\.(copy|move)\" src/ | grep -v resolve_within\n```\n\nEvery hit is a call site to audit.\n\nThis 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`\n\ntools, 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.\n\n**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.", "url": "https://wpnews.pro/news/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks", "canonical_source": "https://dev.to/mcpsecnotes/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks-2654", "published_at": "2026-08-11 11:07:34+00:00", "updated_at": "2026-08-11 11:18:11.336600+00:00", "lang": "en", "topics": ["ai-tools", "ai-safety", "developer-tools"], "entities": ["MCP", "Smeltworks"], "alternates": {"html": "https://wpnews.pro/news/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks", "markdown": "https://wpnews.pro/news/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks.md", "text": "https://wpnews.pro/news/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks.txt", "jsonld": "https://wpnews.pro/news/a-path-traversal-guard-for-mcp-file-tools-that-actually-survives-symlinks.jsonld"}}