Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
89 changes: 89 additions & 0 deletions docs/guides/dns-rebinding-protection.md
Original file line number Diff line number Diff line change
@@ -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)
5 changes: 4 additions & 1 deletion src/mcp/shared/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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}")
Expand Down
48 changes: 48 additions & 0 deletions test_validate_scope_fix.py
Original file line number Diff line number Diff line change
@@ -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")
Loading