Safeguard
Application Security•

SSRF Filters That Check Then Fetch: Three 2026 Advisories

PraisonAI, 9Router and FreeScout all shipped SSRF checks that looked right and still failed. What went wrong in each, and how to build a fetch path that holds.

Safeguard Security Team
6 min read

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

ProductCVEWhat failedFixed in
PraisonAICVE-2026-60091webhook_url validated at request time, re-resolved at connection time (DNS rebinding)4.6.78
9RouterCVE-2026-56676Image URL host resolved for validation, then resolved again by the fetch0.5.2
FreeScoutCVE-2026-34443checkIpByMask() returned false for plain IPs, so CIDR ranges were never checked1.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

  1. Upgrade to PraisonAI 4.6.78, 9Router 0.5.2 and FreeScout 1.8.211 or later where you run them.
  2. Egress control comes first: block your app servers from reaching link-local metadata (169.254.169.254) and internal ranges at the network layer.
  3. On AWS, require IMDSv2 and disable IMDSv1.
  4. Replace validate-then-fetch with resolve-once-and-connect-to-that-IP (code below).
  5. Turn off redirect following in the HTTP client for these fetches, or re-validate every hop.
  6. 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.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.