-
Notifications
You must be signed in to change notification settings - Fork 340
feat: add automatic DNS rebinding protection for localhost servers #760
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: add automatic DNS rebinding protection for localhost servers #760
Conversation
Add DNS rebinding protection that is automatically enabled when requests arrive via localhost (127.0.0.1, [::1]). This protects against malicious websites using DNS rebinding to interact with local MCP servers. Key changes: - Add DisableLocalhostProtection option to StreamableHTTPOptions - Add isLocalhostAddr and isLocalhostHost helper functions - Validate Host header at start of ServeHTTP, rejecting non-localhost Host headers with 403 Forbidden when the connection arrives via localhost The protection is enabled by default with no code changes required. Users can opt-out by setting DisableLocalhostProtection: true. This uses http.LocalAddrContextKey to detect the connection's local address, which means protection is enabled for any request arriving via localhost, regardless of whether the server listens on 127.0.0.1 or 0.0.0.0. Closes: relates to MCP spec security best practices See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise
maciej-kisiel
left a comment
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the PR! I added a few comments, but generally the approach looks sound.
I believe we should also add this protection to the legacy SSE transport.
| // DNS rebinding protection: auto-enabled for localhost servers. | ||
| // See: https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices#local-mcp-server-compromise | ||
| if !h.opts.DisableLocalhostProtection { | ||
| if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
With this change below I think we could only have a single implementation of the isLocalhost function, since they are largely the same. It would be called with localAddr.String() and req.Host.
| if localAddr, ok := req.Context().Value(http.LocalAddrContextKey).(net.Addr); ok { | |
| if localAddr := req.Context().Value(http.LocalAddrContextKey).(net.Addr); localAddr != nil { |
| disableProt bool // DisableLocalhostProtection setting | ||
| wantStatus int | ||
| }{ | ||
| // Auto-enabled for localhost listeners (127.0.0.1) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Extending the test cases to multiple lines and including field names is more readable. I would suggest to do it.
| name string | ||
| listenAddr string // Address to listen on | ||
| hostHeader string // Host header in request | ||
| disableProt bool // DisableLocalhostProtection setting |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
disableProtection should be fine.
| } | ||
| handler := NewStreamableHTTPHandler(func(req *http.Request) *Server { return server }, opts) | ||
|
|
||
| // Create a listener on the specified address to control LocalAddrContextKey |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // Create a listener on the specified address to control LocalAddrContextKey | |
| // Create a listener on the specified address to control LocalAddrContextKey. |
| } | ||
| defer listener.Close() | ||
|
|
||
| // Start server in background |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| // Start server in background | |
| // Start the server in the background. |
Summary
Add DNS rebinding protection that is automatically enabled when requests arrive via localhost (127.0.0.1, [::1]). This protects against malicious websites using DNS rebinding to interact with local MCP servers.
(note: this was a claude assisted PR, mostly wanted to see how difficult passing this test would be for this SDK)
Design Goal: Secure by Default
The primary goal is to make it difficult to run a localhost server without these protections by mistake. There are other approaches that could provide secure defaults (e.g., a helper for
ListenAndServethat does the localhost check), but usinghttp.LocalAddrContextKeyfor runtime detection seemed like the most backwards compatible approach and least likely to be disabled by accident.With this implementation:
DisableLocalhostProtection: trueChanges
DisableLocalhostProtectionoption toStreamableHTTPOptionsisLocalhostAddrandisLocalhostHosthelper functionsServeHTTP, rejecting non-localhost Host headers with 403 ForbiddenHow it works
The protection uses
http.LocalAddrContextKeyto detect the connection's local address at runtime. When a request arrives via localhost (127.0.0.1 or [::1]), the handler validates that the Host header also matches a localhost value. If not, the request is rejected with 403 Forbidden.This approach means:
127.0.0.1or0.0.0.0Edge case: Reverse proxies
If a reverse proxy (e.g., Envoy, nginx) runs on the same host and forwards requests to the MCP server via localhost while preserving the original Host header, those requests would be rejected. In this case, users should either:
DisableLocalhostProtection: trueTesting
isLocalhostAddrandisLocalhostHosthelper functionslocalhost-host-rebinding-rejected: PASSlocalhost-host-valid-accepted: PASSRelated
localhostHostValidation()middlewaredns-rebinding-protectionscenario