Skip to content

Send set_proxy's credentials on the HTTPS CONNECT, and keep proxy credentials off redirects - #3970

Closed
Thanatos9404 wants to merge 2 commits into
nltk:developfrom
Thanatos9404:fix-proxy-auth-https-connect
Closed

Thanatos9404 wants to merge 2 commits into
nltk:developfrom
Thanatos9404:fix-proxy-auth-https-connect

Conversation

@Thanatos9404

Copy link
Copy Markdown

Fixes #3109.

What and why

nltk.set_proxy(proxy, user=..., password=...) installed ProxyBasicAuthHandler and ProxyDigestAuthHandler, which only answer a 407 to a plain HTTP request. NLTK data comes over HTTPS, which a proxy carries through a CONNECT tunnel, and urllib opens that tunnel before any handler sees a response. So the proxy never got the credentials, and nltk.download() failed with Tunnel connection failed: 407 Proxy Authentication Required. The 2026-10-07 issue map in #3883 reproduced this and found that credentials in the proxy URL do get sent.

How

Two commits. The redirect fix comes first, so no commit in the history sends set_proxy's credentials to a host that develop did not send them to.

  1. nltk/pathsec.py: do not carry proxy credentials across a redirect. When a proxy URL has credentials, ProxyHandler adds Proxy-Authorization to each request it routes through the proxy as an ordinary header, and HTTPRedirectHandler copies ordinary headers onto the redirected request. If that request bypasses the proxy (NO_PROXY or the system bypass list), its target received the credentials. A new _ProxyCredentialsRedirectHandler removes the header from the redirected request, and _ValidatingRedirectHandler now subclasses it: pathsec.urlopen runs the SSRF check of the new URL first, then drops the header. ProxyHandler adds the header back if the redirected request is proxied. On develop this leak already happened with HTTPS_PROXY=http://user:password@... credentials (with the proxied-fetch opt-in set); the next commit makes set_proxy use the same mechanism. The SSRF attack harness gets the redirect cases for this commit (see Tests).
  2. nltk/util.py: send the credentials on the CONNECT. set_proxy puts user and password into the proxy URL it gives ProxyHandler, each percent-encoded with quote(..., safe="") so that an @, :, /, % or a DOMAIN\user backslash stays data. urllib then sends Proxy-Authorization: Basic ... with every request it routes through the proxy. For HTTPS, urllib.request.AbstractHTTPHandler.do_open moves that header onto the CONNECT and does not send it to the origin server. pathsec.urlopen already copies the proxy URL from the installed opener, so nltk.download() needs no other change.
    • The scheme and host:port come from urllib.request._parse_proxy, the parser ProxyHandler itself uses, and the URL is rebuilt as scheme://user:password@host:port. The credentials therefore go to the host the requests go to, also when the proxy URL holds another URL in its path, query or fragment (http://proxy:3128/?next=http://other:8080). Credentials passed to set_proxy replace any already in the proxy URL. A proxy without a scheme (proxy.example.com:3128), an upper-case scheme and an IPv6 literal also work.
    • The basic and digest handlers stay. They still answer a plain-HTTP Basic challenge when the password is empty.
    • The opener that set_proxy installs also gets pathsec's _ProxyCredentialsRedirectHandler from commit 1. Code that calls urllib.request.urlopen after set_proxy uses that opener, not pathsec.urlopen. util imports the handler, quote and _parse_proxy under private names, so from nltk.util import * adds nothing to the nltk namespace.
    • password=None now means "". The (user, password) tuple that web/data.rst showed is accepted. Any other user or password that is not exactly a str raises TypeError before the installed opener is replaced. A str subclass is refused too (type(x) is not str), the rule in the proposed AGENTS.md (Add AGENTS.md file #3892).
    • The docstring says that the credentials are sent preemptively as Basic, that HTTPS needs a non-empty password, and that non-string credentials raise TypeError.
    • web/data.rst now passes user= and password=.

Tests

All offline. Every proxy and origin is a stub server on 127.0.0.1. The target, 93.184.216.34, is a public address literal as in the SSRF tests, and only the stub proxy ever receives requests for it. Each stub proxy wants Basic credentials. It records the method, target and Proxy-Authorization of every request, and it never opens a tunnel (an authenticated CONNECT gets a 502). Every urlopen call in the new tests passes timeout=5. In the attack harness, any connection to something other than a stub is recorded and refused, and each case asserts that none was attempted.

54 new cases: 11 in commit 1 and 43 in commit 2.

Commit 1, nltk/test/unit/test_pathsec.py:

  • test_redirect_does_not_carry_proxy_credentials: a redirected request keeps its other headers but not Proxy-Authorization.

Commit 1, nltk/test/unit/test_attack_ssrf_expanded.py (new section 20, "Proxy credentials reach the proxy and nothing else"). The credentials come from HTTP_PROXY and HTTPS_PROXY, with the proxied-fetch opt-in set and NO_PROXY=127.0.0.1, and every request goes through pathsec.urlopen:

  • test_proxy_credentials_stay_off_a_redirect_that_bypasses_the_proxy[environment-301, -302, -303, -307, -308]: the proxy redirects to a second stub that bypasses it, and that stub gets no Proxy-Authorization.
  • test_proxy_credentials_follow_a_redirect_that_stays_proxied[environment-301, -302, -303, -307, -308]: the proxy gets the credentials on both requests, with no 407 in between.
  • 308 is skipped on Python 3.10, where urllib does not follow it.

Commit 2, nltk/test/unit/test_util.py. The stub proxy wants CORP\alice / p@ss:w/rd %:

  • test_set_proxy_authenticates_the_https_tunnel[url, no-scheme, upper-case-scheme, replaces-credentials-in-url, url-in-query]: pathsec.urlopen("https://93.184.216.34/index.xml") sends exactly one CONNECT to the configured proxy, and it carries the credentials. url-in-query is http://127.0.0.1:P/?next=http://127.0.0.1:9.
  • test_set_proxy_still_authenticates_plain_http
  • test_set_proxy_keeps_credentials_off_a_redirect_that_bypasses_the_proxy: through urllib.request.urlopen and the opener set_proxy installs, a redirect to a stub that bypasses the proxy carries no Proxy-Authorization.
  • test_set_proxy_authenticates_a_redirect_that_stays_proxied[urllib, pathsec]
  • test_set_proxy_puts_the_credentials_in_the_proxy_url[password-none, legacy-tuple, ipv6-literal]
  • test_set_proxy_rejects_credentials_that_are_not_strings[bytes-user, int-password, tuple-and-password, str-subclass]: each raises TypeError and leaves the installed opener as it was.

Commit 2, nltk/test/unit/test_attack_ssrf_expanded.py. All of these go through pathsec.urlopen:

  • The redirect cases of commit 1 also run with the credentials given to set_proxy: [set_proxy-301 ... set_proxy-308] for both redirect tests.
  • test_proxy_credentials_authenticate_the_https_tunnel[environment, set_proxy]: the CONNECT carries the credentials from HTTPS_PROXY or from set_proxy.
  • test_set_proxy_hostile_credentials_reach_the_proxy_as_data[https|http × url-delimiters, authority-in-user, crlf-in-password, query-fragment-brackets, percent-encoded-text, non-ascii]: 12 cases. The proxy receives exactly the credentials given, once, and no header is injected.
  • test_set_proxy_credentials_go_to_the_configured_proxy_only[url-in-query, url-in-fragment, url-in-path]: a second stub, whose address appears later in the proxy URL, receives nothing.

Failing on develop. The branch's three test files were run against develop's source with -k "proxy_credentials or set_proxy or redirect_does_not_carry". That selects the 54 new cases and the existing test_urlopen_honors_set_proxy_and_redirect_validation. Result: 46 failed and 9 passed on 3.13 and 3.14. On 3.10, 43 failed, 8 passed and the 4 cases for 308 were skipped.

  • 12 CONNECT cases fail with Tunnel connection failed: 407 Proxy Authentication Required. These are the 5 test_util tunnel cases, the set_proxy tunnel case and the 6 hostile HTTPS cases.
  • The 3 go_to_the_configured_proxy_only cases fail because the CONNECT reached the configured proxy without credentials. On develop, too, the second stub got nothing.
  • The 5 environment bypass cases fail with "the proxy credentials left the proxy". This is the existing leak through pathsec.urlopen.
  • The 5 set_proxy bypass cases, the 5 set_proxy stays-proxied cases, the 2 test_util stays-proxied cases and the 6 hostile HTTP cases fail because each request first goes out without credentials and gets a 407.
  • The pathsec unit case fails with assert 'Basic YWxpY2U6c2VjcmV0' is None.
  • The 3 puts_the_credentials_in_the_proxy_url cases fail because develop leaves the credentials out of the URL.
  • The 4 type cases fail with DID NOT RAISE TypeError.

8 new cases pass on develop, as intended. They are the plain-HTTP case, the test_util bypass case (develop sent set_proxy's credentials only as a header that urllib does not copy onto a redirect), the 5 environment stays-proxied cases and the environment tunnel case. The last six are urllib's own behaviour.

On this branch. All 55 pass on 3.13 and 3.14. On 3.10, 51 pass and the 4 cases for 308 are skipped. The 55 existing proxy, proxied and redirect tests pass on both develop and this branch. They are in test_util.py, test_pathsec.py, test_attack_ssrf_expanded.py, test_advisory_probes.py and test_downloader_url_scheme_security.py.

The downloader test files were run offline on 3.13 with the network-only live_index test deselected. Both the branch and develop give 634 passed, 83 skipped and 3 failed. The 3 failures are the tests that download real data. The security probe report (python -m nltk.test.unit.security_probes, 65 probes) gives the same verdicts on the branch and develop. black, isort, ruff, pyupgrade and the repository's guard hooks pass at both commits. At commit 1 alone, its own 11 cases pass (9 on 3.10, where the two 308 cases are skipped), and the rest of test_pathsec.py, test_util.py and test_attack_ssrf_expanded.py give the same results as on develop.

Security notes

  • Preemptive Basic. With a user, every request routed through the proxy now carries Proxy-Authorization: Basic ... from the start, including the CONNECT. develop sent it only after a Basic 407 to a plain HTTP request. Basic is base64, not encryption. On the client-to-proxy hop of an http:// proxy, anyone who can read that traffic can read the credentials. They are sent only on requests routed through the proxy.
  • Digest-only or NTLM-only proxies now receive that Basic header on the first request, before their challenge. On develop they received nothing, and the request failed. Plain HTTP failed with ValueError: ... does not support the following scheme, and CONNECT failed with a 407. The outcome is still a failure, because urllib supports neither scheme on CONNECT.
  • Redirects. Credentials are removed from every redirected request in pathsec.urlopen and in the opener set_proxy installs. ProxyHandler adds them back only when the redirected request goes through the proxy. The harness covers 301, 302, 303, 307 and 308 through pathsec.urlopen, with credentials from the environment and from set_proxy. A target that bypasses the proxy received no header. A proxied target received the header once per request, with no 407. Python 3.10 does not follow 308 at all. The change also stops pathsec.urlopen from forwarding HTTPS_PROXY=http://user:password@... credentials to a NO_PROXY redirect target, which develop did. That includes a redirect sent by an HTTPS origin inside the tunnel, which was probed with a local TLS origin.
  • Proxy URL shape. set_proxy takes the proxy's scheme and host with urllib.request._parse_proxy, as ProxyHandler does. So with http://127.0.0.1:P1/?next=http://127.0.0.1:P2, or another URL in the path or fragment, the CONNECT and the credentials go to P1, and P2 receives nothing. A version that split the string at its last :// and @ sent both to P2 (see the audit trail).
  • Injection. The credentials are percent-encoded in the URL and base64-encoded in the header. The harness sends 6 hostile credential pairs over HTTPS and plain HTTP. Among them are a user name [email protected]:8080/, a password containing \r\nX-Injected: 1, URL and IPv6 delimiters, already percent-encoded text and non-ASCII. Each reached the stub proxy once, decoded back exactly, and added no header.
  • Error text. No exception or downloader message contained the credentials. Checked: a 407 on CONNECT and on HTTP, a refused proxy port, a non-numeric proxy port, and the error line from Downloader.download.
  • The proxied-fetch opt-in is still required. pathsec.urlopen still refuses any proxied fetch unless NLTK_ALLOW_PROXIED_URLOPEN=1 or nltk.pathsec.ALLOW_PROXIED_FETCH = True is set, because NLTK cannot SSRF-pin a fetch made through a proxy (GHSA-6ww7). Without the opt-in, nltk.download() through set_proxy reports that refusal, on develop and here alike. With the opt-in, develop got the 407 from this issue, and this branch authenticates.

Compatibility

  • The (user, password) tuple from the old web/data.rst example is accepted. It never worked on develop: over plain HTTP the proxy received the literal user name ('USERNAME', 'PASSWORD') and answered 407, and over HTTPS nothing was sent.
  • password=None means "". On develop, None made the basic handler send nothing.
  • user and password must be exactly str, and anything else raises TypeError, including a str subclass. On develop, an int password worked over plain HTTP, sent in its str() form. It never worked over HTTPS. A bytes user was sent as b'alice'.
  • With a user, a proxy URL that urllib cannot parse, such as http:/proxy:3128 with no authority, now raises urllib's ValueError from set_proxy itself, before the installed opener is replaced. On develop, set_proxy accepted it and the same ValueError came from the first fetch.
  • HTTPS through an authenticating proxy needs a non-empty password, because urllib sends the URL's credentials only when both parts are non-empty. Over plain HTTP, a Basic challenge is still answered with an empty password, as on develop.
  • A Proxy-Authorization header that a caller sets on its own Request is now also dropped on a redirect through the opener set_proxy installs.

Limits

  • HTTPS through a Digest or NTLM proxy still fails. urllib supports neither scheme on CONNECT.
  • urllib.request.urlopen with credentials in HTTP(S)_PROXY and no set_proxy call uses urllib's default opener, which still copies the header onto redirects. That is stdlib behaviour. NLTK's own fetches go through pathsec.urlopen.
  • A redirect inside an HTTPS tunnel is not a unit test, because it needs a TLS origin and so a certificate. It was probed locally, as described above.
  • set_proxy does not validate the proxy string beyond urllib's parsing. With an @ after the host (http://proxy:3128/x@y), urllib reads y as the host, on develop too, and the credentials go there with the requests.
  • Only the Windows tests were run locally. The Linux and macOS CI legs were not.

Transparency (per #3883)

Prompt

TARGETS (two separate fixes, two separate branches, two separate PR drafts) in nltk/nltk: #3167 — in non-interactive runs, the downloader's retry prompt (choice = input().strip() around nltk/downloader.py:1614 on develop) raises EOFError. #3109 — "407 Proxy Authentification Required": nltk.set_proxy(proxy, user=, password=) does not authenticate HTTPS CONNECT tunnels; nltk/util.py only installs ProxyBasicAuthHandler, which urllib does not apply to CONNECT. (Verify; consider embedding credentials in the proxy URL handed to ProxyHandler, which urllib uses for CONNECT, and URL-quoting them.) PROJECT NORMS — READ FIRST: maintainer issue #3883 sets rules for AI-assisted contributions (read the whole thread) … If the rules require a plan comment BEFORE a PR even for these, do NOT post anything — draft the comment text and report that requirement. HARD RULES: NO GitHub writes of any kind … Tests must be offline … PROCESS (for each issue): 1. Re-verify unclaimed … 2. Reproduce with a failing test in nltk's test layout/style … 3. Smallest correct fix in nltk's style; for #3167 decide sensible non-interactive behavior (e.g. treat EOF as the default/quit choice) consistent with the surrounding prompt code; for #3109 make sure credentials with special characters are quoted and HTTP (non-CONNECT) proxy auth still works. 4. Run the targeted tests and the repo's lint/format config … 5. Self-review as a skeptical maintainer; fix findings. 6. Commit on branches … 7. Write PR drafts … including the AI disclosure and audit trail #3883 requires.

(local paths redacted; "…" marks omitted boilerplate about local paths and tooling)

  • Audit trail:
    1. Read To contributors and agents scanning Github issues to create new pull requests #3883 and its issue map, Tunnel connection failed: 407 Proxy Authentification Required #3109 and its comment, fix set_proxy for https #1624, the open AGENTS.md PR Add AGENTS.md file #3892, and the proxy code in nltk/util.py and nltk/pathsec.py. The pathsec code includes urlopen's handler copying and the proxied-fetch opt-in. PR searches found nothing addressing Tunnel connection failed: 407 Proxy Authentification Required #3109.

    2. Wrote the stub-proxy test first. On develop the CONNECT carried no credentials and failed with the reported 407, while plain HTTP authenticated after a 407.

    3. Read the urllib paths. ProxyHandler.proxy_open adds Proxy-authorization from the URL's credentials, and AbstractHTTPHandler.do_open moves it into the tunnel headers. Put the quoted credentials into the proxy URL. Probing then showed that the credentials now followed a redirect to a host that bypasses the proxy, which develop's set_proxy did not do. Added a fix in pathsec._ValidatingRedirectHandler. First version: 2 commits, the set_proxy change first, +125 −4, 3 test cases.

    4. Review pass 1, and the changes it led to:

      • The set_proxy commit leaked on its own until the pathsec commit, so the order was swapped.
      • urllib.request.urlopen after set_proxy still copied the header to a bypassed host, so the opener set_proxy installs got its own redirect handler.
      • quote is now imported as _quote, so from nltk.util import * does not export it.
      • password=None now means "". Before, it raised a TypeError from quote().
      • The (user, password) tuple from the old docs is accepted, and other non-strings raise a clear TypeError.
      • The docstring now explains the preemptive Basic.
      • This gave 2 commits, +279 −8, with 13 test cases.
    5. Review pass 2:

      • Re-ran the new and related tests on Python 3.10, 3.13 and 3.14 against the branch, develop and commit 1 alone.
      • Probed the redirect codes through both openers, credentials from the environment proxy, Digest and NTLM proxies, wrong credentials, hostile credentials, error text, and the argument shapes on develop and the branch.
      • Ran an end-to-end CONNECT through a stub proxy to a local TLS origin, then the security probe report, the offline downloader test files, the formatters and the guard hooks.
      • Found three issues. First, set_proxy split the proxy string at its last :// and @, so with http://127.0.0.1:P1/?next=http://127.0.0.1:P2 the CONNECT and the credentials went to P2. Second, the SSRF attack harness had no cases for this surface, which To contributors and agents scanning Github issues to create new pull requests #3883's audit asks for. Third, the header-dropping handler was written twice, and from nltk.util import * exported nltk.HTTPRedirectHandler, which develop did not.
    6. Review pass 3, a security review of that branch. It asked for the following:

      • Take the host from urllib.request._parse_proxy, with a url-in-query tunnel case that fails on the earlier version.
      • Add attack-harness cases through pathsec.urlopen: credentials from set_proxy and from HTTPS_PROXY, every redirect code, hostile credentials, and proxy URL shapes.
      • Define the handler once in pathsec, import it under a private name, and add no public names to nltk.
      • Follow the repository's convention for type checks, which is type(x) is not str in the proposed AGENTS.md (Add AGENTS.md file #3892).
      • Name AbstractHTTPHandler.do_open in the commit message, pass timeout= to every new urlopen, and document the TypeError, the non-empty HTTPS password and the preemptive Basic in the docstring.

      Both commits were rebuilt from develop to meet these points. Commit 1 now adds _ProxyCredentialsRedirectHandler and the harness section, and commit 2 imports that handler. The str-subclass, upper-case-scheme and IPv6 cases were added, and so was an HTTPS tunnel case that runs with both credential sources. The result is 2 commits, +609 −9, with 54 new test cases. The url-in-query cases in both files, and the url-in-fragment and url-in-path harness cases, fail on the earlier version and pass now. dir(nltk) is the same as on develop.

      Re-ran the matrix on Python 3.10, 3.13 and 3.14. The branch's tests were run against develop's source and against the earlier version. Each commit was also tested alone, and the branch was tested merged onto the current upstream develop. The scratch probes ran on the branch, commit 1, develop and the earlier version. Then came the security probe report, the offline downloader test files, the formatters and the guard hooks.

ProxyHandler adds the credentials of a proxy URL, such as
HTTPS_PROXY=http://user:password@proxy:3128, to each request it routes
through that proxy as an ordinary Proxy-Authorization header, and
HTTPRedirectHandler copies ordinary headers onto the redirected request.
When that request is not proxied (its host is in NO_PROXY or the system
bypass list), the credentials went to its host.

Add _ProxyCredentialsRedirectHandler, which drops the header from the
redirected request, and make _ValidatingRedirectHandler a subclass of
it: pathsec.urlopen validates the new URL first, then drops the header.
ProxyHandler adds it again when the redirected request is proxied.

The SSRF attack harness gets cases that run pathsec.urlopen against
stub servers on 127.0.0.1, with the credentials in HTTP_PROXY and
HTTPS_PROXY, for each redirect code: a redirect to a NO_PROXY host must
not receive the header, and a redirect the proxy carries must still
authenticate.

This comes first because the fix for nltk#3109 makes nltk.set_proxy put its
user and password in the proxy URL too.

Refs nltk#3109.
nltk.set_proxy(proxy, user=..., password=...) only installed
ProxyBasicAuthHandler, which answers a 407 to a plain HTTP request. An
HTTPS request goes through a CONNECT tunnel that http.client opens
before any handler sees a response, so the proxy got no credentials and
nltk.download failed with "Tunnel connection failed: 407 Proxy
Authentication Required".

Put the credentials in the proxy URL given to ProxyHandler,
percent-encoded so that an "@", ":" or "/" in them stays data. urllib
sends them as Proxy-Authorization with every request it routes through
the proxy, and urllib.request.AbstractHTTPHandler.do_open moves that
header onto the CONNECT and keeps it from the origin server.
pathsec.urlopen already copies the proxy URL from the installed opener,
so the downloader picks them up. The basic and digest handlers stay for
plain HTTP.

The scheme and host come from urllib.request._parse_proxy, the parser
ProxyHandler itself uses, so the credentials go to the host the
requests go to, also when the proxy URL holds another URL in its path,
query or fragment. Credentials already in the URL are replaced, and a
proxy without a scheme still works.

ProxyHandler adds the credentials as an ordinary header, which urllib's
redirect handler copies onto a redirected request. So the opener that
set_proxy installs gets pathsec's _ProxyCredentialsRedirectHandler from
the previous commit: a redirect to a host that bypasses the proxy
(NO_PROXY) must not receive the credentials, also through a plain
urllib.request.urlopen, and ProxyHandler adds them again when the
redirected request is proxied.

password=None is treated like the default empty password. The
(user, password) tuple that web/data.rst passed as the second argument
is still accepted; a user or password that is not exactly a str now
raises TypeError instead of being sent in its str() form. Also fix the
web/data.rst example to pass user= and password=.

The SSRF attack harness gets the set_proxy cases: the redirect cases
of the previous commit, the CONNECT of an HTTPS request (with the
credentials from set_proxy or from HTTPS_PROXY), hostile credential
strings, and proxy URLs with another URL after the host.

Fixes nltk#3109.
@alvations

Copy link
Copy Markdown
Contributor

Please read #3883 carefully (human too). Solving the previous PR you’ve opened together with this would make more sense.

Also this involves security, please turn up the agent model’s effort to xhigh or above. And make sure the benign, malicious paths and all pentesting flow from develop-security branch is properly followed

Comment thread nltk/pathsec.py
new_req = super().redirect_request(req, fp, code, msg, headers, newurl)
if new_req is not None:
new_req.remove_header("Proxy-authorization")
return new_req

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This becomes a threat vector by itself. Please ask your agent to review this and go through the pentest playbook thoroughly with xhigh effort. https://github.com/nltk/nltk/blob/develop-security/tools/pentest/AGENT_PENTEST.md

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, it needed a closer look. I went through AGENT_PENTEST.md on develop-security for this surface, mainly the network class in section 7.2 (a redirect to a blocked target, a DNS rebind between validation and connect, a proxy that resolves the name). The handler hid a bigger problem instead of solving it. The fixes below are on #3965, which now also carries this PR's commits, as I explain there.

Under a proxy, pathsec.urlopen left out the pinning handlers entirely, since they could not open a CONNECT tunnel. The opt in vouches for the proxy, but urllib still makes some connections itself: to a host in NO_PROXY or the system bypass list, to a redirect target that leaves the proxy (the case this handler was written for), and to a proxy a caller's Request names, when ProxyHandler leaves it in place. Those went out by host name after validate_network_url had resolved it once, so a name that validated as public could rebind to loopback. My own test asserted that such a redirect succeeds, which made the path look supported. With HTTPS_PROXY and a redirect it was already reachable on develop.

0af531e keeps the pinning handlers in place under a proxy too. They hand a connection to urllib only when ProxyHandler routed the request to one of the configured proxies, either a plain HTTP request to it or the CONNECT of a tunnel through it. Everything else is pinned like a direct fetch. The proxy hosts are read with urllib.request._parse_proxy, the parser ProxyHandler uses. The SSRF harness gets section 21: the rebind behind a redirect off the proxy (environment or set_proxy, 302 or 307, http or https), the first hop with the proxy from the environment or from set_proxy, and a Request that names its own proxy. Before the fix the egress recorder caught a connection by name in 10 of those 11 cases; the eleventh was already pinned. test_env_proxy_opt_in_skips_pinning_handlers encoded the old design, so it is replaced by a test that the env ProxyHandler keeps its proxies and both pinning handlers trust exactly that proxy.

The handler itself stays, because it still does one job: with credentials in HTTP_PROXY and a second proxy without credentials in HTTPS_PROXY, a redirect from http to https would otherwise carry the first proxy's credentials onto the second proxy's CONNECT. There is now a test for that, with a teeth case that restores urllib's own redirect handling and sees the leak.

The same pass found a credential confusion in set_proxy (066afdc). A : in the user name is percent encoded into the proxy URL, but urllib unquotes it and sends user:password, so the proxy splits in the wrong place: for [email protected]:8080/ it reads the user [email protected]. My hostile credential test compared header strings and missed it. RFC 7617 also allows no control characters. set_proxy now raises ValueError for both, without repeating the credentials, and leaves the installed opener alone.

If you had a different vector in mind for this handler, please point me at it and I will add it to the harness.

Drafted with Claude Code, posted on my behalf.

Thanatos9404 added a commit to Thanatos9404/nltk that referenced this pull request Oct 18, 2026
…er branch

nltk#3109 and nltk#3167 are the two code defects in the download failures
cluster of nltk#3883, so they are reviewed and fixed together here.
Thanatos9404 added a commit to Thanatos9404/nltk that referenced this pull request Oct 18, 2026
This reverts commit 066afdc. The proxy changes are being taken out of this PR so that it only carries the downloader prompt fix; the proxy question stays with nltk#3970.
Thanatos9404 added a commit to Thanatos9404/nltk that referenced this pull request Oct 18, 2026
… a proxy"

This reverts commit 0af531e. The proxy changes are being taken out of this PR so that it only carries the downloader prompt fix; the proxy question stays with nltk#3970.
Thanatos9404 added a commit to Thanatos9404/nltk that referenced this pull request Oct 18, 2026
…er cluster branch"

This reverts merge commit f760989. The proxy changes are being taken out of this PR so that it only carries the downloader prompt fix; the proxy question stays with nltk#3970.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tunnel connection failed: 407 Proxy Authentification Required

2 participants