GHSA-vxgj-xg5c-p4h7: praisonaiagents: SSRF guard validates literal IPs only and never resolves DNS
praisonaiagents: SSRF guard validates literal IPs only and never resolves DNS**Researcher:** Kai Aizen — SnailSploit (@SnailSploit), Adversarial & Offensive Security Research
**Target:** https://github.com/MervinPraison/PraisonAI
**Weakness:** CWE-918 Server-Side Request Forgery (SSRF).SummaryThe SSRF guard shared by PraisonAI's web tools (`SpiderTools._validate_url` → `_host_is_blocked` in `praisonaiagents/tools/spider_tools.py`) inspects only **literal IP-address encodings** of the URL host. It never resolves DNS names. Any hostname whose A/AAAA record points at an internal, loopback, link-local, or cloud-metadata address passes validation and the request is issued to that target. A static internal A record is sufficient — no DNS-rebinding race is required.The guard's own docstring claims it returns `True` "when hostname **resolves to** loopback/private/internal targets," but no resolution is performed. The fix for CVE-2026-47390 added more *encodings of literal IPs* (decimal integer, `0x` hex, `inet_aton`); it did not address the *class* "host is a name that resolves to a forbidden address."The same guard is reached through two tool surfaces:`scrape_page` / `crawl` / `extract_links` / `extract_text` (spider tools)the `@url` mention fetch in `praisonaiagents/tools/mentions.py` (which calls the identical `SpiderTools._validate_url` then `urllib.request.urlopen`)The correct pattern already exists in the same package: `file_tools.py` resolves the host with `socket.getaddrinfo` and checks each resolved address before fetching. `spider_tools` / `mentions` do not.Affected packages`pip/praisonaiagents` <= 1.6.39`pip/PraisonAI` <= 4.6.39Root cause`praisonaiagents/tools/spider_tools.py`, `_host_is_blocked` (def at line 26):Every branch operates on the **literal string**. For a DNS name (`attacker.example`): it is not in the literal block sets, not a `.local`/`.internal` suffix, `int(host)` is not applicable, `ipaddress.ip_address(name)` raises `ValueError` (swallowed), `inet_aton(name)` raises `OSError` (swallowed), and the function returns `False` — "not blocked." `socket.getaddrinfo` / `gethostbyname` are never called anywhere in this path.`_validate_url` (def line 74) ends with:so a name verdict of "not blocked" yields `_validate_url(...) == True`, and the caller (`scrape_page`, or `mentions._fetch_url` at lines 273–284) proceeds to fetch the original URL via `requests` / `urllib.request.urlopen`.The literal-IP coverage is otherwise good — Python's `ipaddress.is_reserved` / `is_private` happen to flag NAT64 (`64:ff9b::/96`), 6to4 (`2002::/16`), IPv4-mapped (`::ffff:`), and IPv4-compatible (`::/96`) forms. The single residual literal gap is deprecated site-local `fec0::/10` (`is_private` and `is_reserved` both `False`), which is low-impact on modern stacks. The DNS-name class is the material issue.The promise that was brokenThe block set explicitly contains `"169.254.169.254"` and `"metadata.google.internal"` (line 33) — documented intent to stop cloud-metadata theft. A name-based request defeats exactly that intent: register `metadata-thief.example` with an A record of `169.254.169.254`, and the literal block is never consulted because resolution never happens.Proof of conceptEnd-to-end against a deployed agent: point any controlled domain's A record at `169.254.169.254` (or `127.0.0.1`, or an RFC1918 service), then drive an agent that has `scrape_page`/`crawl` enabled, or include the URL as an `@url` mention. The fetch reaches the internal/metadata target and its response is returned into model context.RemediationResolve the host and apply the existing `_ip_blocked` check to **every** resolved address before fetching — the pattern already implemented in `praisonaiagents/tools/file_tools.py` (lines 339–344):To also close DNS rebinding (resolve-then-connect TOCTOU), pin the connection to the validated address rather than re-resolving at fetch time. Apply the same fix to both `_validate_url` and `mentions._fetch_url`. Additionally add `fec0::/10` to the IPv6 rejection set for completeness.Steps to reproduceClone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI`Run the proof of concept shown above against the cloned source.Observe the result shown under *Verified result* below.Verified resultThis PoC was executed against the live upstream code; captured output:CreditKai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
CVSS v3.1: 8.5