extmod/modtls_mbedtls: Close transport on handshake failure by lukaskremla · Pull Request #19630 · micropython/micropython · GitHub
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

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

@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
projectgus

This comment was marked as outdated.

projectgus

This comment was marked as duplicate.

@projectgus
projectgus dismissed their stale review September 3, 2026 01:46

Oops, had missed the CI failures and discussion of the regression.

@lukaskremla

Copy link
Copy Markdown
Author

@projectgus yeah.. :/

I'm unsure what is appropriate for micropython. In order for the correct error to reach the peer, I'm unsure if there is any different approach other than delaying the clean up someway - which would mean that socket exhaustion can still happen while waiting.

Maybe I am missing something, but I haven't been able to think up a solution that avoids this peer-received error regression and the socket exhaustion the current upstream has.

In the project where we encountered the bug, we decided to go with this fix accepting this regression, since the socket exhaustion was actively biting us and making SSL unworkable, whereas wrong errors were simply mildly annoying in comparison. I'm in no position to say which is better for the main micropython repository though.

@projectgus

projectgus commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

I had a look at the root cause, and it seems like it might be an mbedTLS bug when performing the handshake asynchronously. Here's the packet capture from one of my tests:

image
  • 16 - client sends certificate
  • 17 - server sends fatal alert
  • 18 - server sends TCP FIN
  • 19 - client sends a client key exchange message
  • 20 - server responds with RST as the socket is closed

From the mbedTLS side, this looks like the client's call to mbedtls_ssl_write() failing with 32 EPIPE (packets 19 & 20 above). If mbedTLS was to read from the socket at this point then it would receive the pending alert sent by the server step 17, but MicroPython doesn't call mbedtls_ssl_read() and instead raises the EPIPE back as an error.

If I patch it to call mbedtls_ssl_read() before raising EPIPE then this also doesn't work because this calls through to ssl_prepare_handshake_step() which calls mbedtls_ssl_flush_output() which (I think) tries to re-send the same unsent data from step 19, and this fails with EPIPE again...

So that kind of feels like an mbedTLS bug, that it's possible for it to swallow an alert received during the handshake because the handshake step logic (which is internal to mbedTLS) keeps trying to write to the closed socket instead of reading the alert from it.

We're currently on mbedTLS v3.6.6, but in this future if we update to mbedTLS v4.x and this problem still appears then I might try raising it as an issue upstream.

@lukaskremla

Copy link
Copy Markdown
Author

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.

3 participants