You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
DNS rebinding protection defaults off when security_settings is omitted (non-loopback binds stay unprotected) | #3562
Static review of public source at commit 6affe5c0d358. No traffic was sent to any MCP environment.
TransportSecurityMiddleware treats a missing settings argument as “protection off”, even though the settings model itself defaults enable_dns_rebinding_protection to True:
enable_dns_rebinding_protection: bool = True
...
def __init__(self, settings: TransportSecuritySettings | None = None):
# If not specified, disable DNS rebinding protection by default for backwards compatibility
self.settings = settings or TransportSecuritySettings(enable_dns_rebinding_protection=False)
StreamableHTTPServerTransport passes that through unchanged (security_settings: ... = None → TransportSecurityMiddleware(security_settings) at the transport constructor).
MCPServer.streamable_http_app / sse_app only auto-enable an allowlist when host is loopback (127.0.0.1 / localhost / ::1) — see src/mcp/server/lowlevel/server.py around the auto-enable block. Binding or mounting with 0.0.0.0, a LAN IP, or a reverse-proxy hostname therefore ships without Host/Origin checks unless the operator remembers to pass TransportSecuritySettings explicitly.
DNS rebinding against a browser-reachable MCP HTTP transport is exactly what those checks are for. Fail-open on the common “I mounted the ASGI app / bound all interfaces” path is the surprising default.
Suggested change:
When security_settings is None, enable protection with a documented default allowlist (at least the bind host), or refuse to serve HTTP transports until settings are provided.
Keep an explicit opt-out (enable_dns_rebinding_protection=False) for demos that truly need it.
Log once at startup when protection is disabled.
Severity: medium as insecure default / defense-in-depth for HTTP transports; not claiming a working exploit against a specific deployment. No proof-of-concept.
Happy to send a focused PR if this direction is useful.
Confirmed on main (f1b6589). transport_security.py L46-48 falls back to enable_dns_rebinding_protection=False when settings is None, and the auto-enable in lowlevel/server.py L742 and mcpserver/server.py L1155 only fires for 127.0.0.1 / localhost / ::1. v1.x fastmcp/server.py L191-195 has the same block.
Two things to add:
The bigger gap is people wiring StreamableHTTPSessionManager or SseServerTransport directly. That path never goes through the loopback auto-enable, so protection is off even on localhost unless they pass settings.
"Allowlist the bind host" won't work for 0.0.0.0. Browsers send the public hostname, not the bind address, so there's nothing to derive. I think the safer default for a non-loopback host with no settings is to log a clear warning at startup (or refuse to start) until allowed_hosts is set, with an explicit opt-out for people behind a proxy that already checks Host.
For context, I maintain agent-audit-kit, and its AAK-DNS-REBIND-001 rule flags StreamableHTTP/FastMCP network transports with no allowed_hosts. It doesn't yet check for enable_dns_rebinding_protection=False explicitly. I'll add that case.
Thanks for the review. I'm going to close this as not planned.
The mounted-app case is already covered. streamable_http_app() and sse_app() default to host="127.0.0.1", so with no settings the protection is on and any request for a real hostname gets a 421. It's only off if you pass a non-localhost host= yourself, and Deploy & scale says to set transport_security= in that case.
For a server bound to 0.0.0.0 there's no hostname we could build an allowlist from. Switching the low-level default on would also reject every request on existing 2.x deployments that build the transport directly.
Activity
sattyamjjain commentedon Sep 28, 2026
Confirmed on main (f1b6589). transport_security.py L46-48 falls back to enable_dns_rebinding_protection=False when settings is None, and the auto-enable in lowlevel/server.py L742 and mcpserver/server.py L1155 only fires for 127.0.0.1 / localhost / ::1. v1.x fastmcp/server.py L191-195 has the same block.
Two things to add:
The bigger gap is people wiring StreamableHTTPSessionManager or SseServerTransport directly. That path never goes through the loopback auto-enable, so protection is off even on localhost unless they pass settings.
"Allowlist the bind host" won't work for 0.0.0.0. Browsers send the public hostname, not the bind address, so there's nothing to derive. I think the safer default for a non-loopback host with no settings is to log a clear warning at startup (or refuse to start) until allowed_hosts is set, with an explicit opt-out for people behind a proxy that already checks Host.
For context, I maintain agent-audit-kit, and its AAK-DNS-REBIND-001 rule flags StreamableHTTP/FastMCP network transports with no allowed_hosts. It doesn't yet check for enable_dns_rebinding_protection=False explicitly. I'll add that case.
maxisbey commentedon Oct 6, 2026
Thanks for the review. I'm going to close this as not planned.
The mounted-app case is already covered.
streamable_http_app()andsse_app()default tohost="127.0.0.1", so with no settings the protection is on and any request for a real hostname gets a421. It's only off if you pass a non-localhosthost=yourself, and Deploy & scale says to settransport_security=in that case.For a server bound to
0.0.0.0there's no hostname we could build an allowlist from. Switching the low-level default on would also reject every request on existing 2.x deployments that build the transport directly.AI Disclaimer
Generated by Claude Code