{"id":"GHSA-6ww7-3frv-cqxh","summary":"NLTK: pathsec SSRF protection can be bypassed when a proxy is configured","details":"### Summary\n\nCurrent NLTK source reopens SSRF in proxied environments. `pathsec.urlopen()` validates the requested hostname locally, but once proxy inheritance is enabled the real fetch is performed by the proxy rather than by the validated direct-connect socket path.\n\n### Details\n\n- **Vulnerability type:** Server-side request forgery\n- **Affected component:** `nltk.pathsec.urlopen`, `nltk.data.load`, `nltk.downloader.Downloader.index`, `nltk.downloader.Downloader.download`\n- **Affected versions:** Current source `v3.10.0-rc2`; published `3.9.4` was a negative control and did not reproduce.\n- **Patched versions:** Not yet patched\n- **Root cause:** Proxy-handler inheritance disables `_SafeHTTPHandler` and `_SafeHTTPSHandler`, so the validated hostname no longer matches the actual egress destination.\n\nThe hardened direct path pins the validated numeric destination IP before opening the socket. The proxied branch instead copies `ProxyHandler` instances from the global opener, marks the request as proxied, and skips the pinned handlers. I confirmed that a validated public URL can be fetched from a loopback-only internal service through the proxy path via `pathsec.urlopen()`, `nltk.data.load()`, `Downloader.index()`, and `Downloader.download()`.\n\n### PoC\n\n**Preconditions**\n- The runtime has an HTTP proxy configured and the caller relies on `pathsec` to keep network fetches SSRF-safe.\n\n**Steps**\n1. Start a loopback-only HTTP server that serves secret text, a valid downloader index, and a ZIP payload.\n2. Configure a proxy that forwards a validated public URL to that internal loopback service.\n3. Call `pathsec.urlopen()` or `nltk.data.load()` on the public URL and observe the internal response is returned.\n4. Instantiate `Downloader(server_index_url=...)`, call `index()` and `download()`, and observe internal-only content is parsed and installed.\n\n**Minimal reproducible excerpt**\n\n```text\n{'urlopen': 'PROXY_TEXT_SECRET', 'data_load': 'PROXY_TEXT_SECRET', 'downloaded_file': 'INTERNAL_ZIP_SECRET'}\n```\n\n### Impact\n\nConsumers that trust `pathsec` as an SSRF barrier in proxied environments can be made to read internal-only HTTP resources, load forged downloader indexes, and install attacker-chosen package content fetched from the proxy's network view.\n\n### Remediation\n\nPreserve destination validation for the actual proxy egress target or fail closed when the request would otherwise downgrade into an unpinned proxied path. Add regression tests across `pathsec.urlopen`, `nltk.data.load`, and downloader fetches with a configured proxy.\n\n### References\n\n- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L468-L518\n- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/data.py#L1247-L1283\n- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L875-L889\n- https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L1220-L1226\n- https://github.com/nltk/nltk/blob/3.9.4/nltk/pathsec.py#L245-L250\n\n---\n\n## Fix + attack demonstration (verified)\n\n\nNLTK cannot pin the egress through a proxy, so it stops pretending to: under `ENFORCE` a proxied fetch is **refused** rather than performed unvalidated. Operators who trust their proxy opt back in with `NLTK_ALLOW_PROXIED_URLOPEN=1` or `nltk.pathsec.ALLOW_PROXIED_FETCH=True`; under `ENFORCE=False` the refusal degrades to a warning. This closes the **whole class** (environment proxies and explicit `ProxyHandler` alike), because NLTK declines any fetch whose egress it cannot validate.\n\n## Attack demonstration (reproduced; captured output)\nA loopback HTTP server stands in for the internal target; `http_proxy` points at it; NLTK is asked for a **public** IP URL.\n\n**Before the fix** — the internal secret is exfiltrated through the proxy:\n```\nvalidate_network_url(public): PASSED\n*** BYPASS: pathsec.urlopen returned INTERNAL content via proxy: 'INTERNAL_ONLY_SECRET'\n```\n\n**After the fix** — five scenarios, isolated subprocesses:\n| Scenario | Result |\n|---|---|\n| proxied (env) + ENFORCE | `PermissionError` — **blocked** |\n| proxied + opt-in | returns secret — escape hatch works |\n| explicit `ProxyHandler` (not env) + ENFORCE | `PermissionError` — **blocked** (whole class) |\n| no proxy (direct) | internal IP still refused — pinning intact |\n| proxied + `ENFORCE=False` | returns secret **+ warns** |\n\n## Tests\n`nltk/test/unit/test_pathsec.py`: 64 passed. Added an end-to-end regression (`test_proxied_fetch_does_not_reach_internal_target`) plus `test_env_proxy_fails_closed_under_enforce`; the prior `test_env_proxy_skips_pinning_handlers` (which encoded the vulnerable path) is re-expressed as the opt-in case. Existing direct-path DNS-rebinding and IP-policy tests unchanged and passing. pre-commit (isort/black/ruff) clean.\n\n## Note\nThe upfront `validate_network_url()` and the direct-path IP pinning (from the earlier DNS-rebinding fixes, CVE-2026-54296 / GHSA-qvv7) are unchanged — this only closes the proxied downgrade they didn't cover.","aliases":["CVE-2026-78682","PYSEC-2026-3733"],"modified":"2026-09-08T16:45:04.147588108Z","published":"2026-09-08T16:41:40Z","database_specific":{"nvd_published_at":null,"cwe_ids":["CWE-441","CWE-918"],"severity":"HIGH","github_reviewed":true,"github_reviewed_at":"2026-09-08T16:41:40Z"},"references":[{"type":"WEB","url":"https://github.com/nltk/nltk/security/advisories/GHSA-6ww7-3frv-cqxh"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2026-78682"},{"type":"WEB","url":"https://github.com/nltk/nltk/commit/767333a005a1cd3d82d2029215f2dbe66a5844d9"},{"type":"PACKAGE","url":"https://github.com/nltk/nltk"},{"type":"WEB","url":"https://github.com/nltk/nltk/releases/tag/v3.10.3"},{"type":"WEB","url":"https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3733.yaml"},{"type":"WEB","url":"https://www.vulncheck.com/advisories/nltk-before-ssrf-protection-bypass-via-proxy"}],"affected":[{"package":{"name":"nltk","ecosystem":"PyPI","purl":"pkg:pypi/nltk"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"3.10.3"}]}],"versions":["0.8","0.9","0.9.3","0.9.4","0.9.5","0.9.6","0.9.7","0.9.8","0.9.9","2.0.1","2.0.1rc1","2.0.1rc2-git","2.0.1rc3","2.0.1rc4","2.0.2","2.0.3","2.0.4","2.0.5","2.0b4","2.0b5","2.0b6","2.0b7","2.0b8","2.0b9","3.0.0","3.0.0b1","3.0.0b2","3.0.1","3.0.2","3.0.3","3.0.4","3.0.5","3.1","3.10.0","3.10.1","3.10.2","3.2","3.2.1","3.2.2","3.2.3","3.2.4","3.2.5","3.3","3.4","3.4.1","3.4.2","3.4.3","3.4.4","3.4.5","3.5","3.5b1","3.6","3.6.1","3.6.2","3.6.3","3.6.4","3.6.5","3.6.6","3.6.7","3.7","3.8","3.8.1","3.9","3.9.1","3.9.2","3.9.3","3.9.4","3.9b1"],"database_specific":{"last_known_affected_version_range":"\u003c= 3.10.2","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/09/GHSA-6ww7-3frv-cqxh/GHSA-6ww7-3frv-cqxh.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:N/SI:N/SA:N"}]}