Skip to content

fix(mcp): add SSRF protection to OAuth metadata discovery#28112

Open
herdiyana256 wants to merge 3 commits into
google-gemini:mainfrom
herdiyana256:fix/ssrf-dns-hostname-private-ip-bypass
Open

fix(mcp): add SSRF protection to OAuth metadata discovery#28112
herdiyana256 wants to merge 3 commits into
google-gemini:mainfrom
herdiyana256:fix/ssrf-dns-hostname-private-ip-bypass

Conversation

@herdiyana256

@herdiyana256 herdiyana256 commented Jun 23, 2026

Copy link
Copy Markdown

Summary

The OAuth discovery flow in oauth-utils.ts and oauth-provider.ts fetches URLs
received directly from MCP server responses without SSRF validation. This is a
coverage gap relative to web-fetch.ts, which already uses isLoopbackHost()
and resolveAndValidateDns() from utils/fetch.ts.

Vulnerable paths

Three functions fetch attacker-controlled URLs without any IP validation:

  • OAuthUtils.fetchProtectedResourceMetadata(resourceMetadataUrl) — URL extracted from the server's WWW-Authenticate: Bearer resource_metadata="..." header
  • OAuthUtils.fetchAuthorizationServerMetadata(authServerMetadataUrl) — URL from authorization_servers[0] in the server's protected resource metadata response
  • MCPOAuthProvider.registerClient(registrationUrl) URL from registration_endpoint in the authorization server metadata response

A malicious MCP server can point any of these at 169.254.169.254, metadata.google.internal, a private RFC1918 address, or localhost — causing gemini-cli to make GET and POST requests to cloud IMDS endpoints or internal services on the developer's machine.

Fix

Applies the same isLoopbackHost() + resolveAndValidateDns() guards from utils/fetch.ts to all three fetch sites. No new logic introduced , reuses the existing SSRF protection utilities already used in web-fetch.ts.

@herdiyana256 herdiyana256 requested review from a team as code owners June 23, 2026 15:06
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Summary of Changes

Hello, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request addresses critical SSRF vulnerabilities in the OAuth discovery flow and the WebFetch tool. By consistently applying loopback and private IP validation, the changes prevent the application from making unauthorized requests to internal cloud metadata services or local network endpoints. The implementation leverages existing utility functions to ensure a robust and uniform security posture across all URL-fetching operations.

Highlights

  • SSRF Protection: Implemented SSRF protection for OAuth metadata discovery by applying loopback and private IP validation to fetchProtectedResourceMetadata, fetchAuthorizationServerMetadata, and registerClient.
  • DNS Validation: Refactored isPrivateIpAsync into resolveAndValidateDns, which now returns validated IP addresses or an empty array if the host is private or unresolvable, ensuring safer URL handling.
  • WebFetchTool Hardening: Updated WebFetchTool to use the new DNS validation, blocking private/loopback hosts and pinning the request to a resolved public IP to prevent DNS rebinding attacks.
  • Test Coverage: Added comprehensive test cases to web-fetch.test.ts covering various SSRF bypass attempts, including nip.io and literal loopback addresses.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize the Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counterproductive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for GitHub and other Google products, sign up here.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@github-actions github-actions Bot added the size/l A large sized PR label Jun 23, 2026
@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

📊 PR Size: size/L

  • Lines changed: 391
  • Additions: +340
  • Deletions: -51
  • Files changed: 6

@google-cla

google-cla Bot commented Jun 23, 2026

Copy link
Copy Markdown

Thanks for your pull request! It looks like this may be your first contribution to a Google open source project. Before we can look at your pull request, you'll need to sign a Contributor License Agreement (CLA).

View this failed invocation of the CLA check for more information.

For the most up to date status, view the checks section at the bottom of the pull request.

@github-actions

github-actions Bot commented Jun 23, 2026

Copy link
Copy Markdown

🛑 Action Required: Evaluation Approval

Steering changes have been detected in this PR. To prevent regressions, a maintainer must approve the evaluation run before this PR can be merged.

Maintainers:

  1. Go to the Workflow Run Summary.
  2. Click the yellow 'Review deployments' button.
  3. Select the 'eval-gate' environment and click 'Approve'.

Once approved, the evaluation results will be posted here automatically.

@gemini-code-assist gemini-code-assist Bot left a comment

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.

Code Review

This pull request introduces SSRF protection by resolving and validating DNS hostnames to prevent access to private or loopback IP addresses across OAuth providers, utilities, and web fetch tools. However, the current implementation of pinning the IP address by replacing the hostname in the URL and setting the Host header will cause HTTPS requests to fail TLS certificate verification. To securely prevent DNS rebinding attacks without breaking HTTPS/TLS, it is recommended to keep the original URL intact and use a custom undici Agent with a custom lookup function passed as a dispatcher option to fetch.

Comment thread packages/core/src/mcp/oauth-utils.ts Outdated
Comment on lines 111 to 118
const resolvedAddrs = await resolveAndValidateDns(resourceMetadataUrl);
if (resolvedAddrs.length === 0) {
debugLogger.debug(
`Blocked OAuth metadata fetch to private/reserved IP: ${resourceMetadataUrl}`,
);
return null;
}
const response = await fetch(resourceMetadataUrl);

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.

security-high high

The fetchProtectedResourceMetadata method is vulnerable to a Time-of-Check to Time-of-Use (TOCTOU) / DNS Rebinding attack. To securely pin the IP address without breaking HTTPS/TLS, keep the original URL intact and use a custom undici Agent with a custom lookup function passed as a dispatcher option to fetch, maintaining consistency with the reference implementation in web-fetch.ts.

      const resolvedAddrs = await resolveAndValidateDns(resourceMetadataUrl);
      if (resolvedAddrs.length === 0) {
        debugLogger.debug(
          "Blocked OAuth metadata fetch to private/reserved IP: " + resourceMetadataUrl,
        );
        return null;
      }
      const pinnedIp = resolvedAddrs[0];
      const dispatcher = new Agent({
        connect: {
          lookup: (hostname, options, callback) => {
            callback(null, [{ address: pinnedIp, family: pinnedIp.includes(':') ? 6 : 4 }]);
          },
        },
      });
      const response = await fetch(resourceMetadataUrl, {
        dispatcher,
      });
References
  1. When a change's primary purpose is to align a component with a reference implementation (e.g., a 'main agent'), prioritize consistency with the reference, even if it means not addressing potential issues in the aligned code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Now uses undici Agent with a custom lookup callback to pin the resolved IP at the connection level, preventing DNS rebinding between check and use. The original URL is preserved so TLS SNI verification remains intact.

Comment thread packages/core/src/mcp/oauth-utils.ts Outdated
Comment on lines 149 to 156
const resolvedAddrs = await resolveAndValidateDns(authServerMetadataUrl);
if (resolvedAddrs.length === 0) {
debugLogger.debug(
`Blocked OAuth metadata fetch to private/reserved IP: ${authServerMetadataUrl}`,
);
return null;
}
const response = await fetch(authServerMetadataUrl);

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.

security-high high

The fetchAuthorizationServerMetadata method is vulnerable to a Time-of-Check to Time-of-Use (TOCTOU) / DNS Rebinding attack. To securely pin the IP address without breaking HTTPS/TLS, keep the original URL intact and use a custom undici Agent with a custom lookup function passed as a dispatcher option to fetch, maintaining consistency with the reference implementation in web-fetch.ts.

      const resolvedAddrs = await resolveAndValidateDns(authServerMetadataUrl);
      if (resolvedAddrs.length === 0) {
        debugLogger.debug(
          "Blocked OAuth metadata fetch to private/reserved IP: " + authServerMetadataUrl,
        );
        return null;
      }
      const pinnedIp = resolvedAddrs[0];
      const dispatcher = new Agent({
        connect: {
          lookup: (hostname, options, callback) => {
            callback(null, [{ address: pinnedIp, family: pinnedIp.includes(':') ? 6 : 4 }]);
          },
        },
      });
      const response = await fetch(authServerMetadataUrl, {
        dispatcher,
      });
References
  1. When a change's primary purpose is to align a component with a reference implementation (e.g., a 'main agent'), prioritize consistency with the reference, even if it means not addressing potential issues in the aligned code.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Same undici Agent dispatcher pattern applied , IP pinned inside the Agent.connect.lookup callback, original URL kept for correct TLS SNI.

Comment on lines +22 to +26
import {
fetchWithTimeout,
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';

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.

high

Import Agent from undici to allow creating a custom dispatcher for secure DNS resolution.

Suggested change
import {
fetchWithTimeout,
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import {
fetchWithTimeout,
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import { Agent } from 'undici';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Now imports Agent and Dispatcher from undici.

Comment thread packages/core/src/tools/web-fetch.ts Outdated
Comment on lines +318 to +334
const urlWithIp = new URL(url);
const originalHostname = urlWithIp.hostname;
urlWithIp.hostname = pinnedIp;

const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
'User-Agent': USER_AGENT,
const res = await fetchWithTimeout(
urlWithIp.toString(),
URL_FETCH_TIMEOUT_MS,
{
signal,
headers: {
'User-Agent': USER_AGENT,
Host: originalHostname,
},
},
});
);

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.

high

Replacing the hostname with the resolved IP address in the URL (e.g., https://8.8.8.8/) and setting the Host header will cause HTTPS requests to fail TLS certificate verification in Node.js/undici. This is because the TLS handshake will use the IP address as the SNI servername, resulting in an ERR_TLS_CERT_ALTNAME_INVALID error.

To securely pin the IP address without breaking HTTPS/TLS, keep the original URL intact and use a custom undici Agent with a custom lookup function passed as a dispatcher option to fetch.

Suggested change
const urlWithIp = new URL(url);
const originalHostname = urlWithIp.hostname;
urlWithIp.hostname = pinnedIp;
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
'User-Agent': USER_AGENT,
const res = await fetchWithTimeout(
urlWithIp.toString(),
URL_FETCH_TIMEOUT_MS,
{
signal,
headers: {
'User-Agent': USER_AGENT,
Host: originalHostname,
},
},
});
);
const dispatcher = new Agent({
connect: {
lookup: (hostname, options, callback) => {
callback(null, [{ address: pinnedIp, family: pinnedIp.includes(':') ? 6 : 4 }]);
},
},
});
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
dispatcher,
headers: {
'User-Agent': USER_AGENT,
},
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Removed hostname substitution entirely. Now creates an undici Agent with a custom lookup function that returns the pre-resolved IP, then passes it as dispatcher to fetchWithTimeout. The original URL is never modified, so TLS certificate verification works correctly.

Comment thread packages/core/src/tools/web-fetch.ts Outdated
Comment on lines 667 to 688
const urlWithIp = new URL(url);
const originalHostname = urlWithIp.hostname;
urlWithIp.hostname = pinnedIp;

const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
const res = await fetchWithTimeout(
urlWithIp.toString(),
URL_FETCH_TIMEOUT_MS,
{
signal,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
Host: originalHostname,
},
},
});
);
return res;
},
{

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.

high

Apply the same custom undici Agent dispatcher pattern here to prevent TLS certificate verification failures on HTTPS URLs while securely pinning the resolved IP address.

Suggested change
const urlWithIp = new URL(url);
const originalHostname = urlWithIp.hostname;
urlWithIp.hostname = pinnedIp;
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
const res = await fetchWithTimeout(
urlWithIp.toString(),
URL_FETCH_TIMEOUT_MS,
{
signal,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
Host: originalHostname,
},
},
});
);
return res;
},
{
const dispatcher = new Agent({
connect: {
lookup: (hostname, options, callback) => {
callback(null, [{ address: pinnedIp, family: pinnedIp.includes(':') ? 6 : 4 }]);
},
},
});
const response = await retryWithBackoff(
async () => {
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, {
signal,
dispatcher,
headers: {
Accept:
'text/markdown, text/plain;q=0.9, application/json;q=0.9, text/html;q=0.8, application/pdf;q=0.7, video/*;q=0.7, */*;q=0.5',
'User-Agent': USER_AGENT,
},
});

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Same approach , undici Agent dispatcher with custom lookup, original URL preserved.

import { coreEvents } from '../utils/events.js';
import { debugLogger } from '../utils/debugLogger.js';
import { getConsentForOauth } from '../utils/authConsent.js';
import { isLoopbackHost, resolveAndValidateDns } from '../utils/fetch.js';

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.

high

Import Agent from undici to support secure IP pinning via a custom dispatcher.

import { isLoopbackHost, resolveAndValidateDns } from '../utils/fetch.js';
import { Agent } from 'undici';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Imports Agent and Dispatcher from undici, used in registerClient.

Comment thread packages/core/src/mcp/oauth-utils.ts Outdated
Comment on lines +10 to +13
import {
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';

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.

high

Import Agent from undici to support secure IP pinning via a custom dispatcher.

Suggested change
import {
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import {
isLoopbackHost,
resolveAndValidateDns,
} from '../utils/fetch.js';
import { Agent } from 'undici';

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Imports Agent and Dispatcher from undici.

Comment on lines 1020 to 1024
expect(fetchUtils.fetchWithTimeout).toHaveBeenCalledWith(
'https://example.com/',
'https://8.8.8.8/',
expect.any(Number),
expect.objectContaining({
headers: expect.objectContaining({

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.

high

Update the test assertion to match the new custom dispatcher pattern, where the original URL is passed to fetchWithTimeout instead of the IP-replaced URL.

Suggested change
expect(fetchUtils.fetchWithTimeout).toHaveBeenCalledWith(
'https://example.com/',
'https://8.8.8.8/',
expect.any(Number),
expect.objectContaining({
headers: expect.objectContaining({
expect(fetchUtils.fetchWithTimeout).toHaveBeenCalledWith(
'https://example.com/',
expect.any(Number),
expect.objectContaining({
dispatcher: expect.any(Object),
headers: expect.objectContaining({

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Fixed in 04ef846. Updated assertion to expect the original URL ('https://example.com/') and objectContaining({ dispatcher: expect.any(Object) }), consistent with the new undici Agent pattern. Also simplified the mockFetch helper to no longer construct the pinned IP URL since that substitution no longer happens.

@herdiyana256 herdiyana256 force-pushed the fix/ssrf-dns-hostname-private-ip-bypass branch from a37e073 to 6b2f24c Compare June 23, 2026 15:11
@gemini-cli gemini-cli Bot added the status/need-issue Pull requests that need to have an associated issue. label Jun 23, 2026
@herdiyana256 herdiyana256 force-pushed the fix/ssrf-dns-hostname-private-ip-bypass branch from 6b2f24c to f6fe405 Compare June 23, 2026 15:47
The OAuth discovery flow in oauth-utils.ts and oauth-provider.ts
fetches URLs received directly from external MCP server responses
without any SSRF validation, unlike web-fetch.ts which already
uses isLoopbackHost() and resolveAndValidateDns() from utils/fetch.ts.

A malicious MCP server can return private/internal IPs in:
- WWW-Authenticate: Bearer resource_metadata="http://169.254.169.254/..."
- /.well-known/oauth-protected-resource: {authorization_servers: ["http://169.254.169.254"]}
- /.well-known/oauth-authorization-server: {registration_endpoint: "http://10.x.x.x/admin"}

This causes gemini-cli to make GET and POST requests to cloud IMDS
endpoints (AWS 169.254.169.254, GCP metadata.google.internal, Azure
169.254.169.254) or internal services on the developer's network.

Fix: apply the same isLoopbackHost() + resolveAndValidateDns() guards
already used in web-fetch.ts to:
- OAuthUtils.fetchProtectedResourceMetadata()
- OAuthUtils.fetchAuthorizationServerMetadata()
- MCPOAuthProvider.registerClient()
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

size/l A large sized PR status/need-issue Pull requests that need to have an associated issue.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant