From e7d7b78f5db4be52b65f066f875697c7de83af2d Mon Sep 17 00:00:00 2001 From: waheeda bette Date: Sun, 18 Oct 2026 13:22:09 +0530 Subject: [PATCH] Support non-ASCII passwords in basic_auth. hmac.compare_digest raises TypeError on str containing non-ASCII characters, so a correct non-ASCII password was answered with HTTP 500 instead of authenticating. Compare UTF-8 bytes instead. Also reject credentials that aren't valid UTF-8 with HTTP 401 instead of crashing on UnicodeDecodeError. --- src/websockets/asyncio/server.py | 2 +- src/websockets/headers.py | 2 +- src/websockets/legacy/auth.py | 2 +- src/websockets/sync/server.py | 2 +- tests/asyncio/test_server.py | 29 +++++++++++++++++++++++++++++ tests/sync/test_server.py | 27 +++++++++++++++++++++++++++ tests/test_headers.py | 2 ++ 7 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/websockets/asyncio/server.py b/src/websockets/asyncio/server.py index 18b37456e..98328902a 100644 --- a/src/websockets/asyncio/server.py +++ b/src/websockets/asyncio/server.py @@ -870,7 +870,7 @@ def check_credentials(username: str, password: str) -> bool: expected_password = credentials_dict[username] except KeyError: return False - return hmac.compare_digest(expected_password, password) + return hmac.compare_digest(expected_password.encode(), password.encode()) assert check_credentials is not None # help mypy diff --git a/src/websockets/headers.py b/src/websockets/headers.py index e05ff5b4c..8c1a0a03b 100644 --- a/src/websockets/headers.py +++ b/src/websockets/headers.py @@ -556,7 +556,7 @@ def parse_authorization_basic(header: str) -> tuple[str, str]: try: user_pass = base64.b64decode(basic_credentials.encode()).decode() - except binascii.Error: + except (binascii.Error, UnicodeDecodeError): raise InvalidHeaderValue( "Authorization", "expected base64-encoded credentials", diff --git a/src/websockets/legacy/auth.py b/src/websockets/legacy/auth.py index a262fcd79..084c5baa6 100644 --- a/src/websockets/legacy/auth.py +++ b/src/websockets/legacy/auth.py @@ -173,7 +173,7 @@ async def check_credentials(username: str, password: str) -> bool: expected_password = credentials_dict[username] except KeyError: return False - return hmac.compare_digest(expected_password, password) + return hmac.compare_digest(expected_password.encode(), password.encode()) if create_protocol is None: create_protocol = BasicAuthWebSocketServerProtocol diff --git a/src/websockets/sync/server.py b/src/websockets/sync/server.py index a8affce95..771bbd19c 100644 --- a/src/websockets/sync/server.py +++ b/src/websockets/sync/server.py @@ -893,7 +893,7 @@ def check_credentials(username: str, password: str) -> bool: expected_password = credentials_dict[username] except KeyError: return False - return hmac.compare_digest(expected_password, password) + return hmac.compare_digest(expected_password.encode(), password.encode()) assert check_credentials is not None # help mypy diff --git a/tests/asyncio/test_server.py b/tests/asyncio/test_server.py index 87e66f64e..743636d1e 100644 --- a/tests/asyncio/test_server.py +++ b/tests/asyncio/test_server.py @@ -905,6 +905,35 @@ async def test_authorization_with_incorrect_password(self): "server rejected WebSocket connection: HTTP 401", ) + async def test_authorization_with_non_ascii_password(self): + """basic_auth authenticates client with a non-ASCII password.""" + async with serve( + *args, + process_request=basic_auth(credentials=("test", "123£")), + ) as server: + async with connect( + get_uri(server), + additional_headers={"Authorization": "Basic dGVzdDoxMjPCow=="}, + ) as client: + await self.assertEval(client, "ws.username", "test") + + async def test_authorization_with_incorrect_non_ascii_password(self): + """basic_auth rejects client with incorrect non-ASCII password.""" + async with serve( + *args, + process_request=basic_auth(credentials=("test", "iloveyou")), + ) as server: + with self.assertRaises(InvalidStatus) as raised: + async with connect( + get_uri(server), + additional_headers={"Authorization": "Basic dGVzdDoxMjPCow=="}, + ): + self.fail("did not raise") + self.assertEqual( + str(raised.exception), + "server rejected WebSocket connection: HTTP 401", + ) + async def test_list_of_credentials(self): """basic_auth accepts a list of hard coded credentials.""" async with serve( diff --git a/tests/sync/test_server.py b/tests/sync/test_server.py index 59920a3f2..0f60b19e0 100644 --- a/tests/sync/test_server.py +++ b/tests/sync/test_server.py @@ -685,6 +685,33 @@ def test_authorization_with_incorrect_password(self): "server rejected WebSocket connection: HTTP 401", ) + def test_authorization_with_non_ascii_password(self): + """basic_auth authenticates client with a non-ASCII password.""" + with run_server( + process_request=basic_auth(credentials=("test", "123£")), + ) as server: + with connect( + get_uri(server), + additional_headers={"Authorization": "Basic dGVzdDoxMjPCow=="}, + ) as client: + self.assertEval(client, "ws.username", "test") + + def test_authorization_with_incorrect_non_ascii_password(self): + """basic_auth rejects client with incorrect non-ASCII password.""" + with run_server( + process_request=basic_auth(credentials=("test", "iloveyou")), + ) as server: + with self.assertRaises(InvalidStatus) as raised: + with connect( + get_uri(server), + additional_headers={"Authorization": "Basic dGVzdDoxMjPCow=="}, + ): + self.fail("did not raise") + self.assertEqual( + str(raised.exception), + "server rejected WebSocket connection: HTTP 401", + ) + def test_list_of_credentials(self): """basic_auth accepts a list of hard coded credentials.""" with run_server( diff --git a/tests/test_headers.py b/tests/test_headers.py index 816afc541..d18665f5d 100644 --- a/tests/test_headers.py +++ b/tests/test_headers.py @@ -223,6 +223,8 @@ def test_parse_authorization_basic_invalid_header_value(self): "Digest ...", "Basic QWxhZGRpbjpvcGVuIHNlc2FtZQ", "Basic QWxhZGNlc2FtZQ==", + # Credentials aren't valid UTF-8 + "Basic dGVzdDr/", ]: with self.subTest(header=header): with self.assertRaises(InvalidHeaderValue):