Skip to content

fix(security): block SSRF in fetch_image_url via scheme allowlist and private IP rejection#2134

Open
5F0jd2vLq54RerYW wants to merge 2 commits into
exo-explore:mainfrom
5F0jd2vLq54RerYW:pr/ssrf-fix
Open

fix(security): block SSRF in fetch_image_url via scheme allowlist and private IP rejection#2134
5F0jd2vLq54RerYW wants to merge 2 commits into
exo-explore:mainfrom
5F0jd2vLq54RerYW:pr/ssrf-fix

Conversation

@5F0jd2vLq54RerYW
Copy link
Copy Markdown

Summary

fetch_image_url() in chat_completions.py fetches arbitrary URLs provided by API clients with no validation. This allows SSRF attacks:

  • http://169.254.169.254/latest/meta-data/iam/security-credentials/ leaks AWS IAM credentials
  • http://192.168.1.1/admin scans the internal network
  • file:///etc/passwd reads local files (if aiohttp supports the scheme)

Fix

Adds three validation checks before session.get() is ever called:

  1. Scheme allowlist — only http/https permitted
  2. Metadata host blocklist — rejects AWS IMDSv1, GCP, and Azure IMDS endpoints
  3. Literal IP rejectionipaddress.ip_address(hostname) detects private, loopback, and link-local IPs; DNS hostnames are not blocked (DNS rebinding protection is deferred)

No new package dependencies — uses only stdlib ipaddress and urllib.parse.

Tests

12 test cases in src/exo/api/adapters/tests/test_fetch_image_url.py:

  • Every rejection case asserts session.get() is never called (mock + assert_not_called())
  • Coverage: scheme, all 3 metadata hosts, RFC 1918, loopback, link-local, valid HTTPS, public IP literal, hostname passthrough

Scope

In scope: Literal IP validation at parse time.
Out of scope: DNS rebinding (hostname → private IP after resolution). That requires resolving hostnames before connecting, which is a larger change best handled separately.

🤖 Generated with Claude Code

Codex and others added 2 commits May 31, 2026 17:15
… private IP rejection

Adds URL validation to fetch_image_url() before issuing any HTTP request:

- Scheme allowlist: only http/https permitted; file://, ftp://, etc. raise ValueError
- Metadata host blocklist: rejects 169.254.169.254 (AWS IMDSv1),
  metadata.google.internal (GCP), 169.254.170.2 (Azure IMDS)
- Literal IP rejection: ipaddress.ip_address() detects private, loopback,
  and link-local literal IPs; raises ValueError before session.get() is called.
  Hostnames (non-literal IPs) are not blocked — DNS rebinding protection
  is deferred to a future change.

Tests (12 cases) verify that session.get() is never called for rejected URLs
by mocking create_http_session and asserting zero .get() calls.

Co-Authored-By: Claude Sonnet 4.6 (1M context) <noreply@anthropic.com>
@5F0jd2vLq54RerYW
Copy link
Copy Markdown
Author

Note: nix is not available in this dev environment, so I ran ruff format (which is what nix fmt invokes for Python files per the treefmt config in flake.nix) and pushed the formatting commit. The Rust/Svelte/TOML formatters don't apply to this PR (Python-only change).

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.

1 participant