oauthex: oauth discovery checks (#1220)
add extra checks for the default oauth discovery client: * disallow redirects which downgrade to http * disallow redirects to localhost or private ip * disallow the initial call to a private ip * proxy configuration opts out of dial time checking
This commit is contained in:
parent
3632967404
commit
12cbafe54d
3 changed files with 376 additions and 31 deletions
|
|
@ -534,24 +534,24 @@ provided to
|
|||
|
||||
### Server-Side Request Forgery
|
||||
|
||||
The [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) are as follows:
|
||||
The OAuth discovery helpers apply these [mitigations](https://modelcontextprotocol.io/docs/tutorials/security/security_best_practices#mitigation-3) by default:
|
||||
|
||||
- _Enforce HTTPS_. The OAuth helpers provided by the SDK reject the `http://` URLs
|
||||
except loopback addresses (`localhost`, `127.0.0.1`, `::1`).
|
||||
- _Enforce HTTPS_. Reject `http://` URLs (except loopback: `localhost`, `127.0.0.1`, `::1`)
|
||||
and redirects that downgrade `https` to a non-`https` scheme.
|
||||
|
||||
- _Block Private IP Ranges_. The OAuth helpers provided by the SDK allow passing
|
||||
a custom `http.Client`. Developers are advised to customize the client it with
|
||||
appropriate network protections, including IP range blocking. The SDK does not provide
|
||||
this capability out of the box.
|
||||
- _Block Private IP Ranges_. Reject targets that resolve to a non-public address
|
||||
(private, link-local including the `169.254.169.254` metadata endpoint, CGNAT, multicast,
|
||||
unspecified). The check runs on the initial URL and at dial time, guarding against DNS rebinding.
|
||||
|
||||
- _Validate Redirect Targets_. Similarly to previous point, customized `http.Client`
|
||||
can be used to validate network hops. The SDK does not provide this capability out
|
||||
of the box.
|
||||
- _Validate Redirect Targets_. Reject redirects to non-public addresses and cap the redirect count.
|
||||
|
||||
- _Use Egress Proxies_. This is out of scope for the SDK and can be configured separately.
|
||||
- _Use Egress Proxies_. Out of scope for the SDK; configure separately.
|
||||
|
||||
- _DNS Resolution Considerations_. The SDK has DNS rebinding protection on the server side which is enabled by default. For the client side, consider providing
|
||||
a custom `http.Client` that would implement DNS pinning.
|
||||
**Opting out.** Passing a custom `http.Client` can bypass these checks, making SSRF
|
||||
protection your responsibility. Specifically: a custom `Transport.DialContext`/`DialTLSContext`,
|
||||
a non-`*http.Transport`, or a configured `Proxy` disables the dial-time IP check; setting
|
||||
`CheckRedirect` replaces all redirect validation. In these cases, implement your own IP
|
||||
blocking, redirect validation, and DNS pinning.
|
||||
|
||||
### Session Hijacking
|
||||
|
||||
|
|
|
|||
|
|
@ -24,3 +24,26 @@ func IsLoopback(addr string) bool {
|
|||
}
|
||||
return ip.IsLoopback()
|
||||
}
|
||||
|
||||
// cgnat is the RFC 6598 carrier-grade NAT range (100.64.0.0/10). netip's
|
||||
// IsPrivate does not report it, but it is shared ISP space that is not publicly
|
||||
// routable and could point at another tenant behind the same NAT.
|
||||
var cgnat = netip.MustParsePrefix("100.64.0.0/10")
|
||||
|
||||
// IsPrivateOrReserved reports whether ip belongs to a range that must not be
|
||||
// reachable through a server-controlled URL, as a defense against SSRF: private
|
||||
// (RFC 1918 / RFC 4193 ULA), link-local (including the 169.254.169.254 cloud
|
||||
// metadata endpoint), carrier-grade NAT, multicast, and the unspecified address.
|
||||
func IsPrivateOrReserved(ip netip.Addr) bool {
|
||||
ip = ip.Unmap()
|
||||
if !ip.IsValid() {
|
||||
return true
|
||||
}
|
||||
if ip.IsPrivate() ||
|
||||
ip.IsLinkLocalUnicast() ||
|
||||
ip.IsMulticast() ||
|
||||
ip.IsUnspecified() {
|
||||
return true
|
||||
}
|
||||
return cgnat.Contains(ip)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,10 @@
|
|||
// that can be found in the LICENSE file.
|
||||
package util
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"net/netip"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// TestIsLoopback tests the IsLoopback helper function.
|
||||
func TestIsLoopback(t *testing.T) {
|
||||
|
|
@ -33,3 +36,52 @@ func TestIsLoopback(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsPrivateOrReserved(t *testing.T) {
|
||||
tests := []struct {
|
||||
ip string
|
||||
want bool
|
||||
}{
|
||||
// Private (RFC 1918) and IPv6 ULA (RFC 4193).
|
||||
{"10.0.0.1", true},
|
||||
{"172.16.5.4", true},
|
||||
{"192.168.1.1", true},
|
||||
{"fd00::1", true},
|
||||
// Link-local, including the cloud metadata endpoint.
|
||||
{"169.254.169.254", true},
|
||||
{"169.254.0.1", true},
|
||||
{"fe80::1", true},
|
||||
// Carrier-grade NAT (RFC 6598).
|
||||
{"100.64.0.1", true},
|
||||
{"100.127.255.255", true},
|
||||
// Multicast and unspecified.
|
||||
{"224.0.0.1", true},
|
||||
{"ff02::1", true},
|
||||
{"0.0.0.0", true},
|
||||
{"::", true},
|
||||
// IPv4-mapped IPv6 of a private address must also be caught.
|
||||
{"::ffff:10.0.0.1", true},
|
||||
{"::ffff:192.168.0.1", true},
|
||||
// Loopback is intentionally NOT reserved here (policy handled elsewhere).
|
||||
{"127.0.0.1", false},
|
||||
{"::1", false},
|
||||
// Public addresses are allowed.
|
||||
{"8.8.8.8", false},
|
||||
{"1.1.1.1", false},
|
||||
{"2606:4700:4700::1111", false},
|
||||
// Just outside CGNAT.
|
||||
{"100.63.255.255", false},
|
||||
{"100.128.0.0", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.ip, func(t *testing.T) {
|
||||
ip, err := netip.ParseAddr(tt.ip)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseAddr(%q): %v", tt.ip, err)
|
||||
}
|
||||
if got := IsPrivateOrReserved(ip); got != tt.want {
|
||||
t.Errorf("IsPrivateOrReserved(%q) = %v, want %v", tt.ip, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue