FastMCP: path traversal to authenticated SSRF in OpenAPIProvider _build_url()
What's the vulnerability?
FastMCP's OpenAPIProvider parses an OpenAPI specification and exposes the
described backend endpoints to MCP clients as callable tools. The
RequestDirector class builds each outbound HTTP request; its _build_url()
method joins a client-supplied path parameter onto the configured backend
base URL without rejecting path-traversal sequences.
A client that puts ../ segments into a path parameter escapes the intended
API prefix and steers the request to arbitrary endpoints on the backend host —
an authenticated SSRF, because the provider attaches its configured
Authorization headers to the request. This exposes internal/un-described
backend APIs that were never meant to be reachable through the MCP surface.
Root Cause Analysis
# RCA Report — CVE-2026-32871
## Summary
FastMCP'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.
## Impact
- **Package**: `fastmcp` (PyPI)
- **Component**: `fastmcp.utilities.openapi.director.RequestDirector._build_url()`
- **Affected versions**: `< 3.2.0` (tested on `3.1.1` as the latest pre-fix release)
- **Fixed version**: `3.2.0`
- **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.
## Root Cause
In `fastmcp < 3.2.0` the `_build_url()` implementation was:
```python
url_path = path_template
for param_name, param_value in path_params.items():
placeholder = f"{{{param_name}}}"
if placeholder in url_path:
url_path = url_path.replace(placeholder, str(param_value))
return urljoin(base_url.rstrip("/") + "/", url_path.lstrip("/"))
```
Because `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.
The fix in `3.2.0` replaces raw substitution with:
```python
safe_value = quote(str(param_value), safe="").replace(".", "%2E")
url_path = url_path.replace(placeholder, safe_value)
```
`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.
## Reproduction Steps
The reproduction script is `repro/reproduction_steps.sh`. It performs the following:
1. Creates two isolated virtualenvs and installs `fastmcp==3.1.1` (vulnerable) and `fastmcp==3.2.0` (fixed).
2. For each version, instantiates `OpenAPIProvider` with an OpenAPI spec that exposes only `/users/{id}/profile`.
3. Retrieves the generated `OpenAPITool` named `get_user_profile`.
4. Executes `tool.run({'id': '../../../admin'})` against a custom `httpx.AsyncClient` using a recording transport so no real network is needed.
5. Captures the final request URL and the HTTP path actually sent.
6. Compares the vulnerable and fixed behavior and exits `0` when the vulnerability is confirmed.
### Expected evidence
| Build | Request path sent by `OpenAPITool` |
|-------|------------------------------------|
| `3.1.1` (vulnerable) | `/admin/profile` — escaped the `/api/v1/` prefix |
| `3.2.0` (fixed) | `/api/v1/users/../../../admin/profile` — traversal sequences are percent-encoded and remain inside the intended path |
## Evidence
Captured artifacts are stored under `logs/`:
- `logs/vuln_result.json` — JSON record for the vulnerable run showing `escaped_api_prefix: true` and `reached_unexposed_endpoint: true`.
- `logs/fixed_result.json` — JSON record for the fixed run showing `escaped_api_prefix: false` and `reached_unexposed_endpoint: false`.
- `logs/runtime_manifest.json` — consolidated manifest with versions, URLs, and verdict flags.
Key excerpts from the vulnerable run (`logs/vuln_result.json`):
```json
{
"version": "3.1.1",
"url": "http://127.0.0.1:9876/admin/profile",
"path": "/admin/profile",
"escaped_api_prefix": true,
"reached_unexposed_endpoint": true
}
```
Key excerpts from the fixed run (`logs/fixed_result.json`):
```json
{
"version": "3.2.0",
"url": "http://127.0.0.1:9876/api/v1/users/%2E%2E%2F%2E%2E%2F%2E%2E%2Fadmin/profile",
"path": "/api/v1/users/../../../admin/profile",
"escaped_api_prefix": false,
"reached_unexposed_endpoint": false
}
```
Environment details:
- Python 3.11
- `fastmcp==3.1.1` (vulnerable)
- `fastmcp==3.2.0` (fixed)
- `httpx` (for the recording transport)
## Recommendations / Next Steps
1. **Upgrade immediately** to `fastmcp >= 3.2.0`.
2. **Validate input encoding** — any code that builds URLs from user input should percent-encode path segments and encode `.` characters to prevent `..` normalization.
3. **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.
4. **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.
## Additional Notes
- **Idempotency**: `repro/reproduction_steps.sh` was executed twice consecutively and produced identical verdicts both times.
- **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.
- **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.
Verify with pruva-verify
Run the Pruva CLI to automatically fetch and execute the reproduction script.
pruva-verify REPRO-2026-00138 pruva-verify GHSA-vv7q-7jx5-f767 pruva-verify CVE-2026-32871 curl -fsSL https://pruva.dev/install.sh | sh Or Run Manually
Download the script
curl -O https://pruva.dev/api/v1/reproductions/REPRO-2026-00138/artifacts/reproduction_steps.sh Make executable
chmod +x reproduction_steps.sh Run the script
./reproduction_steps.sh How Pruva Reproduced This
Watch the AI agent's step-by-step process.
Loading session...
Artifacts
No artifacts available