Skip to content

extmod/modtls_mbedtls: Close transport on handshake failure - #19630

Open
lukaskremla wants to merge 2 commits into
micropython:masterfrom
lukaskremla:fix/mbedtls-close-transport
Open

extmod/modtls_mbedtls: Close transport on handshake failure#19630
lukaskremla wants to merge 2 commits into
micropython:masterfrom
lukaskremla:fix/mbedtls-close-transport

Conversation

@lukaskremla

Copy link
Copy Markdown

Summary

The existing MicroPython mbedtls implementation doesn't correctly close the underlying transport sockets on fatal SSL handshake errors that occur during read/write operations. It only deletes the reference to them, making future cleanup attempts no-ops.

This bug affects web server projects (Such as those leveraging Microdot) which target chromium-based browsers.

Unlike Firefox - when chromium encounters a self-signed certificate during the TLS handshake - It closes the connection before re-opening a new one even if the browser user explicitly accepted the warning prompt. This sub-optimal behavior is admitted in chromium sources as well (see the spoiler below).

Chrome's behavior combined with MicroPython's clean-up bug resulted in the device running out of free sockets to use, because all got stuck waiting for their time-outs, causing all requests to be dropped with ERR_CONNECTION_RESET errors. The server would become responsive once more after the underlying transports timed out and cleaned-up on their own.

Chromium isn't to blame for this bug - it merely triggers aggressively and makes it apparent.

This bug presumably affects all ports relying on mbedtls.

Chromium sources

https://chromium.googlesource.com/chromium/src/+/refs/heads/main/net/http/http_network_transaction.cc#943
screen2

Chrome's behavior was confirmed by NetLog in my debugging session, and also by further observations of the code in the source file shared above.

Since I believe this to be a self-contained bug even without my Microdot reproduction scripts, I have decided to omit them from this PR. I can send them should they be important.

Testing

I was able to replicate this bug on ESP32 S2 and ESP32 C3 boards using MicroPython version 1.28.0 through actual socket exhaustion. And on the Unix port as well using the updated ssl_poll.py coverage test.

The Microdot-utilizing project in which I discovered this bug no longer experiences the ERR_CONNECTION_RESET errors anymore after the fix either, but it did before the code changes included in this PR.

I have included an additional check in the ssl_poll.py that ensures the socket was closed exactly once. In the bug-affected builds, this test caught the problem. After testing the code in this PR the test passes cleanly.

NOTE:

I suspect the original ssl_poll.py had an unrelated bug:

def assert_raises(cb, *args, **kwargs):
    try:
        cb(*args, **kwargs)
        raise AssertionError("should have raised")
    except Exception as exc:
        pass

The AssertionError("should have raised") is raised in a way where it immediately gets caught and discarded. I assume the intent was to raise an AssertionError() only when cb() doesn't raise anything. So I changed this function to look like this:

def assert_raises(cb, *args, **kwargs):
    try:
        cb(*args, **kwargs)
    except Exception:
        return
    raise AssertionError("should have raised")

Returning plainly if the cb() function raises as expected, raising an AssertionError() if not. Please let me know if the intent behind the code was different than what I took it to be.

Trade-offs and Alternatives

I am not aware of any worthwhile alternative approach that wouldn't only be mitigating the symptoms of this bug - or moving the burden of cleaning up to the Python code (which feels like inappropriately making Python code responsible for C-level lifecycles).

Generative AI

I used generative AI tools when creating this PR, but a human has checked the
code and is responsible for the code and the description above.

This PR was created after a long ai-assisted debugging session examining why self-signed certificates caused odd ERR_CONNECTION_RESET failures in chromium-based browsers and not others (like Firefox).

The code included in it was manually adjusted by me and manually reviewed by me and my colleagues.

Fatal TLS handshake errors during read or write cleared SSLSocket.sock
without closing the underlying transport. Later close() calls therefore
became no-ops, which could leave native sockets allocated until their TCP
timeout expired.

Close the retained transport as part of fatal handshake cleanup and use
the same helper for ordinary closure.

Signed-off-by: Lukas Kremla <contact@lukaskremla.com>
Track MP_STREAM_CLOSE calls made to the fake transport. Verify that
ordinary closure and fatal handshake cleanup each close the transport
exactly once, and that subsequent close() calls do not close it again.

Also fix the assert_raises helper. It raised AssertionError inside its try
block and immediately caught it, so the AssertionError would never
surface.

Signed-off-by: Lukas Kremla <contact@lukaskremla.com>
@lukaskremla lukaskremla changed the title Fix/mbedtls close transport extmod/modtls_mbedtls: Close transport on handshake failure Aug 16, 2026
@github-actions

Copy link
Copy Markdown

Code size report:

Reference:  shared/runtime/pyexec: Don't execute empty line in event driven REPL. [2e3304a]
Comparison: tests/extmod/ssl_poll: Test TLS transport cleanup. [merge of 58aae4b]
  mpy-cross:    +0 +0.000% 
   bare-arm:    +0 +0.000% 
minimal x86:    +0 +0.000% 
   unix x64:  +144 +0.017% standard
      stm32:    +0 +0.000% PYBV10
      esp32:   +48 +0.003% ESP32_GENERIC
     mimxrt:    +0 +0.000% TEENSY40
        rp2:   +32 +0.003% RPI_PICO_W
       samd:    +0 +0.000% ADAFRUIT_ITSYBITSY_M4_EXPRESS
  qemu rv32:    +0 +0.000% VIRT_RV32

@lukaskremla

Copy link
Copy Markdown
Author

My understanding of the regression is that the new clean-up behavior closes the socket sooner than the peer/client can react to the error we attempt to flush before it.

The one trade-off/alternative I ruled out in the initial PR as a bad-practice trade off was removing the call that deletes the reference to the socket - but not cleaning up either. That makes MicroPython (and the application running on it, say Microdot) responsible for calling close() on the socket.

That would create a short delay between sending the error to the peer and tearing down the socket - which might avoid the regression. But it isn't a guaranteed fix, and if the processing is slowed down for any reason the same regression manifests. And I'm not sure that making Python code responsible for socket clean-up like this is appropriate - it's fragile and does what I deem to be the job of C-level code.

I'm unsure what the right solution from here is. The bug that would be fixed by the PR in its current state seems more severe to me than the regression it might introduce, but neither is ideal.

I think that the decision for the best course of action from this point is better fit for the core/active maintainers of this project. There also might be a better solution which avoids both problems that I don't see due to insufficient familiarity with this project.

@Josverl Josverl added the extmod Relates to extmod/ directory in source label Aug 16, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

extmod Relates to extmod/ directory in source

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants