{"repro_id":"REPRO-2026-00290","version":6,"title":"ToolHive SSRF in remote MCP server authentication discovery","repro_type":"security","status":"published","severity":"medium","description":"ToolHive versions before 0.31.0 are vulnerable to host-side SSRF during remote MCP server authentication discovery. The vulnerable path is the ToolHive host process, not the per-server container sandbox: when a user adds/runs a remote MCP server through the documented remote-server workflow, the host performs RFC 9728 / OAuth discovery requests based on attacker-controlled server responses (including resource_metadata and redirect targets) without the preexisting private-IP/loopback guard and without redirect restrictions. A malicious or compromised remote MCP discovery endpoint can therefore cause the ToolHive host to fetch an attacker-selected internal URL, bypassing the intended container isolation boundary. The fixing diff in v0.31.0 adds redirect policy enforcement and private-IP blocking to the discovery HTTP client path.","root_cause":"## Summary\nToolHive versions before 0.31.0 perform host-side OAuth/RFC 9728 authentication discovery for remote MCP servers using URLs and redirects influenced by the remote server. During the documented remote MCP onboarding flow (`thv run <URL> --remote-auth`), a malicious remote MCP endpoint can return a `WWW-Authenticate` challenge with `resource_metadata` pointing to a redirect endpoint; vulnerable ToolHive follows that redirect from the host process to a separate loopback/internal canary. ToolHive 0.31.0 blocks the same cross-host/cross-port redirect, so the internal canary is not reached.\n\n## Impact\n- Package/component affected: `github.com/stacklok/toolhive`, specifically remote MCP server authentication discovery in `pkg/auth/discovery` and `pkg/auth/remote` as invoked by the ToolHive CLI remote-server workflow.\n- Affected versions: all versions before 0.31.0. The reproduction uses ToolHive v0.30.0 as the vulnerable build and v0.31.0 as the fixed negative control.\n- Risk level and consequences: CWE-918 server-side request forgery. A malicious or compromised remote MCP server can cause the ToolHive host process, outside the intended per-server container isolation boundary, to fetch attacker-selected internal URLs. In a real deployment this can expose internal HTTP services or metadata endpoints reachable from the host.\n\n## Impact Parity\n- Disclosed/claimed maximum impact: host-side SSRF during remote MCP server authentication discovery.\n- Reproduced impact from this run: host-side HTTP GET from ToolHive v0.30.0 to an attacker-selected loopback canary URL after an attacker-controlled RFC 9728 `resource_metadata` endpoint issues a 302 redirect.\n- Parity: `full`.\n- Not demonstrated: no secrets were targeted or exfiltrated. The lab intentionally used a local canary and recorded only request path, headers, and redirect behavior.\n\n## Root Cause\nIn vulnerable ToolHive v0.30.0, `FetchResourceMetadata(ctx, metadataURL)` in `pkg/auth/discovery/discovery.go` constructs a normal `http.Client` for a server-supplied RFC 9728 resource metadata URL. That client has no `CheckRedirect` policy and no private/loopback/link-local dial guard. Therefore, after the remote MCP endpoint returns `WWW-Authenticate: Bearer ... resource_metadata=\"<attacker URL>\"`, ToolHive fetches the attacker metadata URL and automatically follows HTTP 30x redirects to a different host/port. This occurs in the host-side authentication discovery path, before/outside any per-server container sandbox.\n\nThe v0.31.0 fix changes the discovery clients to enforce redirect and private-address protections: `DetectAuthenticationFromServer` and metadata/OIDC discovery use `networking.SameHostRedirectPolicy()`, `FetchResourceMetadata` accepts a `blockPrivateIPs` setting, and the protected HTTP transports can use `networking.NewPrivateIPBlockingDialContext()`. The same-host redirect policy refuses redirects to a different host:port and HTTPS-to-HTTP downgrades, which prevents the demonstrated cross-port loopback canary request.\n\n## Reproduction Steps\n1. Run `bundle/repro/reproduction_steps.sh`.\n2. The script:\n   - Uses the prepared project cache when available (`<project_cache_dir>/repo` and `<project_cache_dir>/bin`) and downloads exact ToolHive release binaries for v0.30.0 and v0.31.0 if not already cached.\n   - Starts a malicious remote MCP HTTP endpoint and a separate loopback canary HTTP endpoint in a closed lab network.\n   - Invokes the real product workflow twice per version: `thv run <malicious-url> --remote-auth --remote-auth-skip-browser --foreground`.\n   - The malicious MCP endpoint returns a `WWW-Authenticate` header with an attacker-controlled `resource_metadata` URL. That metadata endpoint returns a 302 redirect to the separate canary.\n   - Records per-attempt ToolHive, malicious endpoint, and canary logs under `bundle/logs/`, and writes `bundle/repro/runtime_manifest.json` before exit.\n3. Expected evidence:\n   - ToolHive v0.30.0: both vulnerable attempts reach the malicious endpoint and the canary receives one ToolHive host-side request per attempt.\n   - ToolHive v0.31.0: both fixed attempts reach the malicious endpoint but the canary remains untouched.\n\n## Evidence\n- Main run log: `bundle/logs/reproduction_steps.log`\n  - Shows two vulnerable successes and two fixed negative controls:\n    - `vulnerable attempt 1: malicious requests=6, canary requests=1`\n    - `vulnerable attempt 2: malicious requests=6, canary requests=1`\n    - `fixed attempt 1: malicious requests=6, canary requests=0`\n    - `fixed attempt 2: malicious requests=6, canary requests=0`\n    - `[CONFIRMED] CVE-2026-58196 reproduced with vulnerable/fixed divergence.`\n- Vulnerable ToolHive product log: `bundle/logs/toolhive_vulnerable_attempt1.log`\n  - Confirms the real `thv run` remote MCP workflow and host-side discovery path:\n    - `Attempting to run remote MCP server: http://127.0.0.1:<port>`\n    - `Detected authentication requirement from server` with `resource_metadata` set to the attacker endpoint.\n    - `Fetching resource metadata` for that attacker URL.\n- Malicious endpoint log: `bundle/logs/malicious_server_vulnerable_attempt1.log`\n  - Records the initial ToolHive request to `/`, the RFC 9728 metadata redirect at `/metadata-redirect/vulnerable/1`, and the redirect `location` pointing at the separate canary URL.\n- Canary log: `bundle/logs/canary_vulnerable_attempt1.log`\n  - Records the vulnerable host-side SSRF oracle:\n    - `method: GET`\n    - `path: /internal-canary/vulnerable/1/metadata.json`\n    - `Referer: http://127.0.0.1:<malicious-port>/metadata-redirect/vulnerable/1`\n    - `User-Agent: Go-http-client/1.1`\n- Fixed product logs: `bundle/logs/toolhive_fixed_attempt1.log`, `bundle/logs/malicious_server_fixed_attempt1.log`, and `bundle/logs/canary_fixed_attempt1.log`\n  - The fixed build reaches the malicious remote endpoint but the corresponding canary log stays empty, demonstrating the v0.31.0 redirect/private-dial protection blocks the canary fetch.\n- Structured evidence manifest: `bundle/repro/runtime_manifest.json`.\n- Attempt summary: `bundle/artifacts/toolhive-ssrf/attempts.jsonl`.\n\n## Recommendations / Next Steps\n- Upgrade ToolHive to v0.31.0 or later.\n- Keep same-host redirect restrictions and private/loopback/link-local dial blocking on every HTTP client that fetches URLs derived from remote MCP server responses, including RFC 9728 metadata, OIDC discovery, and dynamic client registration discovery.\n- Add regression tests for: `resource_metadata` redirects to different host/port, redirects from public targets to private/loopback/link-local addresses, HTTPS-to-HTTP downgrade redirects, and DNS/private-IP rebinding during discovery.\n- Ensure future remote discovery features explicitly distinguish operator-configured trusted URLs from server-supplied URLs and apply stricter SSRF guards to the latter.\n\n## Additional Notes\n- Idempotency confirmation: `bundle/repro/reproduction_steps.sh` was run twice consecutively after finalization and succeeded both times with the same vulnerable/fixed divergence.\n- The lab does not contact real cloud metadata or third-party services. It uses a local malicious endpoint and a local canary as a safe stand-in for an internal service.\n- The script attempts to bind a public-looking lab address for the remote endpoint, but if the sandbox cannot add/bind that address it falls back to loopback. The fixed-version negative control remains valid because v0.31.0 refuses the cross-host/cross-port redirect before the canary request is made.\n","cve_id":"CVE-2026-58196","cwe_id":"CWE-918 Server-Side Request Forgery","source_url":"https://github.com/advisories/GHSA-pr64-jmmf-jp54","package":{"name":"github.com/stacklok/toolhive","ecosystem":"github","affected_versions":"All ToolHive versions before 0.31.0. The GitHub repo advisory states ToolHive through v0.29.3 and main HEAD b672d82f41e6a919670ee1abe812f831ecb72448 were verified vulnerable before the fix."},"reproduced_at":"2026-07-16T11:51:06.632440+00:00","duration_secs":2627.0,"tool_calls":210,"handoffs":2,"total_cost_usd":12.986249,"agent_costs":{"claim_matcher":0.015652,"learning_policy":0.01334,"repro":3.577501,"support":0.463378,"vuln_variant":8.916378},"cost_breakdown":{"claim_matcher":{"gpt-5.5-2026-04-23":0.015652},"learning_policy":{"gpt-5.4-mini-2026-03-17":0.01334},"repro":{"gpt-5.5":3.577501},"support":{"gpt-5.5":0.463378},"vuln_variant":{"gpt-5.5":8.916378}},"quality":{"confidence":"high","idempotent_verified":false,"community_verifications":0},"evidence":{"workflow":{"profile":"known_vulnerability","schema_version":1,"stages":["support","claim_contract","repro","judge","vuln_variant"]}},"environment":{"sandbox_image":"ghcr.io/n3mes1s/pruva-sandbox@sha256:8096b2518d6022e13d68f885c3b8ded6b4fe607098b1a1ccbfb99abc004d1dc1"},"published_at":"2026-07-16T11:51:07.710421+00:00","retracted":false,"artifacts":[{"path":"bundle/repro/reproduction_steps.sh","filename":"reproduction_steps.sh","size":17580,"category":"reproduction_script"},{"path":"bundle/repro/rca_report.md","filename":"rca_report.md","size":7545,"category":"analysis"},{"path":"bundle/vuln_variant/reproduction_steps.sh","filename":"reproduction_steps.sh","size":11668,"category":"reproduction_script"},{"path":"bundle/vuln_variant/rca_report.md","filename":"rca_report.md","size":10530,"category":"analysis"},{"path":"bundle/vuln_variant/root_cause_equivalence.json","filename":"root_cause_equivalence.json","size":1947,"category":"other"},{"path":"bundle/repro/runtime_manifest.json","filename":"runtime_manifest.json","size":1350,"category":"other"},{"path":"bundle/repro/validation_verdict.json","filename":"validation_verdict.json","size":701,"category":"other"},{"path":"bundle/logs/reproduction_steps.log","filename":"reproduction_steps.log","size":1505,"category":"log"},{"path":"bundle/logs/toolhive_vulnerable_attempt1.log","filename":"toolhive_vulnerable_attempt1.log","size":23585,"category":"log"},{"path":"bundle/logs/malicious_server_vulnerable_attempt1.log","filename":"malicious_server_vulnerable_attempt1.log","size":2449,"category":"log"},{"path":"bundle/logs/canary_vulnerable_attempt1.log","filename":"canary_vulnerable_attempt1.log","size":488,"category":"log"},{"path":"bundle/logs/canary_fixed_attempt1.log","filename":"canary_fixed_attempt1.log","size":0,"category":"log"},{"path":"bundle/artifacts/toolhive-ssrf/attempts.jsonl","filename":"attempts.jsonl","size":2342,"category":"other"},{"path":"bundle/logs/toolhive_vulnerable_attempt2.log","filename":"toolhive_vulnerable_attempt2.log","size":23585,"category":"log"},{"path":"bundle/logs/toolhive_fixed_attempt1.log","filename":"toolhive_fixed_attempt1.log","size":23487,"category":"log"},{"path":"bundle/logs/toolhive_fixed_attempt2.log","filename":"toolhive_fixed_attempt2.log","size":23487,"category":"log"},{"path":"bundle/logs/malicious_server_vulnerable_attempt2.log","filename":"malicious_server_vulnerable_attempt2.log","size":2449,"category":"log"},{"path":"bundle/logs/malicious_server_fixed_attempt1.log","filename":"malicious_server_fixed_attempt1.log","size":2434,"category":"log"},{"path":"bundle/logs/malicious_server_fixed_attempt2.log","filename":"malicious_server_fixed_attempt2.log","size":2434,"category":"log"},{"path":"bundle/logs/canary_vulnerable_attempt2.log","filename":"canary_vulnerable_attempt2.log","size":488,"category":"log"},{"path":"bundle/logs/canary_fixed_attempt2.log","filename":"canary_fixed_attempt2.log","size":0,"category":"log"},{"path":"bundle/vuln_variant/variant_lab_servers.py","filename":"variant_lab_servers.py","size":8556,"category":"script"},{"path":"bundle/vuln_variant/patch_analysis.md","filename":"patch_analysis.md","size":8553,"category":"documentation"},{"path":"bundle/vuln_variant/variant_manifest.json","filename":"variant_manifest.json","size":3520,"category":"other"},{"path":"bundle/vuln_variant/validation_verdict.json","filename":"validation_verdict.json","size":2665,"category":"other"},{"path":"bundle/vuln_variant/source_identity.json","filename":"source_identity.json","size":1502,"category":"other"},{"path":"bundle/vuln_variant/runtime_manifest.json","filename":"runtime_manifest.json","size":1585,"category":"other"},{"path":"bundle/logs/vuln_variant/reproduction_steps.log","filename":"reproduction_steps.log","size":1128,"category":"log"},{"path":"bundle/logs/vuln_variant/variant_attempts.jsonl","filename":"variant_attempts.jsonl","size":2535,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_auth.log","filename":"fixed_auth.log","size":2083,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_canary.log","filename":"fixed_canary.log","size":512,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_canary.log","filename":"latest_canary.log","size":512,"category":"log"},{"path":"bundle/logs/vuln_variant/source_revisions.txt","filename":"source_revisions.txt","size":1160,"category":"other"},{"path":"bundle/logs/vuln_variant/vulnerable_thv.log","filename":"vulnerable_thv.log","size":23330,"category":"log"},{"path":"bundle/logs/vuln_variant/fixed_thv.log","filename":"fixed_thv.log","size":22940,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_thv.log","filename":"latest_thv.log","size":23144,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_auth.log","filename":"vulnerable_auth.log","size":2083,"category":"log"},{"path":"bundle/logs/vuln_variant/latest_auth.log","filename":"latest_auth.log","size":2083,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_canary.log","filename":"vulnerable_canary.log","size":512,"category":"log"},{"path":"bundle/logs/vuln_variant/vulnerable_version.txt","filename":"vulnerable_version.txt","size":215,"category":"other"},{"path":"bundle/logs/vuln_variant/fixed_version.txt","filename":"fixed_version.txt","size":215,"category":"other"},{"path":"bundle/logs/vuln_variant/latest_version.txt","filename":"latest_version.txt","size":140,"category":"other"}]}