Skip to content

feat(tools): SSRF guard on browse_url — block loopback / private / link-local targets#58

Merged
Pterjudin merged 1 commit into
mainfrom
fix/browse-url-ssrf-guard-2026-05-31
May 31, 2026
Merged

feat(tools): SSRF guard on browse_url — block loopback / private / link-local targets#58
Pterjudin merged 1 commit into
mainfrom
fix/browse-url-ssrf-guard-2026-05-31

Conversation

@Pterjudin
Copy link
Copy Markdown

Summary

Closes the SSRF item in TOOLS_AUDIT_2026-05-25 §5 (pairs with security review F-03).

browse_url previously accepted any well-formed http(s):// URL — the model (or a redirect chain) could be steered at internal targets that the user never intended to expose to the agent:

Range Risk
localhost, 127.0.0.1 local dev servers, admin panels
10.x, 172.16-31.x, 192.168.x internal corporate / home network
169.254.169.254 cloud metadata service (AWS/GCP/Azure credential leak)
::1, fe80::/10, fc00::/7 IPv6 loopback / link-local / unique-local
::ffff:127.0.0.1 etc. IPv4-mapped IPv6 bypass forms

Fix

New assertNotSSRF(url) helper, called from two sites:

  1. The validator (validateParams.browse_url) — model gets a clear error before the request fires.
  2. The impl boundary (callTool.browse_url) — redirect re-entry calls this.callTool.browse_url directly, skipping the validator, so the guard needs to run there too.

Unit tests cover loopback, all three private CIDRs, link-local (incl. cloud metadata), IPv6 forms, IPv4-mapped IPv6, and boundary cases just outside the blocked CIDRs (172.15.x / 172.32.x / 192.169.x).

2 files changed, +138 / -0 (helper: 57 lines, wire-in: 2 lines, tests: 79 lines).

Known gap (queued, not in this PR)

DNS-based bypass: a hostname like evil.example.com that resolves to 127.0.0.1 will still be allowed by the literal-only check. Catching that needs:

  • async DNS preflight before the request
  • re-checking IPs of every redirect target (since redirects are followed by requestService)
  • handling DNS-rebinding (TOCTOU between resolution and connect)

That's a meaningful chunk of work and benefits from a centralised allowlist/blocklist setting. Tracked as a follow-up; this PR closes the literal-IP class which is the higher-frequency attack vector for prompt-injected URLs.

Test plan

  • Unit tests pass (run as part of the cortexide test suite)
  • npm run compile-check-ts-native passes
  • Manual: trigger browse_url with http://127.0.0.1 in agent mode — confirm clear error surfaces to the model
  • Manual: confirm https://example.com and other public URLs still work

Audit follow-ups remaining after this PR

  • B-04 YOLO heuristic rewrite for run_nl_command (design call needed)
  • B-06 web_search DDG scraping fragility (long-term — SerpAPI/Brave or graceful failure path)
  • DNS-resolution SSRF preflight (above)
  • Option 2 epics from B-05: real WorkspaceEdit-based rename, Monaco-refactor-based extract, internal-LLM review/tests

🤖 Generated with Claude Code

…ocal targets

Follow-up to TOOLS_AUDIT_2026-05-25 (paired with security review F-03).

browse_url previously accepted any well-formed http(s) URL, so the model
(or a redirect chain) could be steered at internal resources:
  - localhost / 127.0.0.1 — local dev servers, admin panels
  - 10.x / 172.16-31.x / 192.168.x — internal corporate / home network
  - 169.254.169.254 — AWS/GCP/Azure cloud metadata service (creds leak)
  - ::1, fe80::/10, fc00::/7 — IPv6 equivalents

Add assertNotSSRF() called from two places:
  1. The validator, so the model gets a clear error before the request.
  2. The impl boundary, so redirect re-entry (callTool.browse_url skips
     the validator) and any future internal caller can't bypass it.

Covers literal hostname bans only. DNS-resolution bypasses (a
public-looking hostname that resolves to a private IP) are not caught
here — that needs async preflight + IPs-of-redirect checking and is
queued as a follow-up.

Includes unit tests covering loopback, private ranges, link-local
(incl. cloud metadata), IPv6 forms, IPv4-mapped IPv6, and boundary
cases just outside the blocked CIDRs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@Pterjudin Pterjudin merged commit bd5c721 into main May 31, 2026
12 of 23 checks passed
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