Skip to content

Commit 6dbfa0b

Browse files
committed
Re-raise SSLErrors produced by TLS alerts as HandshakeErrors.
This commit relies on the bugfix proposed in PR python-trio/trio#3413 to address issue #199. From the user's perspective, the issue was that a TLS client hanged after submitting an invalid (e.g. expired) client certificate. The trio bugfix makes sure that the server (in general, peer) sends out the TLS alert after catching a `ssl.CertificateError`. In this commit, we do two things. First, we intercept `ssl.SSLError`s created by the alerts when receiving data from the stream (`WebSocketConnection._reader_task`) and sending data to the stream (`WebSocketConnection._send`), and wrap them into a `HandshakeError`. Second, we catch and ignore these `HandshakeError`s in `WebSocketServer._handle_connection`, so that a certificate error does not crash the server. Here, I'm unsure whether we should ignore *all* SSL errors (currently, this is the case), or maybe only those generated by alerts, or only those having to do with certificates, and neither do I know how to compile a comprehensive list of the latter, if that is what should be done.
1 parent e828e54 commit 6dbfa0b

File tree

3 files changed

+130
-3
lines changed

3 files changed

+130
-3
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
# Release history
22

3+
## trio-websocket x.y.z
4+
### Fixed
5+
- fix the client hanging upon a certificate issue
6+
([#199](https://github.com/python-trio/trio-websocket/issues/199))
7+
38
## trio-websocket 0.12.2 (2025-02-24)
49
### Fixed
510
- fix incorrect port when using a `wss://` URL without supplying an explicit

tests/test_connection.py

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@
3232
from __future__ import annotations
3333

3434
import copy
35+
import datetime
3536
import re
3637
import ssl
3738
import sys
@@ -276,6 +277,108 @@ async def test_serve_ssl(nursery: trio.Nursery) -> None:
276277
assert conn.remote.is_ssl
277278

278279

280+
async def test_serve_ssl_wrong_ca(nursery: trio.Nursery) -> None:
281+
server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
282+
client_context = ssl.create_default_context()
283+
ca = trustme.CA()
284+
other_ca = trustme.CA()
285+
other_ca.configure_trust(client_context)
286+
cert = ca.issue_server_cert(HOST)
287+
cert.configure_cert(server_context)
288+
289+
server = await nursery.start(serve_websocket, echo_request_handler, HOST, 0,
290+
server_context)
291+
assert isinstance(server, WebSocketServer)
292+
port = server.port
293+
with trio.fail_after(0.1):
294+
with pytest.raises(HandshakeError) as excinfo:
295+
async with open_websocket(HOST, port, RESOURCE, use_ssl=client_context
296+
) as conn:
297+
assert not conn.closed
298+
assert isinstance(conn.local, Endpoint)
299+
assert conn.local.is_ssl
300+
assert isinstance(conn.remote, Endpoint)
301+
assert conn.remote.is_ssl
302+
assert isinstance(excinfo.value.__cause__, ssl.SSLError)
303+
assert excinfo.value.__cause__.reason == "CERTIFICATE_VERIFY_FAILED"
304+
305+
306+
async def test_ssl_client_cert(nursery: trio.Nursery) -> None:
307+
308+
ca = trustme.CA()
309+
310+
server_context = ssl.create_default_context(ssl.Purpose.CLIENT_AUTH)
311+
cert = ca.issue_server_cert(HOST)
312+
cert.configure_cert(server_context)
313+
server_context.verify_mode = ssl.CERT_REQUIRED
314+
ca.configure_trust(server_context)
315+
316+
# Setup a valid client certificate.
317+
good_client_context = ssl.create_default_context()
318+
ca.configure_trust(good_client_context)
319+
good_client_cert = ca.issue_cert("user@example.org")
320+
good_client_cert.configure_cert(good_client_context)
321+
322+
# Setup an expired client certificate.
323+
bad_client_context = ssl.create_default_context()
324+
ca.configure_trust(bad_client_context)
325+
bad_client_cert = ca.issue_cert(
326+
"user@example.org", not_after=datetime.datetime.now(datetime.UTC))
327+
bad_client_cert.configure_cert(bad_client_context)
328+
329+
# Use the timeout because the old SSL code made the client hang.
330+
with trio.fail_after(0.5):
331+
async with trio.open_nursery() as nurs:
332+
333+
server = await nurs.start(
334+
serve_websocket, echo_request_handler, HOST, 0, server_context)
335+
assert isinstance(server, WebSocketServer)
336+
port = server.port
337+
338+
# Test with the valid certificate.
339+
async with open_websocket(
340+
HOST, port, RESOURCE, use_ssl=good_client_context
341+
) as conn:
342+
assert not conn.closed
343+
assert isinstance(conn.local, Endpoint)
344+
assert conn.local.is_ssl
345+
assert isinstance(conn.remote, Endpoint)
346+
assert conn.remote.is_ssl
347+
await conn.send_message('foo')
348+
assert await conn.get_message() == 'foo'
349+
350+
# Test with the expired certificate.
351+
with pytest.raises(HandshakeError) as excinfo:
352+
async with open_websocket(
353+
HOST, port, RESOURCE, use_ssl=bad_client_context
354+
) as conn:
355+
assert not conn.closed
356+
assert isinstance(conn.local, Endpoint)
357+
assert conn.local.is_ssl
358+
assert isinstance(conn.remote, Endpoint)
359+
assert conn.remote.is_ssl
360+
assert isinstance(excinfo.value.__cause__, ssl.SSLError)
361+
assert excinfo.value.__cause__.reason == "SSLV3_ALERT_CERTIFICATE_EXPIRED"
362+
363+
# Test with the valid certificate again. If this does work now,
364+
# this means that the expired certificate crashed the server.
365+
try:
366+
async with open_websocket(
367+
HOST, port, RESOURCE, use_ssl=good_client_context
368+
) as conn:
369+
assert not conn.closed
370+
assert isinstance(conn.local, Endpoint)
371+
assert conn.local.is_ssl
372+
assert isinstance(conn.remote, Endpoint)
373+
assert conn.remote.is_ssl
374+
await conn.send_message('foo')
375+
assert await conn.get_message() == 'foo'
376+
except:
377+
raise RuntimeError("The server crashed in the first subtest") from None
378+
379+
nurs.cancel_scope.cancel()
380+
381+
279382
async def test_serve_handler_nursery(nursery: trio.Nursery) -> None:
280383
async with trio.open_nursery() as handler_nursery:
281384
serve_with_nursery = partial(serve_websocket, echo_request_handler,

trio_websocket/_impl.py

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1570,8 +1570,11 @@ async def _reader_task(self) -> None:
15701570
# Get network data.
15711571
try:
15721572
data = await self._stream.receive_some(self._receive_buffer_size)
1573-
except (trio.BrokenResourceError, trio.ClosedResourceError):
1573+
except (trio.BrokenResourceError, trio.ClosedResourceError) as exc:
15741574
await self._abort_web_socket()
1575+
# Wrap SSL errors into a HandshakeError.
1576+
if isinstance(exc.__cause__, ssl.SSLError):
1577+
raise HandshakeError() from exc.__cause__
15751578
break
15761579
if len(data) == 0:
15771580
logger.debug('%s received zero bytes (connection closed)',
@@ -1608,8 +1611,11 @@ async def _send(self, event: wsproto.events.Event) -> None:
16081611
logger.debug('%s sending %d bytes', self, len(data))
16091612
try:
16101613
await self._stream.send_all(data)
1611-
except (trio.BrokenResourceError, trio.ClosedResourceError):
1614+
except (trio.BrokenResourceError, trio.ClosedResourceError) as exc:
16121615
await self._abort_web_socket()
1616+
# Wrap SSL errors into a HandshakeError.
1617+
if isinstance(exc.__cause__, ssl.SSLError):
1618+
raise HandshakeError() from exc.__cause__
16131619
assert self._close_reason is not None
16141620
raise ConnectionClosed(self._close_reason) from None
16151621

@@ -1783,13 +1789,26 @@ async def _handle_connection(self, stream: trio.abc.Stream) -> None:
17831789
:param stream:
17841790
:type stream: trio.abc.Stream
17851791
'''
1792+
1793+
# Filter out "HandshakeError"s caused by "SSLError"s as we don't want a
1794+
# connection error to crash the server.
1795+
async def _reader_task():
1796+
try:
1797+
await connection._reader_task()
1798+
except* HandshakeError as excs:
1799+
non_ssl_errs = excs.subgroup(
1800+
lambda e: not isinstance(e, ExceptionGroup)
1801+
and not isinstance(e.__cause__, ssl.SSLError))
1802+
if non_ssl_errs:
1803+
raise non_ssl_errs
1804+
17861805
async with trio.open_nursery() as nursery:
17871806
connection = WebSocketConnection(stream,
17881807
WSConnection(ConnectionType.SERVER),
17891808
message_queue_size=self._message_queue_size,
17901809
max_message_size=self._max_message_size,
17911810
receive_buffer_size=self._receive_buffer_size)
1792-
nursery.start_soon(connection._reader_task)
1811+
nursery.start_soon(_reader_task)
17931812
with trio.move_on_after(self._connect_timeout) as connect_scope:
17941813
request = await connection._get_request()
17951814
if connect_scope.cancelled_caught:

0 commit comments

Comments
 (0)