2026.06.30 · cve / ssrf / mcp / disclosure · 3 min

Watching an SSRF Walk Out of the Sandbox

CVE-2026-58196: a remote MCP server steers the ToolHive host into fetching cloud instance metadata, from the one code path that runs before the container sandbox. Step through the chain.

A sandbox is only as strong as the code that runs outside it.

ToolHive runs Model Context Protocol servers for you, each in its own container, behind an egress proxy, with no local credentials. Every MCP server is treated as untrusted, so it stays boxed. That isolation is the product.

But before a remote server reaches its container, ToolHive runs OAuth discovery against it to work out how to authenticate. Discovery runs host-side, in the ToolHive process, outside the per-server sandbox. So the question is whether the untrusted server can influence what the trusted host fetches during discovery. It can. The host follows a chain of the server’s choosing all the way to 169.254.169.254, the cloud instance metadata endpoint, and reads back IAM credentials the sandbox exists to keep out of reach.

Step through it:

ssrf chainstep 1 / 4
untrustedAttacker MCP server
host-side, pre-sandboxToolHive process
169.254.169.254Instance metadata

The tell was in the suppressions

The three discovery requests each carry the maintainers’ own #nosec G704 comment. G704 is gosec’s SSRF rule. It flags an HTTP request whose URL flows from external input, which is what these are. Each suppression waves the rule off on the premise that the URL is “from internal config” or is “the MCP server endpoint.”

That premise is the bug. Under ToolHive’s own threat model the remote server is untrusted, so “the MCP server endpoint” is attacker input, not internal config. The static analyzer found the exact issue and was told, in a comment, that the tainted value was trusted.

Why the obvious guard does not hold

The project already knows how to block this. It ships IsPrivateIP, which rejects RFC1918, link-local, 169.254.0.0/16, and loopback, and it ships redirect hardening for its dynamic-client-registration path. The discovery clients call neither. They set no redirect policy and no private-IP dialer, so a 302 to an internal address is followed with no re-validation of the target.

The first-hop HTTPS check is the kind of guard that looks sufficient and is not. It reads the initial URL, sees https, and never runs again. On IMDSv1 the request to 169.254.169.254 returns credentials to a plain GET. IMDSv2 and GCP need a token or a header a redirect-following GET cannot supply, so on those the reachable surface is the broader one: internal-only HTTP services, link-local and RFC1918 endpoints, and reachability oracles. Rated medium, CVSS 4.7.

The fix

Apply the hardening the project already wrote for its DCR client to the discovery clients: reject cross-host and scheme-downgrade redirects, and wrap the dialer in the existing private-IP guard. The load-bearing part is re-validating the redirect target on every hop, since the first-hop scheme test only ever checks one URL. Fixed in v0.31.0; full advisory at GHSA-pr64-jmmf-jp54.

The lesson generalizes past this one bug. Auth discovery, health checks, and update probes tend to run host-side and inherit none of the isolation a product advertises. And a #nosec with a confident rationale is worth reading twice: the analyzer had already found this, and a trust assumption written in a comment overrode it.

The short version and CVSS breakdown live on the project page.