-
Notifications
You must be signed in to change notification settings - Fork 5.5k
tls: fix SslSocket read resumption after readDisable when processing the SSL record that contains the last bytes of the HTTP message #13234
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
Changes from 2 commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -121,12 +121,13 @@ Network::IoResult SslSocket::doRead(Buffer::Instance& read_buffer) { | |
| bool end_stream = false; | ||
| PostIoAction action = PostIoAction::KeepOpen; | ||
| uint64_t bytes_read = 0; | ||
| uint64_t reserve_size = 16384; | ||
| while (keep_reading) { | ||
| // We use 2 slices here so that we can use the remainder of an existing buffer chain element | ||
| // if there is extra space. 16K read is arbitrary and can be tuned later. | ||
| Buffer::RawSlice slices[2]; | ||
| uint64_t slices_to_commit = 0; | ||
| uint64_t num_slices = read_buffer.reserve(16384, slices, 2); | ||
| uint64_t num_slices = read_buffer.reserve(reserve_size, slices, 2); | ||
| for (uint64_t i = 0; i < num_slices; i++) { | ||
| auto result = sslReadIntoSlice(slices[i]); | ||
| if (result.commit_slice_) { | ||
|
|
@@ -157,8 +158,24 @@ Network::IoResult SslSocket::doRead(Buffer::Instance& read_buffer) { | |
| if (slices_to_commit > 0) { | ||
| read_buffer.commit(slices, slices_to_commit); | ||
| if (callbacks_->shouldDrainReadBuffer()) { | ||
| callbacks_->setReadBufferReady(); | ||
| keep_reading = false; | ||
| // Verify that SSL_get_read_ahead is disabled. SSL_pending does not provide an accurate | ||
| // answer when read-ahead is enabled. As far as we can tell BoringSSL does not implement | ||
| // read-ahead, so the odds of us ever failing this sanity check are effectively zero. | ||
| ASSERT(!SSL_get_read_ahead(rawSsl())); | ||
|
|
||
| // Query the SSL implementation for the number of bytes available for immediate read from | ||
| // internal buffers, and do one last read iteration if bytes are available. This is | ||
| // important to ensure read resumption works correctly after calls to | ||
| // Network::Connection::readDisable(). | ||
| int pending_bytes = SSL_pending(rawSsl()); | ||
| ASSERT(pending_bytes < 16 * 1024, "SSL record should be at most 16KB"); | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Should this be <= ?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The record size is at most 16KB, but at least 1 byte from the record must have been consumed by an earlier SSL_read, so SSL_pending can return at most 16KB-1
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. OK makes sense. Can you add a small comment and adjust the assert error to s/at most/less than? |
||
| if (pending_bytes > 0) { | ||
| ASSERT(keep_reading); | ||
| reserve_size = pending_bytes; | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. So I get the need to drain from the ssl buffer. What I'm less sure of is if we explicitly set pending_bytes to the bytes already buffered, if we're guaranteed that SSL_read won't read more from transport, but will only consume the current record which is already buffered. I was think of paging boringssl devs for that, but I think it'd be better to just regression test the behavior. Is it possible to have a test where we send [lots of data][evenmoredata]
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. (NB: I don't know anything about how Envoy handles I/O.) This is sufficient of an edge case on the abstraction that testing things seems worthwhile (for better or worse, OpenSSL's SSL API is supposed to look vaguely like a UNIX TCP socket which abstracts over most buffer handling). But Though I think the intention of the API (especially if, like OpenSSL but unlike BoringSSL, you believe in renegotiation) is that callers not make as strong of assumptions about the correlation between SSL reads and transport reads. If there's a renegotiation (again, not applicable for BoringSSL), SSL_read may be blocked on the handshake, which may be blocked on transport write or some random callback. A more general implementation (closer to what Chromium does) might look something like: RunReadStateMachine()
This doesn't depend on the state of the buffers, and it's always safe to call RunReadStateMachine(). However, one difference is RunReadStateMachine() will not pass data to an overbuffered application, even if the data is secretly available in SSL_read already. I think this is fine and conceptually simpler, but Antonio mentioned to me the intent was to pass that data along if available?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. alyssawilk: SslReadBufferLimitTest.DrainToSslRecordBoundary attempts to cover the case you describe by doing 10 10kb writes and verifying that the calls to doRead on the server connection with a configured high-watermark of 16KB deliver the data in 20KB chunks. I reached out to davidben about to help confirm my understanding of the current implementation and how it's likely to evolve going forward since the fix implemented here ends up depending on some internal details how boringssl deals with partial reads. Seems like the assumptions I'm making are fairly reasonable, but we may want to also think about implications they have on the transport socket contract and how likely it is for other transport socket implementations to fall on similar traps. There is one other possible way to fix resumption in this case: explicitly schedule read resumption in cases where the read buffer is empty. A benefit of this alternate approach is that imposes less strict requirements on transport socket implementations but it does involve some possibly spurious synthetic fd readable events. I have an old draft PR that optimizes away fd re-registrations on readDisable transitions which seems to fix this resumption issue, but its blocked on performance data and debugging of some test failures: master...antoniovicente:optimize_read_disable
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think I have a preference for @davidben solution as it avoid assuming something about internal behavior that may change. What if we add a method to the transport socket to tell us if the has some buffered bytes, in this case you can avoid useless read events.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @yanavlasov So it would be better to do something similar to this alternate solution? master...antoniovicente:ssl_read_resumption_alternate The issue with a state machine that specific to SSL handling is that it doesn't generalize well to other transport socket implementations. I think the more eager event scheduling proposed in my alternate fix is the closest we can get to davidben's suggestions.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm mildly inclined to avoid wake-ups every time. If we don't think we can do a clean and cheap "is there buffered data to process" check I'd lean towards the original solution with extensive testing in case boring or openssl ever change. I'd definitely like alternate opinions here on which of the ugly options are the least ugly. cc @mattklein123 @snowp @ggreenway for thoughts
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I think this is related to the my question above. I'm confused why we need this in the first place. If the old impl set read to ready, why wouldn't the data be sucked out the next time read is ready?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. @mattklein123 In the normal case the current resumption logic works correctly. The problem is when we factor in calls to readDisable(true) + readDisable(false) done as we trigger and untrigger the buffer high watermark Calls to readDisable unregister and re-register the fd for events. When fds are unregistered, any pending synthetic events on the fd are cleared, so the synthetic read event scheduled via setReadBufferReady is never delivered. Resumption after readDisable(false) depends on either there being some bytes in the user-space read buffer or some bytes in the kernel's socket receive buffer. When handling SSL traffic it is possible to have readable bytes in SSL internal buffers while there are 0 bytes in the stream read buffer and 0 bytes in the kernel buffer; when that happens fd re-registration does not lead to a wakeup despite the transport socket being able to produce bytes.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Thanks this makes sense. At minimum, can you add more comments? Another implementation idea: what if we had a transport socket specific way of asking "are there more bytes?" This could be overridden for TLS to look for kernel/user/within SSL. Then on readEnable() the check would pass and we would read again?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. See #13772 instead. Your comment inspired me to think a bit more about cases where the resumption event is lost due to calls to readDisable. The connection impl can detect and remember when that happens and schedule resumption when needed. |
||
| } else { | ||
| callbacks_->setReadBufferReady(); | ||
| keep_reading = false; | ||
| } | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -19,6 +19,7 @@ | |
| #include "extensions/transport_sockets/tls/context_config_impl.h" | ||
| #include "extensions/transport_sockets/tls/context_manager_impl.h" | ||
|
|
||
| #include "test/integration/autonomous_upstream.h" | ||
| #include "test/integration/integration.h" | ||
| #include "test/integration/utility.h" | ||
| #include "test/test_common/network_utility.h" | ||
|
|
@@ -177,6 +178,103 @@ TEST_P(SslIntegrationTest, AdminCertEndpoint) { | |
| EXPECT_EQ("200", response->headers().getStatusValue()); | ||
| } | ||
|
|
||
| class RawWriteSslIntegrationTest : public SslIntegrationTest { | ||
| protected: | ||
| std::unique_ptr<Http::TestRequestHeaderMapImpl> | ||
| testFragmentedRequestWithBufferLimit(std::list<std::string> request_chunks, | ||
| uint32_t buffer_limit) { | ||
| autonomous_upstream_ = true; | ||
| config_helper_.setBufferLimits(buffer_limit, buffer_limit); | ||
| initialize(); | ||
|
|
||
| // write_request_cb will write each of the items in request_chunks as a separate SSL_write. | ||
| auto write_request_cb = [&request_chunks](Network::ClientConnection& client) { | ||
| if (!request_chunks.empty()) { | ||
| Buffer::OwnedImpl buffer(request_chunks.front()); | ||
| client.write(buffer, false); | ||
| request_chunks.pop_front(); | ||
| } | ||
| }; | ||
|
|
||
| auto client_transport_socket_factory_ptr = | ||
| createClientSslTransportSocketFactory({}, *context_manager_, *api_); | ||
| std::string response; | ||
| auto connection = createConnectionDriver( | ||
| lookupPort("http"), write_request_cb, | ||
| [&](Network::ClientConnection&, const Buffer::Instance& data) -> void { | ||
| response.append(data.toString()); | ||
| }, | ||
| client_transport_socket_factory_ptr->createTransportSocket({})); | ||
|
|
||
| // Drive the connection until we get a response. | ||
| while (response.empty()) { | ||
| connection->run(Event::Dispatcher::RunType::NonBlock); | ||
| } | ||
| EXPECT_THAT(response, testing::HasSubstr("HTTP/1.1 200 OK\r\n")); | ||
|
|
||
| connection->close(); | ||
| return reinterpret_cast<AutonomousUpstream*>(fake_upstreams_.front().get()) | ||
| ->lastRequestHeaders(); | ||
| } | ||
| }; | ||
|
|
||
| INSTANTIATE_TEST_SUITE_P(IpVersions, RawWriteSslIntegrationTest, | ||
| testing::ValuesIn(TestEnvironment::getIpVersionsForTest()), | ||
| TestUtility::ipTestParamsToString); | ||
|
|
||
| // Regression test for https://github.com/envoyproxy/envoy/issues/12304 | ||
| TEST_P(RawWriteSslIntegrationTest, HighWatermarkReadResumptionProcessingHeaders) { | ||
| // The raw writer will perform a separate SSL_write for each of the chunks below. Chunk sizes were | ||
| // picked such that the connection's high watermark will trigger while processing the last SSL | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Is there a way to make sure this remains true even if we changed the default buffer limits / read limits? I think probably the unit test would do it.
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The test SslReadBufferLimitTest.DrainToSslRecordBoundary further down ensures that when processing a stream of 10KB SSL records with a read limit of 16KB we end up reading 2 full records per read operation (e.i. 20KB) That was one of the better unit test ideas that I could come up with. Another potentially good unit test would be to create a full server connection with SSL transport and verify behavior when readDisable(true) + readDisable(false) is called after each of the read wakeups, and that the drain operation runs to completion over a sequence of wakeups. I'll try to get that implemented. |
||
| // record containing the request headers. Verify that read resumption works correctly after | ||
| // hitting the receive buffer high watermark. | ||
| std::list<std::string> request_chunks = { | ||
| "GET / HTTP/1.1\r\nHost: host\r\n", | ||
| "key1:" + std::string(14000, 'a') + "\r\n", | ||
| "key2:" + std::string(16000, 'b') + "\r\n\r\n", | ||
| }; | ||
|
|
||
| std::unique_ptr<Http::TestRequestHeaderMapImpl> upstream_headers = | ||
| testFragmentedRequestWithBufferLimit(request_chunks, 15 * 1024); | ||
| ASSERT_TRUE(upstream_headers != nullptr); | ||
| EXPECT_EQ(upstream_headers->Host()->value(), "host"); | ||
| EXPECT_EQ(std::string(14000, 'a'), | ||
| upstream_headers->get(Envoy::Http::LowerCaseString("key1"))->value().getStringView()); | ||
| EXPECT_EQ(std::string(16000, 'b'), | ||
| upstream_headers->get(Envoy::Http::LowerCaseString("key2"))->value().getStringView()); | ||
| } | ||
|
|
||
| // Regression test for https://github.com/envoyproxy/envoy/issues/12304 | ||
| TEST_P(RawWriteSslIntegrationTest, HighWatermarkReadResumptionProcesingBody) { | ||
| // The raw writer will perform a separate SSL_write for each of the chunks below. Chunk sizes were | ||
| // picked such that the connection's high watermark will trigger while processing the last SSL | ||
| // record containing the POST body. Verify that read resumption works correctly after hitting the | ||
| // receive buffer high watermark. | ||
| std::list<std::string> request_chunks = { | ||
| "POST / HTTP/1.1\r\nHost: host\r\ncontent-length: 30000\r\n\r\n", | ||
| std::string(14000, 'a'), | ||
| std::string(16000, 'a'), | ||
| }; | ||
|
|
||
| std::unique_ptr<Http::TestRequestHeaderMapImpl> upstream_headers = | ||
| testFragmentedRequestWithBufferLimit(request_chunks, 15 * 1024); | ||
| ASSERT_TRUE(upstream_headers != nullptr); | ||
| } | ||
|
|
||
| // Regression test for https://github.com/envoyproxy/envoy/issues/12304 | ||
| TEST_P(RawWriteSslIntegrationTest, HighWatermarkReadResumptionProcesingLargerBody) { | ||
| std::list<std::string> request_chunks = { | ||
| "POST / HTTP/1.1\r\nHost: host\r\ncontent-length: 150000\r\n\r\n", | ||
| }; | ||
| for (int i = 0; i < 10; ++i) { | ||
| request_chunks.push_back(std::string(15000, 'a')); | ||
| } | ||
|
|
||
| std::unique_ptr<Http::TestRequestHeaderMapImpl> upstream_headers = | ||
| testFragmentedRequestWithBufferLimit(request_chunks, 16 * 1024); | ||
| ASSERT_TRUE(upstream_headers != nullptr); | ||
| } | ||
|
|
||
| // Validate certificate selection across different certificate types and client TLS versions. | ||
| class SslCertficateIntegrationTest | ||
| : public testing::TestWithParam< | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Do you mind adding a bit more color here on the control flow that requires this? Naively I would assume that in the previous logic we would signal to read again later, then we would attempt to read again, and get the data?