GHSA-6ww7-3frv-cqxh
NLTK: pathsec SSRF protection can be bypassed when a proxy is configured
Packages
Details
As written in the advisory (Markdown source, shown as plain text).
### 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 | `PermissionError` — **blocked** |
| proxied + opt-in | returns secret — escape hatch works |
| explicit `ProxyHandler` (not env) + ENFORCE | `PermissionError` — **blocked** (whole class) |
| no proxy (direct) | internal IP still refused — pinning intact |
| proxied + `ENFORCE=False` | returns secret **+ warns** |
## Tests
`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.
## Note
The 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.
References
External links from the advisory. They lead to third-party sites.
- https://github.com/nltk/nltk/security/advisories/GHSA-6ww7-3frv-cqxh
- https://nvd.nist.gov/vuln/detail/CVE-2026-78682
- https://github.com/nltk/nltk/commit/767333a005a1cd3d82d2029215f2dbe66a5844d9
- https://github.com/nltk/nltk
- https://github.com/nltk/nltk/releases/tag/v3.10.3
- https://github.com/pypa/advisory-database/tree/main/vulns/nltk/PYSEC-2026-3733.yaml
- https://www.vulncheck.com/advisories/nltk-before-ssrf-protection-bypass-via-proxy