diff --git a/docs/more/settings.mdx b/docs/more/settings.mdx index af6b862fe..74b9938aa 100644 --- a/docs/more/settings.mdx +++ b/docs/more/settings.mdx @@ -88,6 +88,26 @@ When setting Docket values in a `.env` file, use a **double** underscore: `FASTM | `FASTMCP_DOCKET_RECONNECTION_DELAY` | `timedelta` | `5s` | Delay between reconnection attempts when the worker loses its backend connection. | | `FASTMCP_DOCKET_MINIMUM_CHECK_INTERVAL` | `timedelta` | `50ms` | How frequently the worker polls for new tasks. Lower values reduce latency at the cost of more CPU usage. | +## Security + +These control FastMCP's SSRF protection for the outbound fetches it makes during authentication (OAuth client metadata and JWKS). + +| Environment Variable | Type | Default | Description | +|---|---|---|---| +| `FASTMCP_SSRF_TRUST_PROXY` | `bool` | `false` | Trust an outbound HTTP proxy for SSRF-protected fetches. When `false`, FastMCP resolves the target hostname itself and refuses to connect if it maps to a private, loopback, link-local, or reserved IP. When `true`, FastMCP routes auth metadata and JWKS fetches through the configured `HTTPS_PROXY`/`ALL_PROXY` and does not honor `NO_PROXY`; if no proxy is configured the fetch is refused. | + +By default, FastMCP protects its OAuth and JWKS fetches against [SSRF](https://owasp.org/www-community/attacks/Server_Side_Request_Forgery) by resolving the target hostname, rejecting any address that maps to a private, loopback, link-local, or reserved IP, and then pinning the connection to that validated IP. + +This breaks when a corporate `CONNECT` proxy is the only egress path: the container often cannot resolve external DNS at all (only the proxy can), and even when it can, pinning to the IP makes TLS verification fail because public certificates list hostnames, not IP addresses. + +Set `FASTMCP_SSRF_TRUST_PROXY=true` when a trusted proxy is your mandated egress. FastMCP then skips DNS resolution and the IP blocklist entirely and makes a single request to the hostname URL, explicitly routed through the proxy named by the standard `HTTPS_PROXY` / `ALL_PROXY` environment variables (checked in that order). The HTTPS-only and hostname checks still apply. + + +This is a deliberate trust shift: the IP blocklist cannot be enforced through a proxy (the proxy does its own DNS, so an address FastMCP resolved is not the one the proxy dials). Only enable it when the proxy itself is trusted to mediate egress. + +FastMCP reads the proxy URL from the environment and passes it to the HTTP client explicitly, so environment proxy selection and `NO_PROXY` do not participate — the request either goes through that exact proxy or fails outright. TLS trust environment variables remain enabled, so `SSL_CERT_FILE` and `SSL_CERT_DIR` continue to work for deployments that install a corporate CA. A host that `NO_PROXY` would otherwise exclude is still routed through the configured proxy rather than fetched direct with the IP blocklist disabled — the safer of the two options, since the blocklist cannot apply to a direct fetch here anyway. If you set `FASTMCP_SSRF_TRUST_PROXY=true` but neither `HTTPS_PROXY` nor `ALL_PROXY` is present in the server process's environment (an `HTTP_PROXY` alone never routes these HTTPS-only fetches), the request would otherwise go out **direct with the IP blocklist disabled** — no SSRF protection at all. Rather than send it, FastMCP refuses the fetch and raises `SSRFError` with an actionable message. The contract is crisp: proxy-trust mode delegates SSRF protection to the proxy, and with no proxy configured the fetch cannot proceed. Enable this setting only together with an active proxy that routes your auth endpoints. + + ## Advanced | Environment Variable | Type | Default | Description | diff --git a/fastmcp_slim/fastmcp/server/auth/ssrf.py b/fastmcp_slim/fastmcp/server/auth/ssrf.py index 1e240672c..0d8f937ae 100644 --- a/fastmcp_slim/fastmcp/server/auth/ssrf.py +++ b/fastmcp_slim/fastmcp/server/auth/ssrf.py @@ -4,12 +4,22 @@ This module provides SSRF-protected HTTP fetching with: - DNS resolution and IP validation before requests - DNS pinning to prevent rebinding TOCTOU attacks - Support for both CIMD and JWKS fetches + +When ``FASTMCP_SSRF_TRUST_PROXY`` is set, DNS resolution and the IP blocklist are +skipped and a single request is made to the hostname URL through the configured +HTTPS_PROXY/ALL_PROXY, delegating DNS and egress to that trusted proxy (the scheme +and hostname checks still apply). The proxy URL is read from the environment and +passed to httpx explicitly, so environment proxy selection and NO_PROXY are not +evaluated. ``trust_env`` remains enabled so SSL_CERT_FILE and SSL_CERT_DIR continue +to provide corporate CA trust. If no proxy is configured, the fetch is refused +rather than sent direct with the blocklist disabled. """ from __future__ import annotations import asyncio import ipaddress +import os import socket import time from collections.abc import Mapping @@ -18,6 +28,7 @@ from urllib.parse import urlparse import httpx +import fastmcp from fastmcp.utilities.logging import get_logger logger = get_logger(__name__) @@ -62,6 +73,26 @@ def format_ip_for_url(ip_str: str) -> str: return ip_str +def _configured_proxy_url() -> str | None: + """Return the proxy URL to route proxy-trust fetches through, if any is set. + + Reads ``HTTPS_PROXY``/``https_proxy`` first, falling back to ``ALL_PROXY``/ + ``all_proxy``. This is a simple presence check: no host matching, no ``NO_PROXY`` + evaluation. The caller passes the returned URL to httpx explicitly, which takes + precedence over environment proxy selection and NO_PROXY while preserving + environment-provided CA trust — see the module docstring and :func:`validate_url` + for why that matters. + + Returns: + The configured proxy URL, or None if none of the supported variables are set. + """ + for name in ("HTTPS_PROXY", "https_proxy", "ALL_PROXY", "all_proxy"): + value = os.environ.get(name) + if value: + return value + return None + + class SSRFError(Exception): """Raised when an SSRF protection check fails.""" @@ -179,6 +210,7 @@ class ValidatedURL: port: int path: str resolved_ips: list[str] + proxy_url: str | None = None @dataclass @@ -190,6 +222,55 @@ class SSRFFetchResponse: headers: dict[str, str] +@dataclass +class _FetchTarget: + """A single connection attempt for an SSRF-safe fetch. + + In pinned (default) mode there is one target per resolved IP: the request goes to + an IP-literal URL with Host and SNI pinned to the validated hostname. In proxy + mode (FASTMCP_SSRF_TRUST_PROXY) there is a single target: the original hostname + URL with no pinning and an explicit ``proxy_url``, so the request is dialed + through the trusted proxy and the proxy (not httpx's environment-proxy routing) + owns DNS and TLS. + """ + + url: str + host_header: str | None + sni_hostname: str | None + proxy_url: str | None = None + + +def _build_fetch_targets(validated: ValidatedURL) -> list[_FetchTarget]: + """Build the ordered connection attempts for a validated URL. + + An empty ``resolved_ips`` means proxy mode (see :func:`validate_url`): a single + unpinned request to the original hostname URL, explicitly routed through + ``validated.proxy_url``. Otherwise, one pinned IP-literal request per resolved + IP, tried in order with fallback on connection error. + """ + if not validated.resolved_ips: + # Proxy mode: dial the original hostname URL verbatim and let the proxy parse + # and resolve it. validated.hostname is informational here — it does not + # constrain what gets dialed — so do not pin Host or SNI from it. + return [ + _FetchTarget( + url=validated.original_url, + host_header=None, + sni_hostname=None, + proxy_url=validated.proxy_url, + ) + ] + + return [ + _FetchTarget( + url=f"https://{format_ip_for_url(ip)}:{validated.port}{validated.path}", + host_header=validated.hostname, + sni_hostname=validated.hostname, + ) + for ip in validated.resolved_ips + ] + + async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: """Validate URL for SSRF and resolve to IPs. @@ -201,7 +282,8 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: ValidatedURL with resolved IPs Raises: - SSRFError: If URL is invalid or resolves to blocked IPs + SSRFError: If the URL is invalid, resolves to blocked IPs, or proxy-trust + mode is enabled but no configured proxy will route the request. """ try: parsed = urlparse(url) @@ -219,8 +301,55 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: hostname = parsed.hostname or parsed.netloc port = parsed.port or 443 + path = parsed.path + ("?" + parsed.query if parsed.query else "") - # Resolve and validate IPs + # Proxy mode (FASTMCP_SSRF_TRUST_PROXY): a trusted outbound proxy owns DNS and + # egress, so resolving the hostname here is pointless — the IP we'd pin is not + # the one the proxy dials, making the blocklist unenforceable theater. Skip + # resolution and the blocklist entirely and signal proxy mode downstream with an + # empty resolved_ips list. The scheme (HTTPS) and host checks above still run. + if fastmcp.settings.ssrf_trust_proxy: + # Skipping the blocklist is only safe if the request is *actually* routed + # through a trusted proxy, so this does not try to predict whether it will + # be — it controls it. Earlier revisions predicted the HTTP client's routing + # decision, first by approximating NO_PROXY handling with urllib's proxy + # bypass helper, + # then by replicating the client's own environment-proxy matching + # internally. Both were still predictions of a library with + # open-ended NO_PROXY semantics, and each was found wrong for a different + # NO_PROXY form (port-qualified, IPv6, scheme-qualified entries each broke a + # different revision) — always in the dangerous direction of assuming + # "proxied" for a request that actually went out direct. + # + # Instead, read the proxy URL directly from the environment and hand it to + # httpx explicitly below. An explicit `proxy=` fixes httpx's proxy map without + # consulting NO_PROXY, even with `trust_env=True`; keeping trust_env enabled + # preserves SSL_CERT_FILE and SSL_CERT_DIR for corporate CA trust. The request + # therefore goes through that proxy or the connection fails. A NO_PROXY'd + # host is routed through the proxy rather than fetched direct with the + # blocklist already disabled, which is strictly safer than the alternative + # (see the module docstring). If no proxy is configured, there is nothing to + # route through, so refuse rather than fetch unprotected. + proxy_url = _configured_proxy_url() + if proxy_url is None: + raise SSRFError( + f"FASTMCP_SSRF_TRUST_PROXY is enabled but no HTTPS_PROXY/ALL_PROXY is " + f"configured, so the request to {hostname} would go direct with SSRF " + f"protection disabled. Set HTTPS_PROXY (or ALL_PROXY) to the trusted " + f"proxy, or unset FASTMCP_SSRF_TRUST_PROXY to restore DNS/IP " + f"validation." + ) + return ValidatedURL( + original_url=url, + hostname=hostname, + port=port, + path=path, + resolved_ips=[], + proxy_url=proxy_url, + ) + + # Resolve and validate IPs (resolve_hostname raises rather than returning [], so a + # successful return here always yields a non-empty list — see ssrf_safe_fetch_response). resolved_ips = await resolve_hostname(hostname, port) blocked = [ip for ip in resolved_ips if not is_ip_allowed(ip)] @@ -234,7 +363,7 @@ async def validate_url(url: str, require_path: bool = False) -> ValidatedURL: original_url=url, hostname=hostname, port=port, - path=parsed.path + ("?" + parsed.query if parsed.query else ""), + path=path, resolved_ips=resolved_ips, ) @@ -305,31 +434,34 @@ async def ssrf_safe_fetch_response( last_error: Exception | None = None expected_statuses = allowed_status_codes or {200} - for pinned_ip in validated.resolved_ips: + # One target per pinned IP in default mode; a single unpinned target in proxy mode. + targets = _build_fetch_targets(validated) + + for target in targets: elapsed = time.monotonic() - start_time if elapsed > overall_timeout: raise SSRFFetchError(f"Overall timeout exceeded: {url}") remaining = max(1.0, overall_timeout - elapsed) - pinned_url = ( - f"https://{format_ip_for_url(pinned_ip)}:{validated.port}{validated.path}" - ) + logger.debug("SSRF-safe fetch: %s -> %s", url, target.url) - logger.debug( - "SSRF-safe fetch: %s -> %s (pinned to %s)", - url, - pinned_url, - pinned_ip, - ) - - headers = {"Host": validated.hostname} + # In pinned mode Host is forced to the validated hostname; in proxy mode httpx + # derives it from the hostname URL. Either way, never let a caller override it. + headers: dict[str, str] = {} + if target.host_header is not None: + headers["Host"] = target.host_header if request_headers: for key, value in request_headers.items(): - # Host must remain pinned to the validated hostname. if key.lower() == "host": continue headers[key] = value + # Pin SNI to the hostname when connecting to an IP literal; in proxy mode httpx + # derives SNI from the URL, so no override is sent. + extensions: dict[str, str] = {} + if target.sni_hostname is not None: + extensions["sni_hostname"] = target.sni_hostname + try: # Use httpx with streaming to enforce size limit during download async with ( @@ -342,12 +474,17 @@ async def ssrf_safe_fetch_response( ), follow_redirects=False, verify=True, + # An explicit proxy_url controls routing without consulting + # environment proxy selection or NO_PROXY. Keep trust_env enabled + # in both modes so SSL_CERT_FILE and SSL_CERT_DIR remain effective. + proxy=target.proxy_url, + trust_env=True, ) as client, client.stream( "GET", - pinned_url, + target.url, headers=headers, - extensions={"sni_hostname": validated.hostname}, + extensions=extensions, ) as response, ): if time.monotonic() - start_time > overall_timeout: @@ -399,4 +536,4 @@ async def ssrf_safe_fetch_response( raise SSRFFetchError(f"Timeout fetching {url}") from last_error raise SSRFFetchError(f"Error fetching {url}: {last_error}") from last_error - raise SSRFFetchError(f"Error fetching {url}: no resolved IPs succeeded") + raise SSRFFetchError(f"Error fetching {url}: no fetch targets succeeded") diff --git a/fastmcp_slim/fastmcp/settings.py b/fastmcp_slim/fastmcp/settings.py index 72cdbe5f2..0a7790d68 100644 --- a/fastmcp_slim/fastmcp/settings.py +++ b/fastmcp_slim/fastmcp/settings.py @@ -310,6 +310,26 @@ class Settings(BaseSettings): ), ] = False + ssrf_trust_proxy: Annotated[ + bool, + Field( + description=inspect.cleandoc( + """ + Trust an outbound HTTP proxy for SSRF-protected fetches (OAuth client + metadata and JWKS). When False (default), FastMCP resolves the target + hostname itself and refuses to connect if it maps to a private, + loopback, link-local, or otherwise reserved IP. When True, FastMCP + routes auth metadata and JWKS fetches through the configured + HTTPS_PROXY/ALL_PROXY and does not honor NO_PROXY; if no proxy is + configured the fetch is refused (raising SSRFError) rather than sent + direct with the blocklist disabled. Only enable this when a trusted + corporate proxy is the mandated egress path: it shifts SSRF trust to + that proxy. Scheme (HTTPS-only) and hostname checks still apply. + """ + ), + ), + ] = False + server_dependencies: list[str] = Field( default_factory=list, description="List of dependencies to install in the server environment", diff --git a/tests/server/auth/test_ssrf_protection.py b/tests/server/auth/test_ssrf_protection.py index 0236942f8..d9e23c708 100644 --- a/tests/server/auth/test_ssrf_protection.py +++ b/tests/server/auth/test_ssrf_protection.py @@ -3,18 +3,56 @@ This module tests the ssrf.py module which provides SSRF-protected HTTP fetching. """ +import socket from unittest.mock import AsyncMock, MagicMock, patch import httpx import pytest +import fastmcp from fastmcp.server.auth.ssrf import ( SSRFError, SSRFFetchError, is_ip_allowed, ssrf_safe_fetch, + ssrf_safe_fetch_response, validate_url, ) +from fastmcp.utilities.tests import temporary_settings + + +def _mock_httpx_client( + *, + status_code: int = 200, + headers: dict[str, str] | None = None, + body_chunks: list[bytes] | None = None, +) -> AsyncMock: + """Build a mock httpx.AsyncClient whose stream() yields a canned response. + + The returned client's ``.stream.call_args`` exposes the request that was made. + """ + if headers is None: + headers = {"content-length": "2"} + if body_chunks is None: + body_chunks = [b"ok"] + + mock_stream = MagicMock() + mock_stream.status_code = status_code + mock_stream.headers = headers + mock_stream.__aenter__ = AsyncMock(return_value=mock_stream) + mock_stream.__aexit__ = AsyncMock(return_value=None) + + async def aiter_bytes(): + for chunk in body_chunks: + yield chunk + + mock_stream.aiter_bytes = aiter_bytes + + mock_client = AsyncMock() + mock_client.stream = MagicMock(return_value=mock_stream) + mock_client.__aenter__.return_value = mock_client + mock_client.__aexit__ = AsyncMock(return_value=None) + return mock_client class TestIsIPAllowed: @@ -506,3 +544,317 @@ class TestStreamingResponseSizeLimit: with pytest.raises(SSRFFetchError, match="too large"): await ssrf_safe_fetch("https://example.com/api", max_size=5120) + + +class TestProxyMode: + """Tests for FASTMCP_SSRF_TRUST_PROXY (proxy trust) mode. + + In proxy mode FastMCP skips its own DNS resolution and IP blocklist. Rather than + predicting whether the HTTP client would route a request through a proxy -- a strategy + that broke three times chasing different NO_PROXY forms (port-qualified, IPv6, + scheme-qualified) -- it reads the proxy URL directly from the environment and + hands it to httpx explicitly, so the request is provably routed through that + proxy rather than predicted to be. NO_PROXY is therefore not evaluated, while + trust_env remains enabled for environment-provided CA trust. The scheme (HTTPS) + and host checks still apply. + """ + + @pytest.fixture(autouse=True) + def _clear_proxy_env(self, monkeypatch): + """Start every test from a clean slate for both spellings of every proxy + variable, so a proxy inherited from the host/CI environment (or left behind + by another test) can't leak in and make behavior non-deterministic.""" + for name in ( + "HTTP_PROXY", + "http_proxy", + "HTTPS_PROXY", + "https_proxy", + "ALL_PROXY", + "all_proxy", + "NO_PROXY", + "no_proxy", + ): + monkeypatch.delenv(name, raising=False) + + def test_flag_defaults_to_false(self): + """The trust-proxy flag must be off by default (no silent weakening).""" + assert fastmcp.settings.ssrf_trust_proxy is False + + async def test_validate_url_skips_resolution_and_blocklist(self, monkeypatch): + """Proxy mode returns resolved_ips=[] without resolving or blocklisting, and + carries the configured proxy URL for the fetch to use.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname") as mock_resolve, + patch("fastmcp.server.auth.ssrf.is_ip_allowed") as mock_blocklist, + ): + result = await validate_url("https://example.com/path") + + assert result.resolved_ips == [] + assert result.original_url == "https://example.com/path" + assert result.hostname == "example.com" + assert result.proxy_url == "http://proxy.internal:3128" + mock_resolve.assert_not_called() + mock_blocklist.assert_not_called() + + async def test_validate_url_still_rejects_http(self, monkeypatch): + """Proxy mode keeps the HTTPS-only scheme check.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + with temporary_settings(ssrf_trust_proxy=True): + with pytest.raises(SSRFError, match="must use HTTPS"): + await validate_url("http://example.com/path") + + async def test_validate_url_still_rejects_missing_host(self, monkeypatch): + """Proxy mode keeps the host check.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + with temporary_settings(ssrf_trust_proxy=True): + with pytest.raises(SSRFError, match="must have a host"): + await validate_url("https:///path") + + async def test_validate_url_still_enforces_require_path(self, monkeypatch): + """Proxy mode keeps the require_path check (CIMD).""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + with temporary_settings(ssrf_trust_proxy=True): + with pytest.raises(SSRFError, match="non-root path"): + await validate_url("https://example.com/", require_path=True) + + async def test_raises_when_no_proxy_is_configured(self): + """No proxy in the environment → refuse rather than fetch unprotected.""" + with temporary_settings(ssrf_trust_proxy=True): + with pytest.raises(SSRFError, match="no HTTPS_PROXY/ALL_PROXY"): + await validate_url("https://example.com/path") + + async def test_fetch_refuses_end_to_end_when_no_proxy_configured(self): + """The refusal surfaces through ssrf_safe_fetch: no client is ever built. + + The whole point of the hard failure is that the *fetch* cannot proceed, so + this drives it through the public entrypoint and asserts no httpx client is + ever constructed — the request never leaves the process with the blocklist + disabled. + """ + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("httpx.AsyncClient") as mock_client_class, + ): + with pytest.raises(SSRFError, match="no HTTPS_PROXY/ALL_PROXY"): + await ssrf_safe_fetch("https://example.com/api") + + mock_client_class.assert_not_called() + + async def test_https_proxy_used_explicitly(self, monkeypatch): + """HTTPS_PROXY is passed to httpx explicitly while CA environment handling + remains enabled, and a single request goes to the original hostname URL — not + an IP literal. + + This is the property the whole redesign rests on: an explicit proxy= fixes + httpx's proxy map without consulting NO_PROXY, while trust_env=True preserves + SSL_CERT_FILE and SSL_CERT_DIR handling. + """ + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + monkeypatch.setenv("SSL_CERT_FILE", "/corporate-ca.pem") + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname") as mock_resolve, + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + content = await ssrf_safe_fetch("https://example.com/api") + + assert content == b"ok" + mock_resolve.assert_not_called() + + client_kwargs = mock_client_class.call_args[1] + assert client_kwargs["proxy"] == "http://proxy.internal:3128" + assert client_kwargs["trust_env"] is True + + # A single request to the original hostname URL — not an IP literal. + assert mock_client.stream.call_count == 1 + url_called = mock_client.stream.call_args[0][1] + assert url_called == "https://example.com/api" + + # No Host override and no SNI override — the client derives both from the URL. + call_kwargs = mock_client.stream.call_args[1] + assert "Host" not in call_kwargs["headers"] + assert call_kwargs["extensions"] == {} + + # Redirects stay disabled and TLS verification stays on. + assert client_kwargs["follow_redirects"] is False + assert client_kwargs["verify"] is True + + async def test_all_proxy_used_as_fallback(self, monkeypatch): + """ALL_PROXY routes the fetch when HTTPS_PROXY is not set.""" + monkeypatch.setenv("ALL_PROXY", "http://all-proxy.internal:3128") + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + content = await ssrf_safe_fetch("https://example.com/api") + + assert content == b"ok" + client_kwargs = mock_client_class.call_args[1] + assert client_kwargs["proxy"] == "http://all-proxy.internal:3128" + assert client_kwargs["trust_env"] is True + + async def test_https_proxy_preferred_over_all_proxy(self, monkeypatch): + """When both are set, HTTPS_PROXY takes priority.""" + monkeypatch.setenv("HTTPS_PROXY", "http://https-proxy.internal:3128") + monkeypatch.setenv("ALL_PROXY", "http://all-proxy.internal:3128") + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + await ssrf_safe_fetch("https://example.com/api") + + proxy_used = mock_client_class.call_args[1]["proxy"] + assert proxy_used == "http://https-proxy.internal:3128" + + async def test_no_proxy_is_not_honored(self, monkeypatch): + """Documents the behavior change: a NO_PROXY entry that would previously have + matched the target host no longer excludes it. The fetch still proceeds + through the configured proxy rather than being refused, because routing a + NO_PROXY'd host through the proxy is strictly safer than the alternative — + fetching it direct with the IP blocklist already disabled. + """ + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + monkeypatch.setenv("NO_PROXY", "example.com") + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + content = await ssrf_safe_fetch("https://example.com/api") + + assert content == b"ok" + client_kwargs = mock_client_class.call_args[1] + assert client_kwargs["proxy"] == "http://proxy.internal:3128" + assert client_kwargs["trust_env"] is True + + async def test_fetch_preserves_request_headers_but_drops_host(self, monkeypatch): + """Caller headers pass through, but a caller-supplied Host is dropped.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client), + ): + await ssrf_safe_fetch_response( + "https://example.com/api", + request_headers={"If-None-Match": "etag", "Host": "evil.example"}, + ) + + sent_headers = mock_client.stream.call_args[1]["headers"] + assert sent_headers["If-None-Match"] == "etag" + assert "Host" not in sent_headers + + async def test_fetch_size_limit_preserved(self, monkeypatch): + """Proxy mode still enforces the response size limit during streaming.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + big_chunks = [b"x" * 1024 for _ in range(10)] + mock_client = _mock_httpx_client(headers={}, body_chunks=big_chunks) + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client), + ): + with pytest.raises(SSRFFetchError, match="too large"): + await ssrf_safe_fetch("https://example.com/api", max_size=5120) + + async def test_fetch_status_check_preserved(self, monkeypatch): + """Proxy mode still rejects non-allowed status codes.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + mock_client = _mock_httpx_client(status_code=404, body_chunks=[b"no"]) + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("fastmcp.server.auth.ssrf.resolve_hostname"), + patch("httpx.AsyncClient", return_value=mock_client), + ): + with pytest.raises(SSRFFetchError, match="HTTP 404"): + await ssrf_safe_fetch("https://example.com/api") + + async def test_gaierror_repro_succeeds_through_proxy(self, monkeypatch): + """Reproduces issue #4292: on a host with no external DNS at all (every + getaddrinfo() call raises gaierror), the OAuth/JWKS fetch still succeeds in + proxy-trust mode, because DNS resolution is never attempted — only HTTPS_PROXY + is read and the proxy resolves the target. This is the reporter's exact + failure mode, and the strongest proof the redesign closes the issue: unlike + other tests in this class, resolve_hostname itself is *not* mocked, so if + proxy mode ever regressed into calling it, this test would fail with SSRFError + instead of succeeding. + """ + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + + def _no_dns(*args, **kwargs): + raise socket.gaierror("Name or service not known") + + monkeypatch.setattr(socket, "getaddrinfo", _no_dns) + + mock_client = _mock_httpx_client() + with ( + temporary_settings(ssrf_trust_proxy=True), + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + content = await ssrf_safe_fetch("https://example.com/api") + + assert content == b"ok" + client_kwargs = mock_client_class.call_args[1] + assert client_kwargs["proxy"] == "http://proxy.internal:3128" + assert client_kwargs["trust_env"] is True + assert mock_client.stream.call_args[0][1] == "https://example.com/api" + + async def test_default_mode_still_resolves_and_pins(self): + """Regression: with the flag off, resolution + blocklist + IP pinning still + apply, and no explicit proxy is passed to the client.""" + resolved_ip = "93.184.216.34" + mock_client = _mock_httpx_client() + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ) as mock_resolve, + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + assert fastmcp.settings.ssrf_trust_proxy is False + await ssrf_safe_fetch("https://example.com/api") + + mock_resolve.assert_called_once() + + # Connection is pinned to the resolved IP literal, with Host + SNI = hostname. + call_args = mock_client.stream.call_args + url_called = call_args[0][1] + assert resolved_ip in url_called + assert call_args[1]["headers"]["Host"] == "example.com" + assert call_args[1]["extensions"] == {"sni_hostname": "example.com"} + + # No explicit proxy is passed, and trust_env keeps its normal default. + client_kwargs = mock_client_class.call_args[1] + assert client_kwargs["proxy"] is None + assert client_kwargs["trust_env"] is True + + async def test_default_mode_ignores_proxy_env_vars(self, monkeypatch): + """Regression: proxy env vars — including a hostile NO_PROXY that previously + caused non-deterministic failures — must not affect the default (non-trust) + path at all, since it never reads them.""" + monkeypatch.setenv("HTTPS_PROXY", "http://proxy.internal:3128") + monkeypatch.setenv("NO_PROXY", "localhost,127.0.0.1,::1") + resolved_ip = "93.184.216.34" + mock_client = _mock_httpx_client() + with ( + patch( + "fastmcp.server.auth.ssrf.resolve_hostname", + return_value=[resolved_ip], + ) as mock_resolve, + patch("httpx.AsyncClient", return_value=mock_client) as mock_client_class, + ): + assert fastmcp.settings.ssrf_trust_proxy is False + await ssrf_safe_fetch("https://example.com/api") + + mock_resolve.assert_called_once() + assert mock_client_class.call_args[1]["proxy"] is None + assert mock_client_class.call_args[1]["trust_env"] is True