Skip to content

fix(mcp-oauth): disable redirect following on OAuth HTTP clients (SSRF + credential leak)#6315

Merged
houko merged 3 commits into
mainfrom
harden-mcp-oauth-ssrf
Jun 25, 2026
Merged

fix(mcp-oauth): disable redirect following on OAuth HTTP clients (SSRF + credential leak)#6315
houko merged 3 commits into
mainfrom
harden-mcp-oauth-ssrf

Conversation

@houko

@houko houko commented Jun 25, 2026

Copy link
Copy Markdown
Contributor

Summary

The four MCP OAuth outbound HTTP call sites built their reqwest clients with redirect following enabled — reqwest's default policy follows up to 10 hops, and the kernel/extensions bundled-CA client used redirect::Policy::limited(5).
The per-URL SSRF guard (is_ssrf_blocked_url) validates only the initial request URL; it never re-checks the redirect Location.
A malicious or compromised MCP authorization server could therefore:

  • return a 307/308 on a credential-bearing POST (authorization-code exchange, token refresh, dynamic client registration) to replay the request body — client_secret, the PKCE code_verifier, the refresh_token — to an attacker-controlled redirect target; or
  • return a 302 on metadata discovery to drive a blind SSRF / cloud-metadata (IMDS) pivot from the long-running daemon.

This brings the OAuth call paths in line with librefang_runtime::web_fetch's pinned client, which already disables reqwest's automatic redirect following for exactly this reason.

Changes

  • librefang-http: add oauth_client_builder() / oauth_client() — proxy-aware client builders that pin redirect::Policy::none(). The shared build_http_client default is left untouched (LLM-streaming callers legitimately need redirects).
  • discover_oauth_metadata (librefang-runtime-mcp): build the discovery client via oauth_client_builder().
  • Token exchange (librefang-api, routes/mcp_auth.rs): build via librefang_kernel::http_client::oauth_client().
  • try_refresh and register_client / DCR (librefang-kernel, mcp_oauth_provider.rs): switch from librefang_extensions::http_client::new_client() (bundled-CA, Policy::limited(5), proxy-unaware) to librefang_runtime::http_client::oauth_client(). This disables redirects and also gives refresh/DCR the same proxy support discovery and token exchange already had (arch: six independent reqwest::Client constructions bypass librefang-http proxy/TLS #3577); with no proxy configured the behavior is unchanged.

Otherwise the per-site handling is unchanged: each call site already treats a non-is_success() response as a failure (discovery falls through to the next tier / config fallback; token/refresh/DCR log a redacted digest and surface a generic error).
With redirects disabled the credential body is never re-sent to a redirect target.

Verification

  • cargo check -p librefang-http -p librefang-runtime-mcp -p librefang-api -p librefang-kernel -p librefang-runtime --lib — passed, clean.
  • cargo clippy -p librefang-http -p librefang-runtime-mcp -p librefang-api -p librefang-kernel -p librefang-runtime --all-targets -- -D warnings — passed, zero warnings.
  • cargo test -p librefang-runtime --test mcp_oauth_integration — 10 passed, 0 failed. The new test_oauth_client_does_not_follow_redirects stands up a loopback mock that answers every non-/followed request with a 302 Location: /followed; it asserts oauth_client() returns the 302 verbatim (status 302) and never requests the redirect target. Existing OAuth discovery/lifecycle tests still pass.

(Run in the repo's Dockerfile.rust-dev image against an isolated target volume, per the project's no-local-cargo rule.)

Out of scope (intentionally separate PRs)

  • DNS-resolution SSRF: is_ssrf_blocked_url matches host strings literally, so a hostname whose A-record points at 169.254.169.254 / 127.0.0.1 still passes the initial-URL guard. Closing it requires resolving DNS before the IP-tier check (and pinning the connecting client's resolution, as web_fetch does); librefang-channels currently documents DNS-resolution SSRF as out of scope by design, so this is a deliberate, larger follow-up rather than something to bolt on here.
  • Other audit findings surfaced alongside this one (paired-device approval privilege escalation, the deterministic non-expiring dashboard session token, the WASM env-var blocklist suffix bypass, the MCP tool-definition prompt-injection scan gap, two kernel concurrency TOCTOU races, the proactive-memory off→on hot-reload no-op) each belong to a different crate/threat class and will be filed as their own PRs.

The four MCP OAuth outbound clients (metadata discovery, token exchange, dynamic client registration, refresh) followed HTTP redirects: reqwest's default policy follows up to 10 hops and the bundled-CA client used Policy::limited(5). The per-URL SSRF guard validates only the initial URL, never the redirect Location, so a malicious or compromised authorization server could 307/308 a credential-bearing POST to replay client_secret / code_verifier / refresh_token to an attacker-controlled host, or 302 metadata discovery into a blind SSRF / cloud-metadata pivot.

Add proxy-aware oauth_client_builder() / oauth_client() pinned to redirect::Policy::none() in librefang-http and route all four call sites through it. refresh and DCR also gain the proxy support discovery and token exchange already had (#3577); with no proxy configured their behavior is unchanged. This mirrors librefang_runtime::web_fetch's pinned client, which disables redirect following for the same reason. The shared build_http_client default is left untouched so LLM-streaming callers keep following redirects.

Adds a loopback regression test asserting oauth_client() returns a 302 verbatim and never requests the redirect target.
@github-actions github-actions Bot added size/M 50-249 lines changed area/docs Documentation and guides area/runtime Agent loop, LLM drivers, WASM sandbox area/kernel Core kernel (scheduling, RBAC, workflows) labels Jun 25, 2026

@houko houko left a comment

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pushed one mechanical fix (missing (#6315) PR reference in the CHANGELOG security entry — every other entry in the file has a PR number before the attribution). Five inline comments for CLAUDE.md prose-wrapping violations in the new comment blocks; no logic issues found. Security reasoning in the PR is sound.


Generated by Claude Code

Comment on lines +175 to +182
/// Proxy-aware [`reqwest::ClientBuilder`] that NEVER follows HTTP redirects, for OAuth machine-to-machine endpoints (metadata discovery, token exchange, dynamic client registration, token refresh).
///
/// OAuth endpoints return JSON directly and have no legitimate reason to emit a 3xx mid-flow.
/// Following a redirect on these calls is a security hole the per-URL SSRF guard cannot catch, because that guard validates only the initial URL — never the redirect `Location`.
/// A `307`/`308` on a credential-bearing POST replays the request body (`client_secret`, `code_verifier`, `refresh_token`) to an attacker-controlled redirect target; a `302` on discovery becomes a blind SSRF / cloud-metadata pivot.
///
/// This mirrors `librefang_runtime::web_fetch`'s pinned client, which disables reqwest's automatic redirect following for the same reason.
/// Use this for every outbound OAuth request; do not reach for [`proxied_client_builder`] there, whose default policy follows up to 10 hops.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: "never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

This doc comment spans two paragraphs (8 lines). The security rationale is genuinely important, but the rule caps doc comments at a single short line. Consider condensing to something like:

/// Proxy-aware OAuth [`reqwest::ClientBuilder`] with `redirect::Policy::none()` — a 3xx redirect on a credential POST replays the body to the redirect target, bypassing the SSRF guard that validates only the initial URL.

Generated by Claude Code

Comment on lines +938 to +942
// Exchange authorization code for tokens.
// Use the proxy-aware client so token endpoint requests respect proxy config
// and inherit default connect/read timeouts (prevents hung token exchanges).
let http_client = librefang_kernel::http_client::proxied_client();
// Use the proxy-aware OAuth client so token endpoint requests respect proxy
// config and inherit default connect/read timeouts (prevents hung token
// exchanges). Redirects are disabled: a 307/308 here would replay the PKCE
// code_verifier and any client_secret to an attacker-controlled target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md prose-wrapping rule: "Break only at sentence boundaries (after ., ?, !, or other terminal punctuation). One sentence = one line, regardless of length."

This block is two sentences wrapped at a column limit. Should be:

// Use the proxy-aware OAuth client so token endpoint requests respect proxy config and inherit default connect/read timeouts (prevents hung token exchanges).
// Redirects are disabled: a 307/308 here would replay the PKCE code_verifier and any client_secret to an attacker-controlled target.

Generated by Claude Code

Comment on lines +373 to +377
// Proxy-aware OAuth client with redirects disabled: a 307/308 on the
// refresh POST would replay refresh_token / client_secret to the
// redirect target, and the SSRF guard above validates only the initial
// token_endpoint, not redirect hops. `oauth_client` also routes through
// [proxy] config (#3577), which the previous bundled-CA client skipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md prose-wrapping rule: sentences should not be broken at a column limit. This block is two sentences wrapped at ~80 columns. Should be:

// Proxy-aware OAuth client with redirects disabled: a 307/308 on the refresh POST would replay refresh_token / client_secret to the redirect target, and the SSRF guard above validates only the initial token_endpoint, not redirect hops.
// `oauth_client` also routes through [proxy] config (#3577), which the previous bundled-CA client skipped.

Generated by Claude Code

Comment on lines +552 to +555
// Proxy-aware OAuth client with redirects disabled — same rationale as
// try_refresh / token exchange: an OAuth endpoint emitting a 3xx is
// never legitimate, and following it would pivot the registration POST
// (and the client_secret the AS echoes back) to an attacker target.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same prose-wrapping violation as the try_refresh block above: column-wrapping mid-sentence. Should be one sentence per line:

// Proxy-aware OAuth client with redirects disabled — same rationale as try_refresh / token exchange: an OAuth endpoint emitting a 3xx is never legitimate, and following it would pivot the registration POST (and the client_secret the AS echoes back) to an attacker target.

Generated by Claude Code

Comment on lines +781 to +785
// OAuth metadata discovery must respect [proxy] config (#3577) — corporate
// networks routinely require a proxy and OAuth was a primary failure case.
let client = librefang_http::proxied_client_builder()
// Redirects are disabled: a malicious/compromised MCP server returning a
// 302 would otherwise drive a blind SSRF / cloud-metadata pivot, since the
// SSRF guard validates only the initial URL, not redirect hops.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Column-wrapping mid-sentence. The two added sentences should each be on their own line:

// OAuth metadata discovery must respect [proxy] config (#3577) — corporate networks routinely require a proxy and OAuth was a primary failure case.
// Redirects are disabled: a malicious/compromised MCP server returning a 302 would otherwise drive a blind SSRF / cloud-metadata pivot, since the SSRF guard validates only the initial URL, not redirect hops.

Generated by Claude Code

Comment on lines +367 to +375
// ---------------------------------------------------------------------------
// Regression: OAuth HTTP clients must NOT follow redirects.
//
// A 307/308 on a credential-bearing OAuth POST (token exchange, refresh, DCR)
// would replay client_secret / code_verifier / refresh_token to the redirect
// target, and a 302 on metadata discovery is a blind SSRF / cloud-metadata
// pivot. The per-URL SSRF guard validates only the initial URL, so the client
// built by `oauth_client()` must itself refuse to follow any redirect.
// ---------------------------------------------------------------------------

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

CLAUDE.md: "never write multi-paragraph docstrings or multi-line comment blocks — one short line max."

The separator-line banner (// -----...) is the pre-existing test file's convention, but the new block adds a multi-paragraph prose comment inside the separators. If the function name test_oauth_client_does_not_follow_redirects is clear enough on its own (it is), the entire block can be dropped. If the WHY needs capturing, the existing separator pattern in this file already carries the first line; the multi-sentence paragraph inside it (lines 370–374) should be reduced to the one non-obvious constraint: that Policy::none() is the mechanism being tested.


Generated by Claude Code

@houko
houko merged commit 6208951 into main Jun 25, 2026
32 checks passed
@houko
houko deleted the harden-mcp-oauth-ssrf branch June 25, 2026 13:15
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area/docs Documentation and guides area/kernel Core kernel (scheduling, RBAC, workflows) area/runtime Agent loop, LLM drivers, WASM sandbox size/M 50-249 lines changed

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants