GHSA-VXGJ-XG5C-P4H7
Vulnerability from github – Published: 2026-06-18 13:56 – Updated: 2026-06-18 13:56praisonaiagents: 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).
Summary
The 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.39pip/PraisonAI<= 4.6.39
Root cause
praisonaiagents/tools/spider_tools.py, _host_is_blocked (def at line 26):
def _host_is_blocked(hostname: str) -> bool:
"""Return True when hostname resolves to loopback/private/internal targets."""
...
if host.isdigit(): # decimal-int IPv4 literal
return _ip_blocked(ipaddress.ip_address(int(host)))
if host.startswith("0x"): # hex IPv4 literal
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
try:
return _ip_blocked(ipaddress.ip_address(host)) # dotted v4 / v6 literal
except ValueError:
pass
try:
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host))) # octal/short v4
except OSError:
pass
return False # <-- any DNS name lands here
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:
if _host_is_blocked(parsed.hostname):
return False
return True
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 broken
The 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 concept
import socket
from praisonaiagents.tools.spider_tools import _host_is_blocked, SpiderTools
# Literal forms the CVE-2026-47390 fix added — correctly blocked:
for h in ["127.0.0.1", "2130706433", "0x7f000001", "169.254.169.254", "::1"]:
assert _host_is_blocked(h) is True, h
# DNS names that resolve to internal targets — NOT blocked (the class the fix missed):
for h in ["attacker-controlled.example", "metadata-thief.com", "rebind.attacker.net"]:
assert _host_is_blocked(h) is False, h # A record may be 127.0.0.1 / 169.254.169.254
st = SpiderTools
assert st._validate_url("http://127.0.0.1/") is False # literal blocked
assert st._validate_url("http://metadata-thief.com/") is True # name passes -> request fires
# The guard never even attempts resolution:
import praisonaiagents.tools.spider_tools as S
S.socket.getaddrinfo = lambda *a, **k: (_ for _ in ()).throw(RuntimeError("RESOLVER CALLED"))
assert _host_is_blocked("attacker.example") is False # no RuntimeError -> never resolved
print("[+] CONFIRMED: SSRF guard ignores DNS resolution; name->internal bypasses validation")
End-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.
Remediation
Resolve 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):
resolved = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == "https" else 80))
for family, _, _, _, sockaddr in resolved:
if _ip_blocked(ipaddress.ip_address(sockaddr[0])):
return True # blocked
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 reproduce
- Clone 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 result
This PoC was executed against the live upstream code; captured output:
== Literal internal/loopback encodings — correctly BLOCKED ==
127.0.0.1 blocked=True
2130706433 blocked=True
0x7f000001 blocked=True
169.254.169.254 blocked=True
::1 blocked=True
localhost blocked=True
10.0.0.5 blocked=True
== DNS names whose A-record could point internal — NOT blocked (the gap) ==
attacker-controlled.example blocked=False
metadata-thief.com blocked=False
rebind.attacker.net blocked=False
== Prove resolution is NEVER attempted (monkeypatch getaddrinfo to explode) ==
_host_is_blocked('metadata-thief.com') = False (no RuntimeError -> DNS never resolved)
== _validate_url verdict (replicating the method's host check on the real func) ==
http://127.0.0.1/ -> validate=False (blocked)
http://metadata-thief.com/ -> validate=True (PASSES -> request fires)
[+] CONFIRMED: name->internal bypasses the SSRF guard; getaddrinfo/gethostbyname never called.
Credit
Kai Aizen — SnailSploit (@SnailSploit). Adversarial & Offensive Security Research.
{
"affected": [
{
"database_specific": {
"last_known_affected_version_range": "\u003c= 1.6.48"
},
"package": {
"ecosystem": "PyPI",
"name": "praisonaiagents"
},
"ranges": [
{
"events": [
{
"introduced": "0"
},
{
"fixed": "1.6.59"
}
],
"type": "ECOSYSTEM"
}
]
}
],
"aliases": [],
"database_specific": {
"cwe_ids": [
"CWE-918"
],
"github_reviewed": true,
"github_reviewed_at": "2026-06-18T13:56:46Z",
"nvd_published_at": null,
"severity": "HIGH"
},
"details": "# praisonaiagents: SSRF guard validates literal IPs only and never resolves DNS\n\n**Researcher:** Kai Aizen \u2014 SnailSploit (@SnailSploit), Adversarial \u0026 Offensive Security Research\n**Target:** https://github.com/MervinPraison/PraisonAI\n**Weakness:** CWE-918 Server-Side Request Forgery (SSRF).\n\n---\n\n## Summary\n\nThe SSRF guard shared by PraisonAI\u0027s web tools (`SpiderTools._validate_url` \u2192 `_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 \u2014 no DNS-rebinding race is required.\n\nThe guard\u0027s 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.\"\n\nThe same guard is reached through two tool surfaces:\n- `scrape_page` / `crawl` / `extract_links` / `extract_text` (spider tools)\n- the `@url` mention fetch in `praisonaiagents/tools/mentions.py` (which calls the identical `SpiderTools._validate_url` then `urllib.request.urlopen`)\n\nThe 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.\n\n## Affected packages\n\n- `pip/praisonaiagents` \u003c= 1.6.39\n- `pip/PraisonAI` \u003c= 4.6.39\n\n## Root cause\n\n`praisonaiagents/tools/spider_tools.py`, `_host_is_blocked` (def at line 26):\n\n```python\ndef _host_is_blocked(hostname: str) -\u003e bool:\n \"\"\"Return True when hostname resolves to loopback/private/internal targets.\"\"\"\n ...\n if host.isdigit(): # decimal-int IPv4 literal\n return _ip_blocked(ipaddress.ip_address(int(host)))\n if host.startswith(\"0x\"): # hex IPv4 literal\n return _ip_blocked(ipaddress.ip_address(int(host, 16)))\n try:\n return _ip_blocked(ipaddress.ip_address(host)) # dotted v4 / v6 literal\n except ValueError:\n pass\n try:\n return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host))) # octal/short v4\n except OSError:\n pass\n return False # \u003c-- any DNS name lands here\n```\n\nEvery 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` \u2014 \"not blocked.\" `socket.getaddrinfo` / `gethostbyname` are never called anywhere in this path.\n\n`_validate_url` (def line 74) ends with:\n\n```python\n if _host_is_blocked(parsed.hostname):\n return False\n return True\n```\n\nso a name verdict of \"not blocked\" yields `_validate_url(...) == True`, and the caller (`scrape_page`, or `mentions._fetch_url` at lines 273\u2013284) proceeds to fetch the original URL via `requests` / `urllib.request.urlopen`.\n\nThe literal-IP coverage is otherwise good \u2014 Python\u0027s `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.\n\n### The promise that was broken\n\nThe block set explicitly contains `\"169.254.169.254\"` and `\"metadata.google.internal\"` (line 33) \u2014 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.\n\n## Proof of concept\n\n```python\nimport socket\nfrom praisonaiagents.tools.spider_tools import _host_is_blocked, SpiderTools\n\n# Literal forms the CVE-2026-47390 fix added \u2014 correctly blocked:\nfor h in [\"127.0.0.1\", \"2130706433\", \"0x7f000001\", \"169.254.169.254\", \"::1\"]:\n assert _host_is_blocked(h) is True, h\n\n# DNS names that resolve to internal targets \u2014 NOT blocked (the class the fix missed):\nfor h in [\"attacker-controlled.example\", \"metadata-thief.com\", \"rebind.attacker.net\"]:\n assert _host_is_blocked(h) is False, h # A record may be 127.0.0.1 / 169.254.169.254\n\nst = SpiderTools\nassert st._validate_url(\"http://127.0.0.1/\") is False # literal blocked\nassert st._validate_url(\"http://metadata-thief.com/\") is True # name passes -\u003e request fires\n\n# The guard never even attempts resolution:\nimport praisonaiagents.tools.spider_tools as S\nS.socket.getaddrinfo = lambda *a, **k: (_ for _ in ()).throw(RuntimeError(\"RESOLVER CALLED\"))\nassert _host_is_blocked(\"attacker.example\") is False # no RuntimeError -\u003e never resolved\n\nprint(\"[+] CONFIRMED: SSRF guard ignores DNS resolution; name-\u003einternal bypasses validation\")\n```\n\nEnd-to-end against a deployed agent: point any controlled domain\u0027s 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.\n\n## Remediation\n\nResolve the host and apply the existing `_ip_blocked` check to **every** resolved address before fetching \u2014 the pattern already implemented in `praisonaiagents/tools/file_tools.py` (lines 339\u2013344):\n\n```python\nresolved = socket.getaddrinfo(parsed.hostname, parsed.port or (443 if parsed.scheme == \"https\" else 80))\nfor family, _, _, _, sockaddr in resolved:\n if _ip_blocked(ipaddress.ip_address(sockaddr[0])):\n return True # blocked\n```\n\nTo 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.\n\n## Steps to reproduce\n\n1. Clone the target: `git clone --depth 1 https://github.com/MervinPraison/PraisonAI`\n2. Run the proof of concept shown above against the cloned source.\n3. Observe the result shown under *Verified result* below.\n\n## Verified result\n\nThis PoC was executed against the live upstream code; captured output:\n\n```\n== Literal internal/loopback encodings \u2014 correctly BLOCKED ==\n 127.0.0.1 blocked=True\n 2130706433 blocked=True\n 0x7f000001 blocked=True\n 169.254.169.254 blocked=True\n ::1 blocked=True\n localhost blocked=True\n 10.0.0.5 blocked=True\n\n== DNS names whose A-record could point internal \u2014 NOT blocked (the gap) ==\n attacker-controlled.example blocked=False\n metadata-thief.com blocked=False\n rebind.attacker.net blocked=False\n\n== Prove resolution is NEVER attempted (monkeypatch getaddrinfo to explode) ==\n _host_is_blocked(\u0027metadata-thief.com\u0027) = False (no RuntimeError -\u003e DNS never resolved)\n\n== _validate_url verdict (replicating the method\u0027s host check on the real func) ==\n http://127.0.0.1/ -\u003e validate=False (blocked)\n http://metadata-thief.com/ -\u003e validate=True (PASSES -\u003e request fires)\n\n[+] CONFIRMED: name-\u003einternal bypasses the SSRF guard; getaddrinfo/gethostbyname never called.\n```\n\n## Credit\n\nKai Aizen \u2014 SnailSploit (@SnailSploit). Adversarial \u0026 Offensive Security Research.",
"id": "GHSA-vxgj-xg5c-p4h7",
"modified": "2026-06-18T13:56:47Z",
"published": "2026-06-18T13:56:46Z",
"references": [
{
"type": "WEB",
"url": "https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-vxgj-xg5c-p4h7"
},
{
"type": "PACKAGE",
"url": "https://github.com/MervinPraison/PraisonAI"
}
],
"schema_version": "1.4.0",
"severity": [
{
"score": "CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N",
"type": "CVSS_V3"
}
],
"summary": "praisonaiagents: SSRF guard validates literal IPs only and never resolves DNS"
}
Sightings
| Author | Source | Type | Date | Other |
|---|
Nomenclature
- Seen: The vulnerability was mentioned, discussed, or observed by the user.
- Confirmed: The vulnerability has been validated from an analyst's perspective.
- Published Proof of Concept: A public proof of concept is available for this vulnerability.
- Exploited: The vulnerability was observed as exploited by the user who reported the sighting.
- Patched: The vulnerability was observed as successfully patched by the user who reported the sighting.
- Not exploited: The vulnerability was not observed as exploited by the user who reported the sighting.
- Not confirmed: The user expressed doubt about the validity of the vulnerability.
- Not patched: The vulnerability was not observed as successfully patched by the user who reported the sighting.