{"repro_id":"REPRO-2026-00138","version":8,"title":"FastMCP: path traversal to authenticated SSRF in OpenAPIProvider _build_url()","repro_type":"security","status":"published","severity":"high","description":"FastMCP's `OpenAPIProvider` parses an OpenAPI specification and exposes the\ndescribed backend endpoints to MCP clients as callable tools. The\n`RequestDirector` class builds each outbound HTTP request; its `_build_url()`\nmethod joins a **client-supplied path parameter** onto the configured backend\nbase URL **without rejecting path-traversal sequences**.\n\nA client that puts `../` segments into a path parameter escapes the intended\nAPI prefix and steers the request to arbitrary endpoints on the backend host —\nan **authenticated SSRF**, because the provider attaches its configured\n`Authorization` headers to the request. This exposes internal/un-described\nbackend APIs that were never meant to be reachable through the MCP surface.","root_cause":"# RCA Report — CVE-2026-32871\n\n## Summary\n\nFastMCP's `OpenAPIProvider` exposes backend REST APIs as MCP tools. The `RequestDirector._build_url()` method substitutes client-supplied path parameters into the URL template by simple string replacement (`str(param_value)`) without URL-encoding. When a client passes traversal sequences such as `../../../admin`, `urllib.parse.urljoin` resolves the resulting relative path against the base URL, causing the request to escape the intended API prefix and hit arbitrary endpoints on the same backend host — an authenticated SSRF because the provider's configured `Authorization` headers travel with the request.\n\n## Impact\n\n- **Package**: `fastmcp` (PyPI)\n- **Component**: `fastmcp.utilities.openapi.director.RequestDirector._build_url()`\n- **Affected versions**: `< 3.2.0` (tested on `3.1.1` as the latest pre-fix release)\n- **Fixed version**: `3.2.0`\n- **Risk**: High — any MCP client that can supply arguments to an OpenAPI-backed tool can redirect the outgoing HTTP request to internal endpoints, exfiltrating data or triggering unauthorized actions with the provider's own credentials.\n\n## Root Cause\n\nIn `fastmcp < 3.2.0` the `_build_url()` implementation was:\n\n```python\nurl_path = path_template\nfor param_name, param_value in path_params.items():\n    placeholder = f\"{{{param_name}}}\"\n    if placeholder in url_path:\n        url_path = url_path.replace(placeholder, str(param_value))\nreturn urljoin(base_url.rstrip(\"/\") + \"/\", url_path.lstrip(\"/\"))\n```\n\nBecause `str(param_value)` is inserted verbatim, a payload of `../../../admin` in a template `/users/{id}/profile` yields a path string `users/../../../admin/profile`. `urljoin` then normalizes this relative to the base URL `http://host/api/v1/` and resolves it to `http://host/admin/profile`, completely escaping the `/api/v1/` prefix.\n\nThe fix in `3.2.0` replaces raw substitution with:\n\n```python\nsafe_value = quote(str(param_value), safe=\"\").replace(\".\", \"%2E\")\nurl_path = url_path.replace(placeholder, safe_value)\n```\n\n`quote` encodes `/` to `%2F` and `.` to `%2E`, so traversal sequences become literal strings inside the path segment (e.g., `%2E%2E%2F%2E%2E%2F%2E%2E%2Fadmin`) and `urljoin` no longer interprets them as directory traversal.\n\n## Reproduction Steps\n\nThe reproduction script is `repro/reproduction_steps.sh`. It performs the following:\n\n1. Creates two isolated virtualenvs and installs `fastmcp==3.1.1` (vulnerable) and `fastmcp==3.2.0` (fixed).\n2. For each version, instantiates `OpenAPIProvider` with an OpenAPI spec that exposes only `/users/{id}/profile`.\n3. Retrieves the generated `OpenAPITool` named `get_user_profile`.\n4. Executes `tool.run({'id': '../../../admin'})` against a custom `httpx.AsyncClient` using a recording transport so no real network is needed.\n5. Captures the final request URL and the HTTP path actually sent.\n6. Compares the vulnerable and fixed behavior and exits `0` when the vulnerability is confirmed.\n\n### Expected evidence\n\n| Build | Request path sent by `OpenAPITool` |\n|-------|------------------------------------|\n| `3.1.1` (vulnerable) | `/admin/profile` — escaped the `/api/v1/` prefix |\n| `3.2.0` (fixed) | `/api/v1/users/../../../admin/profile` — traversal sequences are percent-encoded and remain inside the intended path |\n\n## Evidence\n\nCaptured artifacts are stored under `logs/`:\n\n- `logs/vuln_result.json` — JSON record for the vulnerable run showing `escaped_api_prefix: true` and `reached_unexposed_endpoint: true`.\n- `logs/fixed_result.json` — JSON record for the fixed run showing `escaped_api_prefix: false` and `reached_unexposed_endpoint: false`.\n- `logs/runtime_manifest.json` — consolidated manifest with versions, URLs, and verdict flags.\n\nKey excerpts from the vulnerable run (`logs/vuln_result.json`):\n```json\n{\n  \"version\": \"3.1.1\",\n  \"url\": \"http://127.0.0.1:9876/admin/profile\",\n  \"path\": \"/admin/profile\",\n  \"escaped_api_prefix\": true,\n  \"reached_unexposed_endpoint\": true\n}\n```\n\nKey excerpts from the fixed run (`logs/fixed_result.json`):\n```json\n{\n  \"version\": \"3.2.0\",\n  \"url\": \"http://127.0.0.1:9876/api/v1/users/%2E%2E%2F%2E%2E%2F%2E%2E%2Fadmin/profile\",\n  \"path\": \"/api/v1/users/../../../admin/profile\",\n  \"escaped_api_prefix\": false,\n  \"reached_unexposed_endpoint\": false\n}\n```\n\nEnvironment details:\n- Python 3.11\n- `fastmcp==3.1.1` (vulnerable)\n- `fastmcp==3.2.0` (fixed)\n- `httpx` (for the recording transport)\n\n## Recommendations / Next Steps\n\n1. **Upgrade immediately** to `fastmcp >= 3.2.0`.\n2. **Validate input encoding** — any code that builds URLs from user input should percent-encode path segments and encode `.` characters to prevent `..` normalization.\n3. **Regression tests** — the existing `TestPathTraversalPrevention` test class in `tests/utilities/openapi/test_director.py` already covers this; ensure it remains part of the CI pipeline.\n4. **Defense in depth** — consider adding server-side URL path normalization checks or allow-listing in the reverse proxy / API gateway in front of the backend.\n\n## Additional Notes\n\n- **Idempotency**: `repro/reproduction_steps.sh` was executed twice consecutively and produced identical verdicts both times.\n- **Edge cases tested**: The same payload `../../../admin` was used for both runs. The fixed version correctly encodes both slashes and dots, so double-encoded payloads (`..%2F..%2Fadmin`) are also handled safely because they are encoded a second time.\n- **Limitations**: The reproduction uses a synthetic `httpx` recording transport rather than a live MCP client/server handshake. This is sufficient because the bug lives entirely in the request-building layer (`RequestDirector._build_url() → OpenAPITool.run()`), which the script exercises end-to-end through the actual fastmcp code path.\n","ghsa_id":"GHSA-vv7q-7jx5-f767","cve_id":"CVE-2026-32871","package":{"name":"fastmcp","ecosystem":"pip","affected_versions":"< 3.2.0","fixed_version":"3.2.0","tested_patched":"3.2.0"},"reproduced_at":"2026-05-22T09:52:12.592576+00:00","duration_secs":2235.8349225521088,"tool_calls":204,"turns":171,"handoffs":3,"total_cost_usd":2.19690966,"agent_costs":{"repro":0.49405625999999986,"support":0.03161008,"vuln_variant":1.6712433199999996},"cost_breakdown":{"repro":{"accounts/fireworks/models/kimi-k2p6":0.49405625999999986},"support":{"accounts/fireworks/models/kimi-k2p6":0.03161008},"vuln_variant":{"accounts/fireworks/models/kimi-k2p6":1.6712433199999996}},"quality":{"idempotent_verified":false,"community_verifications":0},"published_at":"2026-05-22T09:52:14.711865+00:00","retracted":false,"artifacts":[{"path":"repro/rca_report.md","filename":"rca_report.md","size":5776,"category":"analysis"},{"path":"repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":6333,"category":"reproduction_script"},{"path":"vuln_variant/rca_report.md","filename":"rca_report.md","size":7697,"category":"analysis"},{"path":"vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11336,"category":"reproduction_script"},{"path":"bundle/context.json","filename":"context.json","size":3054,"category":"other"},{"path":"bundle/metadata.json","filename":"metadata.json","size":564,"category":"other"},{"path":"bundle/ticket.md","filename":"ticket.md","size":3471,"category":"ticket"},{"path":"repro/validation_verdict.json","filename":"validation_verdict.json","size":783,"category":"other"},{"path":"vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1545,"category":"other"},{"path":"vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":4719,"category":"documentation"},{"path":"vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":2968,"category":"other"},{"path":"vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2944,"category":"other"},{"path":"logs/attempt2_fixed_tool_path.log","filename":"attempt2_fixed_tool_path.log","size":96,"category":"log"},{"path":"logs/fixed_result.log","filename":"fixed_result.log","size":265,"category":"log"},{"path":"logs/vuln_result.json","filename":"vuln_result.json","size":188,"category":"other"},{"path":"logs/attempt3_encoded_tool_path.log","filename":"attempt3_encoded_tool_path.log","size":250,"category":"log"},{"path":"logs/fixed_result.json","filename":"fixed_result.json","size":253,"category":"other"},{"path":"logs/runtime_manifest.json","filename":"runtime_manifest.json","size":340,"category":"other"},{"path":"logs/attempt4_resource_read_path.log","filename":"attempt4_resource_read_path.log","size":345,"category":"log"},{"path":"logs/vuln_result.log","filename":"vuln_result.log","size":145,"category":"log"},{"path":"logs/attempt5_proxy_resource_path.log","filename":"attempt5_proxy_resource_path.log","size":299,"category":"log"},{"path":"logs/backend_requests.log","filename":"backend_requests.log","size":70,"category":"log"},{"path":"logs/attempt1_vuln_tool_path.log","filename":"attempt1_vuln_tool_path.log","size":59,"category":"log"}]}