Skip to content

Set SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER in calling OpenSSL #1287

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 45 commits into
base: main
Choose a base branch
from

Conversation

julianz-
Copy link
Contributor

See cherrypy/cheroot#245 for discussion.

@mhils
Copy link
Member

mhils commented Jan 25, 2024

See #1242 for more context.

Copy link
Member

@mhils mhils left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for pushing this further! 🍰 I think we had general agreement to do this in #1242, so this looks good to merge after some minor docs fixes. :)

We can ignore codecov, that seems to be a bug in determining coverage.

Copy link

@webknjaz webknjaz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Add some empty line separators under title marks.

@julianz- julianz- force-pushed the jan23-2024 branch 2 times, most recently from c2cae69 to 2e788b7 Compare January 26, 2024 23:48
@julianz- julianz- requested a review from webknjaz January 28, 2024 05:16
@julianz- julianz- marked this pull request as draft January 28, 2024 05:19
@julianz- julianz- marked this pull request as ready for review January 28, 2024 08:13
@julianz- julianz- marked this pull request as draft January 28, 2024 17:32
@julianz- julianz- force-pushed the jan23-2024 branch 2 times, most recently from 6c1d483 to 23e9e11 Compare January 28, 2024 19:07
@julianz- julianz- requested a review from webknjaz January 28, 2024 19:13
@julianz- julianz- marked this pull request as ready for review January 28, 2024 19:14
@webknjaz
Copy link

@mhils @alex it appears that https://app.codecov.io/gh/pyca/pyopenssl treats master as the default branch, which is why it may get confused and assign coverage drops to pull requests that have nothing to do with said lines.

@alex
Copy link
Member

alex commented Jan 28, 2024

I've changed the default branch to be main

@webknjaz
Copy link

Is there a smoke test we could include in this PR?

@julianz-
Copy link
Contributor Author

julianz- commented Jan 29, 2024

@webknjaz The only test for setting the mode currently is:

def test_set_mode(self):
        """
        `Context.set_mode` accepts a mode bitvector and returns the
        newly set mode.
        """
        context = Context(SSLv23_METHOD)
        assert MODE_RELEASE_BUFFERS & context.set_mode(MODE_RELEASE_BUFFERS)

This checks that setting the mode for MODE_RELEASE_BUFFERS returns the same bit. I guess we could add another check to make sure passing SSL_MODE_ACCEPT_MOVING_WRITE_BUFFER also returns the appropriate value? Not sure whether that counts as a smoke test but it's something perhaps?

@webknjaz
Copy link

Maybe, find some existing test where a write buffer is passed, copy it and pass a moving buffer there?

@mhils ideas?

@mhils
Copy link
Member

mhils commented Jan 29, 2024

We could possibly adapt something like this: https://github.com/pyca/pyopenssl/blob/main/tests/test_ssl.py#L2837

If that's easy to add I'm all for it, but I also feel that not having an elaborate test here is not the end of the world. There's precedent (SSL_MODE_ENABLE_PARTIAL_WRITE has no test either) and, more importantly, if this fails in the future we should get a very explicit 'bad write retry' error. Does CPython have a dedicated test for this?

This looks good to merge otherwise. @alex @reaperhulk, if you want to make a judgement call here please just merge. :)

@julianz-
Copy link
Contributor Author

@mhils Strangely, when I try running pytest locally on my branch I am getting an exception on that test that makes it fail the test:

E       OpenSSL.SSL.Error: [('system library', '', ''), ('system library', '', ''), ('system library', '', ''), ('system library', '', ''), ('SSL routines', '', 'certificate verify failed')]

../../../../Library/Python/3.9/lib/python/site-packages/OpenSSL/_util.py:57: Error
____________________________________________________________________ TestConnection.test_wantWriteError _____________________________________________________________________

That's just a fragment of the output so maybe not very meaningful but as I understand it, the test is meant to throw WantWriteError but for some reason although it's expected the exception is not being considered a success? How is this supposed to work?

@webknjaz
Copy link

although it's expected the exception is not being considered a success?

In your log, a more generic exception happens (OpenSSL.SSL.Error), not OpenSSL.SSL.WantWriteError. This is why pytest.raises() doesn't match it as expected.

@julianz-
Copy link
Contributor Author

julianz- commented Feb 2, 2024

@webknjaz

In your log, a more generic exception happens (OpenSSL.SSL.Error), not OpenSSL.SSL.WantWriteError. This is why pytest.raises() doesn't match it as expected.

Ok to make any progress on this, I'm trying to understand the first test that is failing when I run pytest locally - test_set_default_verify_paths() in test_ssl.py. It's basically saying "certificate verify failed". I tried debugging this using the following in the command line:

openssl s_client -connect "encrypted.google.com":443

and get:

Verify return code: 18 (self signed certificate)

I assume this generates an error because the return code is not 0. But how is this supposed to work? The test relies on Google's certificate but there seems to be problem with the cert?

@julianz-
Copy link
Contributor Author

@alex @webknjaz @mhils
Just checking on review status? I just recently updated this PR quite extensively.

@alex
Copy link
Member

alex commented Jul 24, 2025

I am to get to it this weekend.

Copy link
Member

@alex alex left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm unclear why these tests are so complicated. As I understand the point of this flag, it's to allow passing a buffer of the same contents, but at a different address, which is needed when a buffer is filled up.

We have a test, test_wantWriteError that shows filling up the buffer, why is it not sufficient to have that logic, then drain some of the buffer on the reader side, then perform another write?

Comment on lines 449 to 463
key = PKey()
key.generate_key(TYPE_RSA, 2048)
cert = X509()
cert.set_version(2)
cert.get_subject().C = b"US"
cert.get_subject().ST = b"California"
cert.get_subject().L = b"Palo Alto"
cert.get_subject().O = b"pyOpenSSL"
cert.get_subject().CN = b"localhost"
cert.set_serial_number(1)
cert.gmtime_adj_notBefore(0)
cert.gmtime_adj_notAfter(60 * 60)
cert.set_issuer(cert.get_subject())
cert.set_pubkey(key)
cert.sign(key, "sha1")
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We already have utilities for generating certificates, this should use those.

In fact, we have utilities for generating connection pairs. Those should be extended, rather than duplicated.

@julianz-
Copy link
Contributor Author

I'm unclear why these tests are so complicated. As I understand the point of this flag, it's to allow passing a buffer of the same contents, but at a different address, which is needed when a buffer is filled up.

We have a test, test_wantWriteError that shows filling up the buffer, why is it not sufficient to have that logic, then drain some of the buffer on the reader side, then perform another write?

test_wantWriteError is a very superficial test compared with the tests I added for the moving buffer because it only generates a WantWriteError during the handshaking phase. Generating a WantWriteError in this way is almost trivial compared with reliably generating a "bad write retry" error. A crucial element of the new tests is creating a full SSL socket connection with access to both the server and client and filling up the buffers at both ends. Once this is done, it's possible to send a message that partially succeeds but not completely so that it generates a WantWriteError and then expects a retry. test_wantWriteError won't do this at all. I needed to have handshaking completed and then application data to be sent.

I had to spend a lot of time figuring out how to trigger bad write retries reliably and I recall seeing other comments that other people had tried and not succeeded. Some of the complication comes in probing how much application data can be sent before a WantWriteError occurs. If you simply send a huge buffer then you can easily get repeated WantWriteErrors but no bad retries. The buffer has to be large enough to trigger the first WantWriteError but small enough that you don't immediately get another WantWriteError after resending the same sized buffer (hence also the need to drain the buffers after the first WantWriteError so the second try has a chance of success). Finding the right size buffer depends on the environment so I had to make my code adaptive.

Regarding your comments about creating the socket connection, I have slightly revised create_ssl_nonblocking_connection() to reuse an existing _create_certificate_chain() method rather than creating the certs from scratch.

Incidentally, test_wantWriteError although it passes the CI on Github fails on my Mac usually generating a WantReadError rather than WantWriteError. I was able to fix the test to work on my Mac with a simple one line addition which still passed the CI but have not included the fix on this PR as this is a separate issue. However, this kind of error speaks to the difficulty and sensitivity of testing problems that arise from these kind of race conditions.

@julianz- julianz- requested a review from alex July 27, 2025 22:33
if e.errno == EWOULDBLOCK:
print("Client socket buffer filled (EWOULDBLOCK hit).")
return # Buffer successfully filled, exit function
raise # pragma: no cover # Re-raise unexpected OSErrors
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is never hit, it's better to just have assert e.errno == EWOULDBLOCK. That way we have clear coverage and a clear assertion.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok sure wlll replace.

@julianz- julianz- requested a review from alex August 4, 2025 02:41
)
initial_want_write_triggered = True
break # Exit loop as desired error was triggered
except Exception as e: # pragma: no cover
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, we strongly avoid no cover pragmas. You can simply omit this handling, if some other exception is raised, you can just let it propagate.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good to know. Have revised to remove all but one of my no cover pragmas. The one remaining in _shutdown_connections() is forgivable I think but see what you think.

Comment on lines 3131 to 3133
if not initial_want_write_triggered:
pytest.fail("Could not induce WantWriteError") # pragma: no cover

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Similarly, this can simply be assert initial_want_write_triggered

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done

@julianz- julianz- requested a review from alex August 7, 2025 22:47
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Development

Successfully merging this pull request may close these issues.

5 participants