google-gemini / google-gemini/gemini-cli
security(web-fetch): SSRF via redirect to private IP bypasses isPrivateIp check
- Dominant language
- TypeScript
- Stars
- 107k
- Forks
- 14.6k
- Avg merge
- 2d 3h
- Merged PRs (30d)
- 45
Description
### What happened?
In `packages/core/src/tools/web-fetch.ts` and `packages/core/src/utils/fetch.ts` (main @ 812f7a2bc), the web-fetch tool validates the *initial* URL against private IP blocklists via `isPrivateIp()` / `isPrivateIpAsync()`, but `fetch()` is called with default redirect handling (`redirect: "follow"`), and **redirect targets are not re-validated**.
`fetch.ts` lines 190-212:
```ts
export async function fetchWithTimeout(url: string, timeout: number, options?: RequestInit): Promise {
// ...
const response = await fetch(url, { ...options, signal: controller.signal });
return response;
}
```
`web-fetch.ts` lines 297, 636:
```ts
const res = await fetchWithTimeout(url, URL_FETCH_TIMEOUT_MS, { ... });
```
An attacker can host a URL that initially resolves to a public IP (passing `isPrivateIp` checks) but returns a `302` redirect to a private address:
- `http://169.254.169.254/latest/meta-data/` (AWS IMDS)
- `http://127.0.0.1:41242/tasks` (local A2A server, unauthenticated per #29001)
- `http://192.168.1.1/admin`
`fetch()` transparently follows the redirect to the private IP, and the response body is returned to the LLM and then to the user, enabling SSRF.
The existing `isPrivateIpAsync` (which does DNS lookup) is only called at request start, not on redirect. `fetch` does not expose redirect URL to the caller without `redirect: "manual"` handling.
### What did you expect to happen?
- Use `redirect: "manual"` and manually validate each `Location` header with `isPrivateIp` / `isPrivateIpAsync` before following, or
- Use undici's `maxRedirections` with a custom dispatcher that validates redirect targets, or
- At minimum, document and limit `maxRedirections: 0` and surface redirect `Location` to the LLM instead of auto-following
### Client information
- Source-level finding verified against upstream `main` at commit `812f7a2bc`
- Files: `packages/core/src/utils/fetch.ts:190-212`, `packages/core/src/tools/web-fetch.ts:297,636`
- Affects all platforms
### Login information
Not applicable.
### Anything else we need to know?
Sources:
- https://github.com/google-gemini/gemini-cli/blob/812f7a2bc/packages/core/src/utils/fetch.ts#L190-L212
- https://github.com/google-gemini/gemini-cli/blob/812f7a2bc/packages/core/src/tools/web-fetch.ts#L297
- Private IP validation exists (`isPrivateIp`, `isPrivateIpAsync`, `isAddressPrivate`) but is only applied to the initial URL, not redirect chain
**Repro:**
1. Host a server at `https://example.com/redirect` that returns `302 Location: http://127.0.0.1:41242/tasks`
2. In gemini-cli, `web_fetch` with `url: "https://example.com/redirect"`
3. Observe fetch follows to private A2A server and returns task list (SSRF)
Searched existing issues for "web-fetch redirect", "SSRF", "private ip bypass" — no open duplicate found (closed #27635 is about OAuth metadata URLs, distinct).
Contributor guide
Research direction
Start with fetchWithTimeout in packages/core/src/utils/fetch.ts and its callers at packages/core/src/tools/web-fetch.ts:297 and :636. Review the existing isPrivateIp and isPrivateIpAsync helpers, then reproduce the public-to-private redirect described in the issue. Done means redirect targets are checked before access and the SSRF reproduction no longer reaches the private address.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- networking, security
- Issue type
- Bug
- Difficulty
- 4/5
- Estimated time
- 3-5 days
- Activity status
- Active
- Clarity
- Mostly clear
- Newbie friendliness
- 58/100