Three 2026 advisories describe the same family of mistake. On July 10, 2026, PraisonAI (CVE-2026-60091) and 9Router (CVE-2026-56676) were published, both describing a hostname that is validated in one step and resolved again in another. In March, FreeScout's CVE-2026-34443 described a private IP range check that never ran.
Each team had an SSRF defense. Each defense was shaped like a defense. None of them held, and the reasons are specific enough to learn from.
What's affected
| Product | CVE | What failed | Fixed in |
|---|---|---|---|
| PraisonAI | CVE-2026-60091 | webhook_url validated at request time, re-resolved at connection time (DNS rebinding) | 4.6.78 |
| 9Router | CVE-2026-56676 | Image URL host resolved for validation, then resolved again by the fetch | 0.5.2 |
| FreeScout | CVE-2026-34443 | checkIpByMask() returned false for plain IPs, so CIDR ranges were never checked | 1.8.211 |
PraisonAI's flaw sits in the unauthenticated Jobs API /api/v1/runs endpoint and gives a blind SSRF to internal services. 9Router's requires an authenticated caller with access to the LLM proxy and a vision-capable model, using an attacker-controlled DNS name that first resolves to a public address and then rebinds to an internal one.
FreeScout's helper checked whether the input contained a / character. A plain IP address never does, so the function always returned false and the whole 10.0.0.0/8 and 172.16.0.0/12 ranges were unprotected.
How to check if you're exposed
First the versions:
pip show praisonai | grep -i '^version'
npm ls 9router
# FreeScout: the version shows in the admin footer and the release tag you deployed
Then the pattern in your own code. Search for any place where a URL is validated and a separate call fetches it:
grep -rnE "(is_private|ipaddress|is_safe_url|validate_url|isPrivateIP|checkIp)" src | head -40
grep -rnE "(requests\.(get|post)|httpx\.|fetch\(|axios\.|http\.Get|urlopen)" src | head -60
For each fetch of a user-influenced URL, answer three questions. Does the validator resolve the name, and does the HTTP client connect to that exact address? Are redirects followed? Is there a unit test that feeds the validator a plain private IP, an IPv6 address and a name that resolves to loopback?
What to do now
- Upgrade to PraisonAI 4.6.78, 9Router 0.5.2 and FreeScout 1.8.211 or later where you run them.
- Egress control comes first: block your app servers from reaching link-local metadata (169.254.169.254) and internal ranges at the network layer.
- On AWS, require IMDSv2 and disable IMDSv1.
- Replace validate-then-fetch with resolve-once-and-connect-to-that-IP (code below).
- Turn off redirect following in the HTTP client for these fetches, or re-validate every hop.
- Add the test cases listed above to CI.
How this could have been avoided
- Resolve once. Both rebinding bugs came from two DNS lookups: one to validate, one to connect. A single lookup that you connect to removes the gap.
- Test the validator with the boring inputs. A function that ignores plain IP addresses fails the first test anyone writes for it.
- Use a library that validates at connect time instead of a hand-written helper.
- Limit network reach. A blind SSRF is far less useful when the app server cannot reach internal services at all.
- Treat webhook and image URLs as hostile input, since both are supplied by callers by design.
A fetch path that holds
OWASP's SSRF prevention cheat sheet says deny-lists are bypass-prone and allow-lists are preferred. Where users must supply arbitrary URLs, as with webhooks, it says to resolve the hostname, check every returned A and AAAA address, and connect only to an address you validated, keeping the original hostname for the Host header, SNI and certificate verification. It also says that checking DNS separately before connecting does not prevent rebinding.
A Python sketch of that approach:
import ipaddress
import socket
import requests
from requests.adapters import HTTPAdapter
from urllib.parse import urlparse
def resolve_public(host: str) -> str:
infos = socket.getaddrinfo(host, None, proto=socket.IPPROTO_TCP)
addrs = {i[4][0] for i in infos}
for a in addrs:
ip = ipaddress.ip_address(a)
if not ip.is_global:
raise ValueError(f"blocked address {a}")
return sorted(addrs)[0]
def safe_get(url: str):
u = urlparse(url)
if u.scheme not in ("http", "https"):
raise ValueError("scheme")
ip = resolve_public(u.hostname)
# Connect to the validated IP, keep the hostname for Host and TLS.
pinned = url.replace(u.hostname, ip, 1)
return requests.get(
pinned,
headers={"Host": u.hostname},
allow_redirects=False,
timeout=5,
)
This sketch is a starting point. For HTTPS you also need to keep SNI and certificate checks on the original hostname, which the plain requests call above does not do. In practice that means a custom transport adapter or a proxy that does the connection. Libraries that offer a "safe" client are usually the shorter path.
The ranges to reject if you must use a deny-list, per OWASP, are loopback (127.0.0.0/8, 0.0.0.0/8, ::1/128), RFC 1918 (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), IPv6 unique local (fc00::/7), IPv6 link-local (fe80::/10) and multicast. Note that is_global in Python's ipaddress module covers these and more.
Why it matters
An SSRF check is easy to write and hard to verify by reading it. The 9Router and PraisonAI bugs would pass a code review that only asks "is there a validator?" The FreeScout bug would pass one that asks "does the validator look right?"
What catches them is an adversarial test that uses a rebinding name and a plain internal IP, plus a network that does not let the server reach what it should not. Ask for both when you review an outbound fetch feature.
Two of these three products are AI tooling, where fetching a URL on behalf of a model is a core feature. The attack surface is the feature.
Try it yourself
Take one outbound fetch in your codebase. Write three tests: a plain private IPv4 address, an IPv6 loopback address, and a hostname you control that you can switch from a public to a private answer. Run them against your validator and your HTTP client together, not the validator alone.
Quick check
Why does validating a hostname and then fetching it fail against DNS rebinding?
The two steps resolve the name separately. The attacker's DNS server answers with a public address for the check and an internal address for the connection.
What was wrong with FreeScout's checkIpByMask() before 1.8.211?
It looked for a / character in the input. Plain IP addresses never contain one, so it always returned false and the CIDR ranges were never checked.
Name two controls that limit SSRF even when the validator is wrong.
Network egress rules that block internal ranges and metadata addresses, and IMDSv2 on AWS. Disabling redirect following also removes one bypass.