Skip to content

fix: prevent SSRF in OAuth metadata discovery#2035

Open
mfbx9da4 wants to merge 1 commit intomainfrom
fix/ssrf-oauth-discovery
Open

fix: prevent SSRF in OAuth metadata discovery#2035
mfbx9da4 wants to merge 1 commit intomainfrom
fix/ssrf-oauth-discovery

Conversation

@mfbx9da4
Copy link
Copy Markdown

@mfbx9da4 mfbx9da4 commented Mar 30, 2026

Summary

  • Fix SSRF vulnerability in fetchJSON() used by OAuth metadata discovery for external MCP servers
  • A malicious MCP server could craft WWW-Authenticate headers with URLs pointing to internal networks (e.g., AWS metadata at 169.254.169.254), causing the Gram server to make requests from its trusted network position
  • Add validateFetchURL() that enforces HTTPS and blocks private/internal IP ranges (loopback, RFC 1918, link-local, cloud metadata) by resolving DNS before connecting

Test plan

  • Unit tests for isPrivateIP covering all private ranges and public IPs
  • Unit tests for validateFetchURL covering HTTP rejection, localhost, metadata endpoint, missing scheme, and valid HTTPS
  • Manual verification that external MCP OAuth discovery still works with legitimate public OAuth providers

Open with Devin

Add URL validation to fetchJSON() to block requests to private/internal
IP ranges and require HTTPS. A malicious external MCP server could
previously craft WWW-Authenticate headers pointing to internal network
addresses (e.g., cloud metadata endpoints at 169.254.169.254), causing
the Gram server to make arbitrary requests from its trusted network
position.
@vercel
Copy link
Copy Markdown

vercel bot commented Mar 30, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
gram-docs-redirect Ready Ready Preview, Comment Mar 30, 2026 9:54pm

Request Review

Copy link
Copy Markdown

@claude claude bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This repository is configured for manual code reviews. Comment @claude review to trigger a review and subscribe this PR to future pushes, or @claude review once for a one-time review.

Tip: disable this comment in your organization's Code Review settings.

@changeset-bot
Copy link
Copy Markdown

changeset-bot bot commented Mar 30, 2026

⚠️ No Changeset found

Latest commit: 83133e7

Merging this PR will not cause a version bump for any packages. If these changes should not result in a new version, you're good to go. If these changes should result in a version bump, you need to add a changeset.

This PR includes no changesets

When changesets are added to this PR, you'll see the packages that this PR includes changesets for and the associated semver types

Click here to learn what changesets are, and how to add one.

Click here if you're a maintainer who wants to add a changeset to this PR

@github-actions
Copy link
Copy Markdown
Contributor


Thank you for your submission, we really appreciate it. Like many open-source projects, we ask that you sign our Contributor License Agreement before we can accept your contribution. You can sign the CLA by just posting a Pull Request Comment same as the below format.


I have read the CLA Document and I hereby sign the CLA


You can retrigger this bot by commenting recheck in this Pull Request. Posted by the CLA Assistant Lite bot.

Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 3 additional findings in Devin Review.

Open in Devin Review

Copy link
Copy Markdown
Contributor

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 validateFetchURL validates the initial URL's scheme and resolved IP, but the HTTP client created at oauthdiscovery.go:312 (retryablehttp.NewClient().StandardClient()) follows redirects by default (Go's net/http.Client follows up to 10 redirects when CheckRedirect is nil). A malicious external MCP server could host a public HTTPS endpoint that returns a 302 redirect to an internal address like http://169.254.169.254/latest/meta-data/ (cloud metadata) or any other private IP. The validateFetchURL check 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
  1. Attacker registers an MCP server at https://evil.com
  2. https://evil.com/.well-known/oauth-authorization-server returns 302 Location: http://169.254.169.254/latest/meta-data/iam/security-credentials/
  3. validateFetchURL("https://evil.com/...") passes — evil.com resolves to a public IP
  4. Go HTTP client follows the redirect to the internal metadata endpoint
  5. Response is parsed as JSON and returned through the OAuth metadata fields

(Refers to line 312)

Prompt for agents
In server/internal/externalmcp/oauthdiscovery.go, the HTTP client at line 312 follows redirects by default, bypassing the SSRF validation. Fix by setting CheckRedirect on the standard client returned by retryablehttp to validate each redirect URL. After line 312 (client := retryablehttp.NewClient().StandardClient()), add:

client.CheckRedirect = func(req *http.Request, via []*http.Request) error {
    if err := validateFetchURL(req.URL.String()); err != nil {
        return fmt.Errorf("redirect blocked: %w", err)
    }
    if len(via) >= 10 {
        return fmt.Errorf("too many redirects")
    }
    return nil
}

This ensures every redirect target is also validated against the same SSRF checks (HTTPS scheme and non-private IP).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +299 to +301
if err := validateFetchURL(fetchURL); err != nil {
return nil, fmt.Errorf("URL validation failed: %w", err)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The 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 validateFetchURL function at oauthdiscovery.go:278 performs a DNS lookup via net.LookupHost and checks the resolved IPs against private ranges. However, the actual HTTP request at oauthdiscovery.go:313 performs its own independent DNS resolution. An attacker with a DNS server configured with a short TTL can serve a public IP during the validateFetchURL check, then switch to a private/internal IP (e.g., 169.254.169.254) before the HTTP client resolves the name — a classic DNS rebinding attack. While OS-level DNS caching can mitigate this in many cases, it is not guaranteed (especially with TTL=0 records). The robust fix is to pin the resolved IP and use it for the actual connection via a custom DialContext on the transport.

Prompt for agents
In server/internal/externalmcp/oauthdiscovery.go, the DNS resolution in validateFetchURL (line 278) is separate from the DNS resolution done by the HTTP client (line 312-313). To prevent DNS rebinding, refactor so that the resolved IP from validation is pinned and used for the actual connection. One approach:

1. Have validateFetchURL return the validated IP addresses alongside the nil error.
2. Create a custom http.Transport with a DialContext that dials the pre-resolved IP instead of re-resolving DNS:

   transport := &http.Transport{
       DialContext: func(ctx context.Context, network, addr string) (net.Conn, error) {
           // Replace the hostname with the pre-validated IP
           _, port, _ := net.SplitHostPort(addr)
           return (&net.Dialer{}).DialContext(ctx, network, net.JoinHostPort(validatedIP, port))
       },
   }

3. Set this transport on the HTTP client used in fetchJSON.

This ensures the same IP that was validated is the one actually connected to.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Comment on lines +39 to +61
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)
}
})
}
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🚩 Test coverage for validateFetchURL depends on live DNS resolution

The TestValidateFetchURL tests at oauthdiscovery_test.go:39-61 make live DNS queries (e.g., resolving localhost, 127.0.0.1, accounts.google.com). These tests will fail in air-gapped CI environments or when DNS is unavailable. The localhost test also depends on the system's /etc/hosts configuration resolving to 127.0.0.1. Consider adding a note or using a test helper that mocks net.LookupHost for more reliable CI behavior.

Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

@disintegrator disintegrator left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

use the existing guardian module. there are examples of other services that obtain the guardian policy from start.go and drill it down where needed. it handles redirects and other interesting cases.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants