## Summary
ToolHive v0.31.0 fixes the original RFC 9728 `resource_metadata` redirect SSRF, but a later Dynamic Client Registration (DCR) metadata re-fetch still uses an unguarded HTTP client. A malicious remote MCP server can advertise a direct protected-resource metadata document whose `authorization_servers` entry is accepted, then the DCR resolver re-fetches RFC 8414 authorization-server metadata with `oauthproto.FetchAuthorizationServerMetadata(ctx, discoveredDoc.Issuer, nil)`. That helper follows a cross-host/cross-port 302 to a loopback canary on the ToolHive host. This is a confirmed bypass on fixed ToolHive v0.31.0 and remains reproducible on latest tested v0.38.0.

## Fix Coverage / Assumptions
The v0.31.0 fix relies on the invariant that every host-side HTTP client fetching URLs influenced by remote MCP/OAuth discovery enforces same-host redirect restrictions and, where appropriate, private/non-public IP blocking.

The fix explicitly covers:

- `pkg/auth/discovery/discovery.go`: `DetectAuthenticationFromServer()` installs `networking.SameHostRedirectPolicy()` for initial GET/POST auth detection.
- `pkg/auth/discovery/discovery.go`: `FetchResourceMetadata(ctx, metadataURL, blockPrivateIPs)` installs `networking.SameHostRedirectPolicy()` and can install `networking.NewPrivateIPBlockingDialContext()`.
- `pkg/auth/oauth/oidc.go`: `DiscoverActualIssuer()` and its default client path install same-host redirect restrictions and can block private dials.
- `pkg/auth/remote/handler.go`: computes `blockPrivateIPs := !networking.TargetIsPrivate(ctx, remoteURL)` and propagates it through the main server-supplied realm/resource-metadata/authorization-server discovery path.

The fix does **not** cover:

- `pkg/auth/discovery/discovery.go:731`, where `resolveDCRCredentials()` performs a second metadata fetch with `oauthproto.FetchAuthorizationServerMetadata(ctx, discoveredDoc.Issuer, nil)`.
- `pkg/oauthproto/discovery.go:191`, where `buildDiscoveryHTTPClient(nil)` builds a plain `http.Client` with timeout and transport only, but no `CheckRedirect` policy and no private-IP blocking dialer.
- The related `pkg/auth/dcr/resolver.go:894` call to `oauthproto.FetchAuthorizationServerMetadataFromURL(ctx, req.DiscoveryURL, upstreamIssuer, nil)`, which uses the same unguarded default-client family and should be reviewed.

## Variant / Alternate Trigger
The bypass uses the same documented remote MCP authentication entry point as the parent claim, but reaches the SSRF sink through a different later stage:

1. Entry point: `thv run <malicious-remote-url> --remote-auth --remote-auth-skip-browser --foreground`.
2. The malicious remote MCP endpoint returns `401 WWW-Authenticate: Bearer ... resource_metadata="<auth-server>/resource-metadata"`.
3. The fixed `FetchResourceMetadata()` path fetches a direct JSON document, not a redirect, so the parent fix is not triggered there.
4. That document advertises `authorization_servers: ["<auth-server>"]`.
5. ToolHive validates the authorization server through the guarded OIDC discovery path.
6. During DCR, `resolveDCRCredentials()` re-fetches full AS metadata through `oauthproto.FetchAuthorizationServerMetadata(..., nil)`.
7. The malicious authorization server returns `302 Location: http://127.0.0.1:<canary-port>/.well-known/oauth-authorization-server` from `/.well-known/oauth-authorization-server`.
8. The unguarded `oauthproto` client follows the redirect and the loopback canary records the host-side request.

Specific code path:

- `pkg/auth/remote/handler.go:361` computes `blockPrivateIPs` for the protected primary discovery path.
- `pkg/auth/discovery/discovery.go:915` implements the now-guarded `FetchResourceMetadata()` parent sink.
- `pkg/auth/discovery/discovery.go:731` is the variant anchor: DCR re-fetch via `oauthproto.FetchAuthorizationServerMetadata(ctx, discoveredDoc.Issuer, nil)`.
- `pkg/oauthproto/discovery.go:191` builds the unguarded default client.
- `pkg/oauthproto/discovery.go:270` performs the metadata GET with that client.

## Impact
- Package/component affected: `github.com/stacklok/toolhive`, remote MCP authentication discovery and Dynamic Client Registration metadata discovery.
- Affected versions tested:
  - Vulnerable baseline: v0.30.0, commit `bc4beddb921523d1f314b82b5b9d4249ee0a605b`.
  - Fixed target: v0.31.0, commit `493d030ed24b8c012db4c5717475a2386f5b72ce`.
  - Latest release tested: v0.38.0, commit `116421213382e82016067d32581b61f4fe1ab807`.
- Risk level and consequences: CWE-918 SSRF. A malicious or compromised remote MCP/OAuth metadata endpoint can still cause the ToolHive host process to follow an attacker-selected metadata redirect to a separate internal/loopback URL during DCR metadata discovery.

## Impact Parity
- Disclosed/claimed maximum impact for the parent: host-side SSRF during remote MCP server authentication discovery, bypassing the intended isolation boundary around per-server containers.
- Reproduced impact from this variant run: host-side HTTP GET from fixed ToolHive v0.31.0 and latest v0.38.0 to a separate loopback canary URL during DCR authorization-server metadata re-fetch.
- Parity: `full` for the SSRF primitive and trust-boundary class.
- Not demonstrated: no real cloud metadata or third-party internal service was contacted, and no secrets were exfiltrated. The lab uses only loopback canaries and records request/redirect behavior.

## Root Cause
The original fix hardened several clients in `pkg/auth/discovery` and `pkg/auth/oauth/oidc`, but the DCR resolver introduced/retained another metadata discovery call in `pkg/oauthproto` that constructs its own default client. That client does not install `networking.SameHostRedirectPolicy()` and does not use `networking.NewPrivateIPBlockingDialContext()`.

The unguarded client is reached only after earlier guarded discovery succeeds, which likely explains the missed path: `resolveDCRCredentials()` synthesizes a pre-discovered document from already validated endpoints, but then intentionally re-fetches full authorization-server metadata to recover DCR/PKCE capability fields. The second fetch is still derived from remote-server-provided discovery data, but it is outside the code paths patched for CVE-2026-58196.

Fix version reference: the tested fixed release is v0.31.0 (`493d030ed24b8c012db4c5717475a2386f5b72ce`). The relevant diff is captured in `bundle/logs/vuln_variant/v0.30.0_to_v0.31.0_auth_ssrf.diff`.

## Reproduction Steps
1. Run `bundle/vuln_variant/reproduction_steps.sh`.
2. The script:
   - Uses cached or downloaded ToolHive release binaries for v0.30.0, v0.31.0, and v0.38.0.
   - Starts a malicious remote MCP server, a malicious OAuth metadata server, and a separate loopback canary.
   - Invokes `thv run <remote-url> --remote-auth --remote-auth-skip-browser --remote-auth-timeout 5s --foreground` once per version.
   - Serves direct RFC 9728 resource metadata first, so the parent `FetchResourceMetadata` redirect fix is not the trigger.
   - Returns a 302 from the DCR/RFC 8414 `/.well-known/oauth-authorization-server` re-fetch to the separate canary.
3. Expected evidence:
   - v0.30.0: canary receives the DCR metadata redirect request.
   - v0.31.0: canary still receives the DCR metadata redirect request, confirming bypass of the fixed release.
   - v0.38.0: canary still receives the request, confirming the issue remains in the latest tested release.

## Evidence
Primary logs:

- Main variant run log: `bundle/logs/vuln_variant/reproduction_steps.log`
  - Shows successful idempotent run with:
    - `vulnerable: remote requests=2, auth-server events=9, internal canary events=2`
    - `fixed: remote requests=2, auth-server events=9, internal canary events=2`
    - `latest: remote requests=2, auth-server events=9, internal canary events=2`
    - `[CONFIRMED] Variant bypass reproduced on fixed ToolHive v0.31.0.`
- Attempt manifest: `bundle/logs/vuln_variant/variant_attempts.jsonl`
  - Records the per-version remote URL, resource metadata URL, authorization server URL, canary URL, and canary event counts.
- Fixed-version auth-server log: `bundle/logs/vuln_variant/fixed_auth.log`
  - Shows ToolHive v0.31.0 requesting `/resource-metadata`, then `/.well-known/openid-configuration`, then `/.well-known/oauth-authorization-server`.
  - Records `event: dcr_metadata_redirect` with `location` set to the separate loopback canary URL.
- Fixed-version canary log: `bundle/logs/vuln_variant/fixed_canary.log`
  - Shows `method: GET`, `path: /.well-known/oauth-authorization-server`, `User-Agent: ToolHive/1.0`, and `Referer: http://127.0.0.1:<auth-port>/.well-known/oauth-authorization-server`.
- Latest-version canary log: `bundle/logs/vuln_variant/latest_canary.log`
  - Shows the same canary hit on v0.38.0.
- Runtime manifest: `bundle/vuln_variant/runtime_manifest.json`.
- Source/version identity: `bundle/logs/vuln_variant/source_revisions.txt`, `bundle/logs/vuln_variant/fixed_version.txt`, and `bundle/logs/vuln_variant/latest_version.txt`.

## Recommendations / Next Steps
- Extend the SSRF guard to `pkg/oauthproto` discovery clients. `FetchAuthorizationServerMetadata` and `FetchAuthorizationServerMetadataFromURL` should not build an unguarded default client when the URL can be derived from remote discovery.
- Thread the existing `blockPrivateIPs` decision from `pkg/auth/remote/handler.go` through `resolveDCRCredentials()` into the second AS metadata fetch.
- Install `networking.SameHostRedirectPolicy()` on the `oauthproto` discovery client, and use `networking.NewPrivateIPBlockingDialContext()` with disabled keep-alives when server-derived metadata for a public configured target is fetched.
- Add regression tests for:
  - DCR metadata re-fetch returning a 302 to a different host or port.
  - DCR metadata re-fetch redirecting from a public authorization server to loopback/private/link-local targets.
  - `FetchAuthorizationServerMetadataFromURL(..., nil)` using a guarded default client when called from server-derived discovery.

## Additional Notes
- Idempotency confirmation: `bundle/vuln_variant/reproduction_steps.sh` was executed twice consecutively and exited 0 both times with the same vulnerable/fixed/latest canary-hit pattern.
- The proof intentionally stops at the OAuth callback timeout; the SSRF oracle occurs before browser/user completion, during DCR metadata discovery.
- No repository checkout state was changed. Source inspection used the prepared cache and read-only `git show` / `git diff` / `git rev-parse` operations.
