From 6e3cb27299c24e46fe2028f311668e399cfbb0dc Mon Sep 17 00:00:00 2001 From: okxint Date: Thu, 17 Sep 2026 15:07:24 +0530 Subject: [PATCH] fix(proxy): percent-decode username and password in parse_proxy() urllib.parse.urlparse() returns the raw (percent-encoded) userinfo components rather than decoded strings. parse_proxy() was passing those raw values straight through to Proxy.username / Proxy.password, so a credential such as p%40ss was sent to the proxy server instead of the intended p@ss. Fix by applying urllib.parse.unquote() to both components immediately after urlparse(). This matches the behaviour of python-socks (_helpers.parse_proxy_url) and requests, which both unquote proxy userinfo before using it. Also remove the urllib.parse.quote() call in the IRI branch: Proxy should always store plain (decoded) credentials; encoding them back was incorrect because build_authorization_basic() and python-socks both expect decoded strings. Tests: add two percent-encoded cases to PROXIES_WITH_USER_INFO and update the existing non-ASCII IRI case to expect decoded credentials. Fixes #1761 --- src/websockets/proxy.py | 13 +++++++------ tests/test_proxy.py | 8 +++++++- 2 files changed, 14 insertions(+), 7 deletions(-) diff --git a/src/websockets/proxy.py b/src/websockets/proxy.py index a343b37bc..0c16e5c88 100644 --- a/src/websockets/proxy.py +++ b/src/websockets/proxy.py @@ -74,8 +74,13 @@ def parse_proxy(proxy: str) -> Proxy: scheme = parsed.scheme host = parsed.hostname port = parsed.port or (443 if parsed.scheme == "https" else 80) - username = parsed.username - password = parsed.password + # urllib.parse.urlparse() returns the raw (percent-encoded) userinfo + # components. Decode them so that credentials with reserved characters + # such as "@", ":", "/", or "%" can be used. This matches the behaviour + # of python-socks and requests, which both unquote proxy userinfo. + # See https://github.com/aaugustin/websockets/issues/1761 + username = urllib.parse.unquote(parsed.username) if parsed.username is not None else None + password = urllib.parse.unquote(parsed.password) if parsed.password is not None else None # urllib.parse.urlparse accepts URLs with a username but without a # password. This doesn't make sense for HTTP Basic Auth credentials. if username is not None and password is None: @@ -87,10 +92,6 @@ def parse_proxy(proxy: str) -> Proxy: # Input contains non-ASCII characters. # It must be an IRI. Convert it to a URI. host = host.encode("idna").decode() - if username is not None: - assert password is not None - username = urllib.parse.quote(username, safe=DELIMS) - password = urllib.parse.quote(password, safe=DELIMS) return Proxy(scheme, host, port, username, password) diff --git a/tests/test_proxy.py b/tests/test_proxy.py index e0d12898e..aced3d46c 100644 --- a/tests/test_proxy.py +++ b/tests/test_proxy.py @@ -57,7 +57,13 @@ PROXIES_WITH_USER_INFO = [ ("http://proxy", None), ("http://user:pass@proxy", ("user", "pass")), - ("http://üser:påss@høst", ("%C3%BCser", "p%C3%A5ss")), + # Percent-encoded reserved characters are decoded so the plain credential + # is available for HTTP Basic Auth and SOCKS authentication. + # See https://github.com/aaugustin/websockets/issues/1761 + ("socks5://alice:p%40ss@proxy", ("alice", "p@ss")), + ("socks5://alice%3Abob:secret%25word@proxy", ("alice:bob", "secret%word")), + # Non-ASCII IRIs: credentials contain decoded Unicode characters. + ("http://üser:påss@høst", ("üser", "påss")), ] PROXY_ENVS = [