From f40137a0ccc87676e6b78e511141988f06f51772 Mon Sep 17 00:00:00 2001 From: David Legrand Date: Sun, 26 Apr 2026 23:11:06 +0200 Subject: [PATCH] net.http: fix HEAD/1xx/204/304 responses hanging or erroring on Content-Length (#27000) --- .gitignore | 2 + vlib/net/http/head_with_content_length_test.v | 85 +++++++++++++++++++ vlib/net/http/request.v | 36 +++++--- .../mbedtls_head_with_content_length_test.v | 77 +++++++++++++++++ 4 files changed, 190 insertions(+), 10 deletions(-) create mode 100644 vlib/net/http/head_with_content_length_test.v create mode 100644 vlib/net/mbedtls/mbedtls_head_with_content_length_test.v diff --git a/.gitignore b/.gitignore index d98758094..0fdcdd5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ !vlib/v/checker/tests/modules/c_type_cast_imported_from_module/** !vlib/v/checker/tests/modules/c_type_cast_imported_from_module.out !vlib/veb/tests/catchall_route_order_regression_test.v +!vlib/net/http/head_with_content_length_test.v +!vlib/net/mbedtls/mbedtls_head_with_content_length_test.v !vlib/v/checker/tests/modules/imported_struct_field_with_any_type_err/** !vlib/v/checker/tests/modules/imported_struct_field_with_any_type_err.out diff --git a/vlib/net/http/head_with_content_length_test.v b/vlib/net/http/head_with_content_length_test.v new file mode 100644 index 000000000..72f1359af --- /dev/null +++ b/vlib/net/http/head_with_content_length_test.v @@ -0,0 +1,85 @@ +module http + +import net + +// These tests cover RFC 7230 §3.3.3 / RFC 9112 §6.2: HEAD responses, and +// 1xx/204/304 status responses, must not carry a body. Before the fix, the +// receive loop in `receive_all_data_from_cb_in_builder` waited for +// `Content-Length` body bytes that the server never sends, then surfaced +// either `response body ended early` (when the connection closed) or a +// read timeout (when the server kept the socket open). + +const head_resp = 'HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 1234\r\nConnection: close\r\n\r\n' + +const status_204_resp = 'HTTP/1.1 204 No Content\r\nContent-Length: 5\r\nConnection: close\r\n\r\n' + +const status_304_resp = 'HTTP/1.1 304 Not Modified\r\nContent-Length: 100\r\nConnection: close\r\n\r\n' + +fn serve_once(mut listener net.TcpListener, response string) { + mut conn := listener.accept() or { return } + defer { + conn.close() or {} + } + mut buf := []u8{len: 4096} + for { + n := conn.read(mut buf) or { return } + if n <= 0 { + return + } + if buf[..n].bytestr().contains('\r\n\r\n') { + break + } + } + conn.write(response.bytes()) or {} +} + +fn start_server(response string) !int { + mut listener := net.listen_tcp(.ip, '127.0.0.1:0')! + addr := listener.addr()! + port := addr.port()! + spawn fn [mut listener, response] () { + serve_once(mut listener, response) + }() + return port +} + +fn test_head_does_not_wait_for_body_when_content_length_is_set() { + port := start_server(head_resp) or { + assert false, 'failed to start server: ${err}' + return + } + resp := head('http://127.0.0.1:${port}/') or { + assert false, 'HEAD should not error, got: ${err}' + return + } + assert resp.status_code == 200 + assert resp.body == '' + cl := resp.header.get(.content_length) or { '' } + assert cl == '1234', 'Content-Length header should still be exposed to the caller' +} + +fn test_get_204_no_content_does_not_wait_for_body() { + port := start_server(status_204_resp) or { + assert false, 'failed to start server: ${err}' + return + } + resp := get('http://127.0.0.1:${port}/') or { + assert false, 'GET 204 should not error, got: ${err}' + return + } + assert resp.status_code == 204 + assert resp.body == '' +} + +fn test_get_304_not_modified_does_not_wait_for_body() { + port := start_server(status_304_resp) or { + assert false, 'failed to start server: ${err}' + return + } + resp := get('http://127.0.0.1:${port}/') or { + assert false, 'GET 304 should not error, got: ${err}' + return + } + assert resp.status_code == 304 + assert resp.body == '' +} diff --git a/vlib/net/http/request.v b/vlib/net/http/request.v index cc1f88934..d713942fa 100644 --- a/vlib/net/http/request.v +++ b/vlib/net/http/request.v @@ -551,6 +551,18 @@ fn parse_received_response(response_text string, info ReceivedResponseInfo) !Res return parse_response(response_text) } +// response_has_no_body returns true when the HTTP method or status code +// guarantees that no response body is sent (HEAD requests, 1xx informational, +// 204 No Content, 304 Not Modified). For these, a `Content-Length` header +// describes the body that *would* have been sent for a GET, so it must not +// drive read termination or completion validation. (RFC 7230 §3.3.3) +fn response_has_no_body(method Method, status_code int) bool { + if method == .head { + return true + } + return status_code in [101, 102, 103, 204, 304] +} + fn validate_received_response_completion(has_content_length bool, expected_size u64, body_so_far u64, is_chunked_transfer bool, chunked_complete bool) ! { if has_content_length && body_so_far < expected_size { return error('http.request: response body ended early: received ${body_so_far} of ${expected_size} bytes') @@ -584,8 +596,10 @@ fn (req &Request) receive_all_data_from_cb_in_builder(mut content strings.Builde } else { u64(0) } - validate_received_response_completion(has_content_length, expected_size, - body_so_far, is_chunked_transfer, chunked_body_tracker.complete)! + if !response_has_no_body(req.method, status_code) { + validate_received_response_completion(has_content_length, expected_size, + body_so_far, is_chunked_transfer, chunked_body_tracker.complete)! + } break } return err @@ -602,8 +616,10 @@ fn (req &Request) receive_all_data_from_cb_in_builder(mut content strings.Builde } else { u64(0) } - validate_received_response_completion(has_content_length, expected_size, body_so_far, - is_chunked_transfer, chunked_body_tracker.complete)! + if !response_has_no_body(req.method, status_code) { + validate_received_response_completion(has_content_length, expected_size, + body_so_far, is_chunked_transfer, chunked_body_tracker.complete)! + } break } new_len = old_len + u64(len) @@ -687,16 +703,16 @@ fn (req &Request) receive_all_data_from_cb_in_builder(mut content strings.Builde if is_chunked_transfer && chunked_complete { break } + if headers_end >= 0 && response_has_no_body(req.method, status_code) { + // HEAD / 1xx / 204 / 304: response body is forbidden by the spec, so + // stop as soon as the headers terminator is in. Any `Content-Length` + // describes a body that will never be sent. + break + } if has_content_length { if expected_size > 0 && body_so_far >= expected_size { break } - // Some streaming responses may incorrectly send `Content-Length: 0` and then stream data. - // Only short-circuit zero-length bodies for statuses/methods that must not have a body. - if expected_size == 0 - && (req.method == .head || status_code in [101, 102, 103, 204, 304]) { - break - } } if req.stop_receiving_limit > 0 && new_len > req.stop_receiving_limit { break diff --git a/vlib/net/mbedtls/mbedtls_head_with_content_length_test.v b/vlib/net/mbedtls/mbedtls_head_with_content_length_test.v new file mode 100644 index 000000000..bb43d5dfa --- /dev/null +++ b/vlib/net/mbedtls/mbedtls_head_with_content_length_test.v @@ -0,0 +1,77 @@ +module main + +import net +import net.http +import net.mbedtls + +// HTTPS counterpart to vlib/net/http/head_with_content_length_test.v. +// This is the exact path that emitted `received 0 of N bytes` against +// real-world servers (mbedtls `PEER_CLOSE_NOTIFY` → `socket_read_into_ptr` +// returns 0 → `len <= 0` branch in receive_all_data_from_cb_in_builder). + +const head_cl_test_cert = '-----BEGIN CERTIFICATE-----\nMIIEOTCCAyECFG64Q2g46jZb3kRbDOJWX/BwjSp6MA0GCSqGSIb3DQEBCwUAMEUx\nCzAJBgNVBAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRl\ncm5ldCBXaWRnaXRzIFB0eSBMdGQwIBcNMjMwODAyMTcyOTQyWhgPMjA1MDEyMTcx\nNzI5NDJaMGsxCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRQwEgYD\nVQQHDAtMb3MgQW5nZWxlczEdMBsGA1UECgwUQ2F0YWx5c3QgRGV2ZWxvcG1lbnQx\nEjAQBgNVBAMMCWxvY2FsaG9zdDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoC\nggIBALqAI4fqUi+QBVWcsXglouLdOML5+w0+1hSR1KdO0Q5XPdQAs/yYWJ+KUkDw\nG++rfy9DUPq7FNRBVurXQkcAtn6gXdllGUSjwUiDo/N4mMOyS/2sufBuaeww7jVi\nrppH+zwP1tUnjRd6khl6bi1Ian9VSzr3Iy9CkXIg1GU4CPXkOydLeoQfepXxWoK1\nOUNwT3VKC/stAfY3j/NIIeiJYkyuRGFCkxn/BUjN+AsXiTugRcYKEFHdIPkOuCXp\nYbhf+lLsczpxCs3rdZG9b/N6mEDCzXTmeHkmsjdPTf+1k5DZZvKzVBBrgdxCgBb7\n5RwjF5v9WmnIc33wWgfJC6FaUzj9NYxYUbPHD+jTz0rJB/jj4u/xJlM/e5NRmXdW\n70pOMKXtWjRSolLOFIPKLY1qs3KMTAZxKKWPDDF7WlMJxMRt7nnnks5yw43Nog4C\njDLk1ZgETnPpLgo3jbmJdIv+OHKTJrBlVvDq7VTyixCoS5G8KoOmyQJhaXG6NwE2\niVhH5JIKgzgCfetfDsnjxqJ/qtrFXPa8FF2TsomD0NK/GZmIcs+9OeVB75Jn5uhF\nfLHScpiTbuu5w3P/LI/MqihLRB6RRNnRzPH8fIg5bYC9b770ta/8GcFRuYE8t+UR\nGtqXJoIKixbDlqV54kal8FQzYzhETf9+NM6Kb/lKEfG/pslvAgMBAAEwDQYJKoZI\nhvcNAQELBQADggEBALI3uNiNO0QE1brA3QYFK+d9ZroB72NrJ0UNkzYHDg2Fc6xg\n4aVVfaxY08+TmKc0JlMOW+pUxeCW/+UBSngdQiR9EE9xm0k0XIrAsy9RXxRvEtPu\nM1VI2h7ayp1Y2BrnQinevTSgtqLRyS1VbOFRl1FiyVvinw2I0KsDdAMNevAPXcOa\nQ8pUgUq6f56DkhocQaj+hxD/uV8HryNxuoSXnPhvfTN3z4YRGzsaWevJ9EYJliOM\n+XugcqfFJ+W7/QCEcAHCL+Bw6OydG5NFORr3p57PXjjcL/uKmxPBrWg2Bz6uT4uR\nMhj0zttiFHLAt9jGfyk6W57UNUja1e1ggftJJhs=\n-----END CERTIFICATE-----\n' + +const head_cl_test_key = '-----BEGIN RSA PRIVATE KEY-----\n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\n-----END RSA PRIVATE KEY-----\n' + +fn serve_https_head_response(mut listener mbedtls.SSLListener, response string) { + defer { + listener.shutdown() or {} + } + mut conn := listener.accept() or { return } + defer { + conn.shutdown() or {} + } + mut request_buf := []u8{len: 2048} + _ = conn.read(mut request_buf) or { return } + conn.write_string(response) or { return } +} + +fn start_https_server(response string) !int { + mut port_listener := net.listen_tcp(.ip, '127.0.0.1:0')! + port := port_listener.addr()!.port()! + port_listener.close()! + mut listener := mbedtls.new_ssl_listener('127.0.0.1:${port}', mbedtls.SSLConnectConfig{ + cert: head_cl_test_cert + cert_key: head_cl_test_key + validate: false + in_memory_verification: true + })! + spawn serve_https_head_response(mut listener, response) + return port +} + +fn test_https_head_does_not_wait_for_body_when_content_length_is_set() { + port := start_https_server('HTTP/1.1 200 OK\r\nContent-Type: text/plain\r\nContent-Length: 1234\r\nConnection: close\r\n\r\n') or { + assert false, 'failed to start TLS server: ${err}' + return + } + resp := http.fetch( + method: .head + url: 'https://127.0.0.1:${port}/' + validate: false + ) or { + assert false, 'HTTPS HEAD should not error, got: ${err}' + return + } + assert resp.status_code == 200 + assert resp.body == '' + cl := resp.header.get(.content_length) or { '' } + assert cl == '1234' +} + +fn test_https_get_204_with_content_length_returns_empty_body() { + port := start_https_server('HTTP/1.1 204 No Content\r\nContent-Length: 5\r\nConnection: close\r\n\r\n') or { + assert false, 'failed to start TLS server: ${err}' + return + } + resp := http.fetch( + method: .get + url: 'https://127.0.0.1:${port}/' + validate: false + ) or { + assert false, 'HTTPS GET 204 should not error, got: ${err}' + return + } + assert resp.status_code == 204 + assert resp.body == '' +} -- 2.39.5