-
Notifications
You must be signed in to change notification settings - Fork 23
fix: prevent SSRF in OAuth metadata discovery #2035
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?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -5,6 +5,7 @@ import ( | |
| "encoding/json" | ||
| "fmt" | ||
| "log/slog" | ||
| "net" | ||
| "net/http" | ||
| "net/url" | ||
| "regexp" | ||
|
|
@@ -235,12 +236,74 @@ func buildWellKnownURL(baseURL string) string { | |
| return baseURL + "/.well-known/oauth-authorization-server" | ||
| } | ||
|
|
||
| // isPrivateIP returns true if the given IP is in a private, loopback, or link-local range. | ||
| func isPrivateIP(ip net.IP) bool { | ||
| privateRanges := []string{ | ||
| "127.0.0.0/8", // IPv4 loopback | ||
| "10.0.0.0/8", // RFC 1918 | ||
| "172.16.0.0/12", // RFC 1918 | ||
| "192.168.0.0/16", // RFC 1918 | ||
| "169.254.0.0/16", // Link-local / cloud metadata | ||
| "0.0.0.0/8", // Current network | ||
| "::1/128", // IPv6 loopback | ||
| "fc00::/7", // IPv6 unique local | ||
| "fe80::/10", // IPv6 link-local | ||
| } | ||
| for _, cidr := range privateRanges { | ||
| _, network, err := net.ParseCIDR(cidr) | ||
| if err != nil { | ||
| continue | ||
| } | ||
| if network.Contains(ip) { | ||
| return true | ||
| } | ||
| } | ||
| return false | ||
| } | ||
|
|
||
| // validateFetchURL checks that a URL is safe to fetch: it must use HTTPS and must not | ||
| // resolve to a private/internal IP address. This prevents SSRF attacks where a malicious | ||
| // external MCP server could trick the Gram server into making requests to internal services. | ||
| func validateFetchURL(rawURL string) error { | ||
| u, err := url.Parse(rawURL) | ||
| if err != nil { | ||
| return fmt.Errorf("invalid URL: %w", err) | ||
| } | ||
|
|
||
| if u.Scheme != "https" { | ||
| return fmt.Errorf("URL scheme must be https, got %q", u.Scheme) | ||
| } | ||
|
|
||
| hostname := u.Hostname() | ||
| ips, err := net.LookupHost(hostname) | ||
| if err != nil { | ||
| return fmt.Errorf("DNS lookup failed for %q: %w", hostname, err) | ||
| } | ||
|
|
||
| for _, ipStr := range ips { | ||
| ip := net.ParseIP(ipStr) | ||
| if ip == nil { | ||
| return fmt.Errorf("could not parse resolved IP %q", ipStr) | ||
| } | ||
| if isPrivateIP(ip) { | ||
| return fmt.Errorf("URL %q resolves to private/internal IP %s", rawURL, ipStr) | ||
| } | ||
| } | ||
|
|
||
| return nil | ||
| } | ||
|
|
||
| // fetchJSON fetches JSON from a URL and decodes it into the target. | ||
| func fetchJSON[T any](ctx context.Context, logger *slog.Logger, url string) (*T, error) { | ||
| // The URL is validated before fetching to prevent SSRF attacks. | ||
| func fetchJSON[T any](ctx context.Context, logger *slog.Logger, fetchURL string) (*T, error) { | ||
| if err := validateFetchURL(fetchURL); err != nil { | ||
| return nil, fmt.Errorf("URL validation failed: %w", err) | ||
| } | ||
|
Comment on lines
+299
to
+301
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🔴 TOCTOU DNS rebinding allows SSRF bypass between validateFetchURL and actual HTTP request The Prompt for agentsWas this helpful? React with 👍 or 👎 to provide feedback. |
||
|
|
||
| ctx, cancel := context.WithTimeout(ctx, 10*time.Second) | ||
| defer cancel() | ||
|
|
||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) | ||
| req, err := http.NewRequestWithContext(ctx, http.MethodGet, fetchURL, nil) | ||
| if err != nil { | ||
| return nil, fmt.Errorf("create request: %w", err) | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package externalmcp | ||
|
|
||
| import ( | ||
| "net" | ||
| "testing" | ||
| ) | ||
|
|
||
| func TestIsPrivateIP(t *testing.T) { | ||
| tests := []struct { | ||
| ip string | ||
| private bool | ||
| }{ | ||
| {"127.0.0.1", true}, | ||
| {"10.0.0.1", true}, | ||
| {"172.16.0.1", true}, | ||
| {"192.168.1.1", true}, | ||
| {"169.254.169.254", true}, | ||
| {"0.0.0.0", true}, | ||
| {"::1", true}, | ||
| {"8.8.8.8", false}, | ||
| {"1.1.1.1", false}, | ||
| {"93.184.216.34", false}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.ip, func(t *testing.T) { | ||
| ip := net.ParseIP(tt.ip) | ||
| if ip == nil { | ||
| t.Fatalf("failed to parse IP %s", tt.ip) | ||
| } | ||
| got := isPrivateIP(ip) | ||
| if got != tt.private { | ||
| t.Errorf("isPrivateIP(%s) = %v, want %v", tt.ip, got, tt.private) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| func TestValidateFetchURL(t *testing.T) { | ||
| tests := []struct { | ||
| name string | ||
| url string | ||
| wantErr bool | ||
| }{ | ||
| {"http scheme rejected", "http://example.com/foo", true}, | ||
| {"private IP via localhost", "https://localhost/foo", true}, | ||
| {"private IP via 127.0.0.1", "https://127.0.0.1/foo", true}, | ||
| {"metadata endpoint", "https://169.254.169.254/latest/meta-data/", true}, | ||
| {"no scheme", "example.com/foo", true}, | ||
| {"valid public https", "https://accounts.google.com/.well-known/openid-configuration", false}, | ||
| } | ||
|
|
||
| for _, tt := range tests { | ||
| t.Run(tt.name, func(t *testing.T) { | ||
| err := validateFetchURL(tt.url) | ||
| if (err != nil) != tt.wantErr { | ||
| t.Errorf("validateFetchURL(%q) error = %v, wantErr %v", tt.url, err, tt.wantErr) | ||
| } | ||
| }) | ||
| } | ||
| } | ||
|
Comment on lines
+39
to
+61
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🚩 Test coverage for validateFetchURL depends on live DNS resolution The Was this helpful? React with 👍 or 👎 to provide feedback. |
||
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.
🔴 SSRF bypass via HTTP redirects: validateFetchURL only checks the initial URL, not redirect targets
The SSRF protection in
validateFetchURLvalidates the initial URL's scheme and resolved IP, but the HTTP client created atoauthdiscovery.go:312(retryablehttp.NewClient().StandardClient()) follows redirects by default (Go'snet/http.Clientfollows up to 10 redirects whenCheckRedirectis nil). A malicious external MCP server could host a public HTTPS endpoint that returns a 302 redirect to an internal address likehttp://169.254.169.254/latest/meta-data/(cloud metadata) or any other private IP. ThevalidateFetchURLcheck passes because the initial hostname resolves to a public IP, but the HTTP client then follows the redirect to the internal target, completely bypassing the SSRF protection.Attack scenario
https://evil.comhttps://evil.com/.well-known/oauth-authorization-serverreturns302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/validateFetchURL("https://evil.com/...")passes —evil.comresolves to a public IP(Refers to line 312)
Prompt for agents
Was this helpful? React with 👍 or 👎 to provide feedback.