diff --git a/docs/guides/dns-rebinding-protection.md b/docs/guides/dns-rebinding-protection.md new file mode 100644 index 000000000..88437a921 --- /dev/null +++ b/docs/guides/dns-rebinding-protection.md @@ -0,0 +1,89 @@ +# DNS Rebinding Protection + +The MCP Python SDK includes DNS rebinding protection to prevent DNS rebinding attacks. While this improves security, it may cause existing setups to fail with a **421 Misdirected Request / Invalid Host Header** error if the host header doesn't match the allowed list. + +This commonly occurs when using: +- Reverse proxies (Nginx, Caddy, etc.) +- API gateways +- Custom domains +- Docker/Kubernetes networking + +## Resolving the Error + +Depending on your security requirements, you can resolve this in two ways: + +### Option 1: Explicitly Allow Specific Hosts (Recommended for Production) + +Use this approach if you are running in production or through a gateway. You can wildcard the ports using `*`. + +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings + +mcp = FastMCP( + "MyServer", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=True, + # Add your specific gateway or domain here + allowed_hosts=["localhost:*", "127.0.0.1:*", "your-gateway-host:*"], + allowed_origins=["http://localhost:*", "http://your-gateway-host:*"], + ) +) +``` + +### Option 2: Disable DNS Rebinding Protection (Development Only) + +Use this approach for local development or if you are managing security at a different layer of your infrastructure. + +```python +from mcp.server.fastmcp import FastMCP +from mcp.server.transport_security import TransportSecuritySettings + +mcp = FastMCP( + "MyServer", + transport_security=TransportSecuritySettings( + enable_dns_rebinding_protection=False, + ) +) +``` + +## Common Scenarios + +### Using with Nginx + +If you're using Nginx as a reverse proxy, ensure it's passing the correct headers: + +```nginx +location / { + proxy_pass http://localhost:8000; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; +} +``` + +And configure your MCP server to allow the Nginx host: + +```python +allowed_hosts=["localhost:*", "your-domain.com:*"] +``` + +### Using with Docker + +When running in Docker, you may need to allow the container hostname: + +```python +allowed_hosts=["localhost:*", "127.0.0.1:*", "mcp-server:*"] +``` + +## Security Considerations + +- **Production**: Always use Option 1 with explicit host allowlisting +- **Development**: Option 2 is acceptable for local testing +- **Never** disable DNS rebinding protection in production environments exposed to the internet + +## Related Issues + +- Original implementation: [#861](https://github.com/modelcontextprotocol/python-sdk/pull/861) +- Common errors: [#1797](https://github.com/modelcontextprotocol/python-sdk/issues/1797) diff --git a/src/mcp/shared/auth.py b/src/mcp/shared/auth.py index ca5b7b45a..583acc9b2 100644 --- a/src/mcp/shared/auth.py +++ b/src/mcp/shared/auth.py @@ -71,7 +71,10 @@ def validate_scope(self, requested_scope: str | None) -> list[str] | None: if requested_scope is None: return None requested_scopes = requested_scope.split(" ") - allowed_scopes = [] if self.scope is None else self.scope.split(" ") + # When no scope is required (None), allow all requested scopes + if self.scope is None: + return requested_scopes + allowed_scopes = self.scope.split(" ") for scope in requested_scopes: if scope not in allowed_scopes: # pragma: no branch raise InvalidScopeError(f"Client was not registered with scope {scope}") diff --git a/test_validate_scope_fix.py b/test_validate_scope_fix.py new file mode 100644 index 000000000..bb84f42e0 --- /dev/null +++ b/test_validate_scope_fix.py @@ -0,0 +1,48 @@ +"""Test for validate_scope fix when self.scope is None""" +import pytest +from mcp.shared.auth import ClientRegistration, InvalidScopeError + + +def test_validate_scope_with_none_scope_allows_all(): + """When client has no scope restriction (None), all requested scopes should be allowed.""" + client = ClientRegistration( + client_id="test-client", + client_secret="secret", + scope=None, # No scope restriction + redirect_uris=["http://localhost/callback"], + ) + + # Should not raise - all scopes allowed when no restriction + result = client.validate_scope("read write admin") + assert result == ["read", "write", "admin"] + + +def test_validate_scope_with_empty_requested_returns_none(): + """When requested_scope is None, return None.""" + client = ClientRegistration( + client_id="test-client", + client_secret="secret", + scope="read write", + redirect_uris=["http://localhost/callback"], + ) + + result = client.validate_scope(None) + assert result is None + + +def test_validate_scope_with_restrictions_enforced(): + """When client has scope restrictions, only allowed scopes pass.""" + client = ClientRegistration( + client_id="test-client", + client_secret="secret", + scope="read write", + redirect_uris=["http://localhost/callback"], + ) + + # Allowed scope + result = client.validate_scope("read") + assert result == ["read"] + + # Disallowed scope should raise + with pytest.raises(InvalidScopeError): + client.validate_scope("admin")