NLTK: pathsec SSRF protection can be bypassed when a proxy is configured
Summary
Current 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.
Details
- Vulnerability type: Server-side request forgery
- Affected component:
nltk.pathsec.urlopen,nltk.data.load,nltk.downloader.Downloader.index,nltk.downloader.Downloader.download - Affected versions: Current source
v3.10.0-rc2; published3.9.4was a negative control and did not reproduce. - Patched versions: Not yet patched
- Root cause: Proxy-handler inheritance disables
_SafeHTTPHandlerand_SafeHTTPSHandler, so the validated hostname no longer matches the actual egress destination.
The 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().
PoC
Preconditions
- The runtime has an HTTP proxy configured and the caller relies on
pathsecto keep network fetches...
Affected versions
| Package | Affected | Fixed in |
|---|---|---|
| nltk PyPI | < 3.10.3 | 3.10.3 |
Details and references
### Summary Current 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. ### Details - **Vulnerability type:** Server-side request forgery - **Affected component:** `nltk.pathsec.urlopen`, `nltk.data.load`, `nltk.downloader.Downloader.index`, `nltk.downloader.Downloader.download` - **Affected versions:** Current source `v3.10.0-rc2`; published `3.9.4` was a negative control and did not reproduce. - **Patched versions:** Not yet patched - **Root cause:** Proxy-handler inheritance disables `_SafeHTTPHandler` and `_SafeHTTPSHandler`, so the validated hostname no longer matches the actual egress destination. The 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()`. ### PoC **Preconditions** - The runtime has an HTTP proxy configured and the caller relies on `pathsec` to keep network fetches SSRF-safe. **Steps** 1. Start a loopback-only HTTP server that serves secret text, a valid downloader index, and a ZIP payload. 2. Configure a proxy that forwards a validated public URL to that internal loopback service. 3. Call `pathsec.urlopen()` or `nltk.data.load()` on the public URL and observe the internal response is returned. 4. Instantiate `Downloader(server_index_url=...)`, call `index()` and `download()`, and observe internal-only content is parsed and installed. **Minimal reproducible excerpt** ```text {'urlopen': 'PROXY_TEXT_SECRET', 'data_load': 'PROXY_TEXT_SECRET', 'downloaded_file': 'INTERNAL_ZIP_SECRET'} ``` ### Impact Consumers 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. ### Remediation Preserve 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. ### References - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/pathsec.py#L468-L518 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/data.py#L1247-L1283 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L875-L889 - https://github.com/nltk/nltk/blob/v3.10.0-rc2/nltk/downloader.py#L1220-L1226 - https://github.com/nltk/nltk/blob/3.9.4/nltk/pathsec.py#L245-L250 --- ## Fix + attack demonstration (verified) NLTK 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. ## Attack demonstration (reproduced; captured output) A loopback HTTP server stands in for the internal target; `http_proxy` points at it; NLTK is asked for a **public** IP URL. **Before the fix** , the internal secret is exfiltrated through the proxy: ``` validate_network_url(public): PASSED *** BYPASS: pathsec.urlopen returned INTERNAL content via proxy: 'INTERNAL_ONLY_SECRET' ``` **After the fix** , five scenarios, isolated subprocesses: | Scenario | Result | |---|---| | proxied (env) + ENFORCE |
- CVSS 4.0
- 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
- Severity from
- GitHub (reviewed advisory)
- Weakness
- CWE-441, CWE-918
- Also known as
- CVE-2026-78682, PYSEC-2026-3733
- github.com/nltk/nltk/security/advisories/GHSA-6ww7-3frv-cqxh
- nvd.nist.gov/vuln/detail/CVE-2026-78682
- github.com/nltk/nltk/commit/767333a005a1cd3d82d2029215f2dbe66a5844d9
- github.com/nltk/nltk
- github.com/nltk/nltk/releases/tag/v3.10.3
- github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3733.yaml
- www.vulncheck.com/advisories/nltk-before-ssrf-protection-bypass-via-proxy