Inconsistent Interpretation of HTTP Requests in puma
Description
Puma is a Ruby/Rack web server built for parallelism. Prior to versions 6.3.1 and 5.6.7, puma exhibited incorrect behavior when parsing chunked transfer encoding bodies and zero-length Content-Length headers in a way that allowed HTTP request smuggling. Severity of this issue is highly dependent on the nature of the web site using puma is. This could be caused by either incorrect parsing of trailing fields in chunked transfer encoding bodies or by parsing of blank/zero-length Content-Length headers. Both issues have been addressed and this vulnerability has been fixed in versions 6.3.1 and 5.6.7. Users are advised to upgrade. There are no known workarounds for this vulnerability.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Puma HTTP request smuggling vulnerability due to incorrect parsing of chunked transfer encoding bodies and zero-length Content-Length headers.
Vulnerability
Analysis
CVE-2023-40175 describes an HTTP request smuggling vulnerability in the Puma web server, affecting versions prior to 6.3.1 and 5.6.7. The root cause lies in incorrect parsing of chunked transfer encoding bodies and zero-length Content-Length headers [1][2]. Specifically, the parser failed to properly validate trailing fields in chunked transfer encoding or handle empty Content-Length values, allowing an attacker to inject HTTP requests that would be interpreted differently by a front-end proxy and the Puma server [1][2].
Exploitation and
Attack Surface
Exploitation requires the ability to send crafted HTTP requests to a Puma server, typically through a reverse proxy or directly if Puma is exposed. The attack leverages differences in how chunked transfer encoding and Content-Length headers are parsed [1][2]. By sending a request with a zero-length Content-Length header or malformed chunked encoding, an attacker can cause the server to misinterpret the request boundaries, leading to request smuggling [2]. No authentication is required for this attack, but the severity depends on the surrounding infrastructure and application logic [2].
Impact
Successful exploitation could allow an attacker to bypass security controls, poison web caches, or perform cross-site scripting (XSS) attacks by causing the server to process smuggled requests as legitimate [1][2]. The impact is highly dependent on the web site's architecture and the presence of intermediary HTTP devices, but can lead to data theft or unauthorized actions in vulnerable environments [2].
Mitigation
The vulnerability is fixed in Puma versions 6.3.1 and 5.6.7, which include patches to the chunked transfer encoding and Content-Length parsing logic [1][2][3][4]. Users are advised to upgrade immediately, as no workarounds exist [2]. The patches ensure that empty Content-Length headers are rejected and that chunked encoding trailers are correctly handled [3][4].
AI Insight generated on May 20, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
pumaRubyGems | < 5.6.7 | 5.6.7 |
pumaRubyGems | >= 6.0.0, < 6.3.1 | 6.3.1 |
Affected products
12- ghsa-coords11 versionspkg:gem/pumapkg:rpm/opensuse/rubygem-puma&distro=openSUSE%20Leap%2015.4pkg:rpm/opensuse/rubygem-puma&distro=openSUSE%20Leap%2015.5pkg:rpm/opensuse/rubygem-puma&distro=openSUSE%20Leap%2015.6pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP1pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP2pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP3pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP4pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP5pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP6pkg:rpm/suse/rubygem-puma&distro=SUSE%20Linux%20Enterprise%20High%20Availability%20Extension%2015%20SP7
< 5.6.7+ 10 more
- (no CPE)range: < 5.6.7
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 5.6.9-150600.18.3.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 4.3.12-150000.3.12.1
- (no CPE)range: < 5.6.9-150600.18.3.1
- (no CPE)range: < 5.6.9-150600.18.3.1
- puma/pumav5Range: < 5.6.7
Patches
37405a219801dMerge pull request from GHSA-68xg-gqqm-vgj8
2 files changed · +56 −9
lib/puma/client.rb+15 −8 modified@@ -45,7 +45,8 @@ class Client # chunked body validation CHUNK_SIZE_INVALID = /[^\h]/.freeze - CHUNK_VALID_ENDING = "\r\n".freeze + CHUNK_VALID_ENDING = Const::LINE_END + CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize # Content-Length header value validation CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze @@ -347,8 +348,8 @@ def setup_body cl = @env[CONTENT_LENGTH] if cl - # cannot contain characters that are not \d - if cl =~ CONTENT_LENGTH_VALUE_INVALID + # cannot contain characters that are not \d, or be empty + if cl =~ CONTENT_LENGTH_VALUE_INVALID || cl.empty? raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" end else @@ -509,7 +510,7 @@ def decode_chunk(chunk) while !io.eof? line = io.gets - if line.end_with?("\r\n") + if line.end_with?(CHUNK_VALID_ENDING) # Puma doesn't process chunk extensions, but should parse if they're # present, which is the reason for the semicolon regex chunk_hex = line.strip[/\A[^;]+/] @@ -521,13 +522,19 @@ def decode_chunk(chunk) @in_last_chunk = true @body.rewind rest = io.read - last_crlf_size = "\r\n".bytesize - if rest.bytesize < last_crlf_size + if rest.bytesize < CHUNK_VALID_ENDING_SIZE @buffer = nil - @partial_part_left = last_crlf_size - rest.bytesize + @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize return false else - @buffer = rest[last_crlf_size..-1] + # if the next character is a CRLF, set buffer to everything after that CRLF + start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) + CHUNK_VALID_ENDING_SIZE + else # we have started a trailer section, which we do not support. skip it! + rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 + end + + @buffer = rest[start_of_rest..-1] @buffer = nil if @buffer.empty? set_ready return true
test/test_puma_server.rb+41 −1 modified@@ -627,7 +627,7 @@ def test_large_chunked_request [200, {}, [""]] } - header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\r\nTransfer-Encoding: chunked\r\n\r\n" chunk_header_size = 6 # 4fb8\r\n # Current implementation reads one chunk of CHUNK_SIZE, then more chunks of size 4096. @@ -1365,4 +1365,44 @@ def test_rack_url_scheme_user data = send_http_and_read "GET / HTTP/1.0\r\n\r\n" assert_equal "user", data.split("\r\n").last end + + def test_cl_empty_string + server_run do |env| + [200, {}, [""]] + end + + empty_cl_request = "GET / HTTP/1.1\r\nHost: localhost\r\nContent-Length:\r\n\r\nGET / HTTP/1.1\r\nHost: localhost\r\n\r\n" + + data = send_http_and_read empty_cl_request + assert_operator data, :start_with?, 'HTTP/1.1 400 Bad Request' + end + + def test_crlf_trailer_smuggle + server_run do |env| + [200, {}, [""]] + end + + smuggled_payload = "GET / HTTP/1.1\r\nTransfer-Encoding: chunked\r\nHost: whatever\r\n\r\n0\r\nX:POST / HTTP/1.1\r\nHost: whatever\r\n\r\nGET / HTTP/1.1\r\nHost: whatever\r\n\r\n" + + data = send_http_and_read smuggled_payload + assert_equal 2, data.scan("HTTP/1.1 200 OK").size + end + + # test to check if content-length is ignored when 'transfer-encoding: chunked' + # is used. See also test_large_chunked_request + def test_cl_and_te_smuggle + body = nil + server_run { |env| + body = env['rack.input'].read + [200, {}, [""]] + } + + req = "POST /search HTTP/1.1\r\nHost: vulnerable-website.com\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 4\r\nTransfer-Encoding: chunked\r\n\r\n7b\r\nGET /404 HTTP/1.1\r\nHost: vulnerable-website.com\r\nContent-Type: application/x-www-form-urlencoded\r\nContent-Length: 144\r\n\r\nx=\r\n0\r\n\r\n" + + data = send_http_and_read req + + assert_includes body, "GET /404 HTTP/1.1\r\n" + assert_includes body, "Content-Length: 144\r\n" + assert_equal 1, data.scan("HTTP/1.1 200 OK").size + end end
ed0f2f94b569Merge pull request from GHSA-68xg-gqqm-vgj8
2 files changed · +109 −9
lib/puma/client.rb+15 −8 modified@@ -49,7 +49,8 @@ class Client # :nodoc: # chunked body validation CHUNK_SIZE_INVALID = /[^\h]/.freeze - CHUNK_VALID_ENDING = "\r\n".freeze + CHUNK_VALID_ENDING = Const::LINE_END + CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize # Content-Length header value validation CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze @@ -382,8 +383,8 @@ def setup_body cl = @env[CONTENT_LENGTH] if cl - # cannot contain characters that are not \d - if CONTENT_LENGTH_VALUE_INVALID.match? cl + # cannot contain characters that are not \d, or be empty + if CONTENT_LENGTH_VALUE_INVALID.match?(cl) || cl.empty? raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" end else @@ -544,7 +545,7 @@ def decode_chunk(chunk) while !io.eof? line = io.gets - if line.end_with?("\r\n") + if line.end_with?(CHUNK_VALID_ENDING) # Puma doesn't process chunk extensions, but should parse if they're # present, which is the reason for the semicolon regex chunk_hex = line.strip[/\A[^;]+/] @@ -556,13 +557,19 @@ def decode_chunk(chunk) @in_last_chunk = true @body.rewind rest = io.read - last_crlf_size = "\r\n".bytesize - if rest.bytesize < last_crlf_size + if rest.bytesize < CHUNK_VALID_ENDING_SIZE @buffer = nil - @partial_part_left = last_crlf_size - rest.bytesize + @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize return false else - @buffer = rest[last_crlf_size..-1] + # if the next character is a CRLF, set buffer to everything after that CRLF + start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) + CHUNK_VALID_ENDING_SIZE + else # we have started a trailer section, which we do not support. skip it! + rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 + end + + @buffer = rest[start_of_rest..-1] @buffer = nil if @buffer.empty? set_ready return true
test/test_puma_server.rb+94 −1 modified@@ -749,7 +749,7 @@ def test_large_chunked_request [200, {}, [""]] } - header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\r\nTransfer-Encoding: chunked\r\n\r\n" chunk_header_size = 6 # 4fb8\r\n # Current implementation reads one chunk of CHUNK_SIZE, then more chunks of size 4096. @@ -1695,4 +1695,97 @@ def spawn_cmd(env = {}, cmd) [out_w, err_w].each(&:close) [out_r, err_r, pid] end + + def test_lowlevel_error_handler_response + options = { + lowlevel_error_handler: ->(_error) do + [500, {}, ["something wrong happened"]] + end + } + broken_app = ->(_env) { [200, nil, []] } + + server_run(**options, &broken_app) + + data = send_http_and_read "GET / HTTP/1.1\r\n\r\n" + + assert_match(/something wrong happened/, data) + end + + def test_cl_empty_string + server_run do |env| + [200, {}, [""]] + end + + # rubocop:disable Layout/TrailingWhitespace + empty_cl_request = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Host: localhost + Content-Length: + + GET / HTTP/1.1 + Host: localhost + + REQ + # rubocop:enable Layout/TrailingWhitespace + + data = send_http_and_read empty_cl_request + assert_operator data, :start_with?, 'HTTP/1.1 400 Bad Request' + end + + def test_crlf_trailer_smuggle + server_run do |env| + [200, {}, [""]] + end + + smuggled_payload = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Transfer-Encoding: chunked + Host: whatever + + 0 + X:POST / HTTP/1.1 + Host: whatever + + GET / HTTP/1.1 + Host: whatever + + REQ + + data = send_http_and_read smuggled_payload + assert_equal 2, data.scan("HTTP/1.1 200 OK").size + end + + # test to check if content-length is ignored when 'transfer-encoding: chunked' + # is used. See also test_large_chunked_request + def test_cl_and_te_smuggle + body = nil + server_run { |env| + body = env['rack.input'].read + [200, {}, [""]] + } + + req = <<~REQ.gsub("\n", "\r\n") + POST /search HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 4 + Transfer-Encoding: chunked + + 7b + GET /404 HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 144 + + x= + 0 + + REQ + + data = send_http_and_read req + + assert_includes body, "GET /404 HTTP/1.1\r\n" + assert_includes body, "Content-Length: 144\r\n" + assert_equal 1, data.scan("HTTP/1.1 200 OK").size + end end
690155e7d644Merge pull request from GHSA-68xg-gqqm-vgj8
2 files changed · +94 −9
lib/puma/client.rb+15 −8 modified@@ -49,7 +49,8 @@ class Client # :nodoc: # chunked body validation CHUNK_SIZE_INVALID = /[^\h]/.freeze - CHUNK_VALID_ENDING = "\r\n".freeze + CHUNK_VALID_ENDING = Const::LINE_END + CHUNK_VALID_ENDING_SIZE = CHUNK_VALID_ENDING.bytesize # Content-Length header value validation CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze @@ -382,8 +383,8 @@ def setup_body cl = @env[CONTENT_LENGTH] if cl - # cannot contain characters that are not \d - if CONTENT_LENGTH_VALUE_INVALID.match? cl + # cannot contain characters that are not \d, or be empty + if CONTENT_LENGTH_VALUE_INVALID.match?(cl) || cl.empty? raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" end else @@ -544,7 +545,7 @@ def decode_chunk(chunk) while !io.eof? line = io.gets - if line.end_with?("\r\n") + if line.end_with?(CHUNK_VALID_ENDING) # Puma doesn't process chunk extensions, but should parse if they're # present, which is the reason for the semicolon regex chunk_hex = line.strip[/\A[^;]+/] @@ -556,13 +557,19 @@ def decode_chunk(chunk) @in_last_chunk = true @body.rewind rest = io.read - last_crlf_size = "\r\n".bytesize - if rest.bytesize < last_crlf_size + if rest.bytesize < CHUNK_VALID_ENDING_SIZE @buffer = nil - @partial_part_left = last_crlf_size - rest.bytesize + @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize return false else - @buffer = rest[last_crlf_size..-1] + # if the next character is a CRLF, set buffer to everything after that CRLF + start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) + CHUNK_VALID_ENDING_SIZE + else # we have started a trailer section, which we do not support. skip it! + rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 + end + + @buffer = rest[start_of_rest..-1] @buffer = nil if @buffer.empty? set_ready return true
test/test_puma_server.rb+79 −1 modified@@ -749,7 +749,7 @@ def test_large_chunked_request [200, {}, [""]] } - header = "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n" + header = "GET / HTTP/1.1\r\nConnection: close\r\nContent-Length: 200\r\nTransfer-Encoding: chunked\r\n\r\n" chunk_header_size = 6 # 4fb8\r\n # Current implementation reads one chunk of CHUNK_SIZE, then more chunks of size 4096. @@ -1710,4 +1710,82 @@ def test_lowlevel_error_handler_response assert_match(/something wrong happened/, data) end + + def test_cl_empty_string + server_run do |env| + [200, {}, [""]] + end + + # rubocop:disable Layout/TrailingWhitespace + empty_cl_request = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Host: localhost + Content-Length: + + GET / HTTP/1.1 + Host: localhost + + REQ + # rubocop:enable Layout/TrailingWhitespace + + data = send_http_and_read empty_cl_request + assert_operator data, :start_with?, 'HTTP/1.1 400 Bad Request' + end + + def test_crlf_trailer_smuggle + server_run do |env| + [200, {}, [""]] + end + + smuggled_payload = <<~REQ.gsub("\n", "\r\n") + GET / HTTP/1.1 + Transfer-Encoding: chunked + Host: whatever + + 0 + X:POST / HTTP/1.1 + Host: whatever + + GET / HTTP/1.1 + Host: whatever + + REQ + + data = send_http_and_read smuggled_payload + assert_equal 2, data.scan("HTTP/1.1 200 OK").size + end + + # test to check if content-length is ignored when 'transfer-encoding: chunked' + # is used. See also test_large_chunked_request + def test_cl_and_te_smuggle + body = nil + server_run { |env| + body = env['rack.input'].read + [200, {}, [""]] + } + + req = <<~REQ.gsub("\n", "\r\n") + POST /search HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 4 + Transfer-Encoding: chunked + + 7b + GET /404 HTTP/1.1 + Host: vulnerable-website.com + Content-Type: application/x-www-form-urlencoded + Content-Length: 144 + + x= + 0 + + REQ + + data = send_http_and_read req + + assert_includes body, "GET /404 HTTP/1.1\r\n" + assert_includes body, "Content-Length: 144\r\n" + assert_equal 1, data.scan("HTTP/1.1 200 OK").size + end end
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
9- github.com/advisories/GHSA-68xg-gqqm-vgj8ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2023-40175ghsaADVISORY
- github.com/puma/puma/commit/690155e7d644b80eeef0a6094f9826ee41f1080aghsax_refsource_MISCWEB
- github.com/puma/puma/commit/7405a219801dcebc0ad6e0aa108d4319ca23f662ghsaWEB
- github.com/puma/puma/commit/ed0f2f94b56982c687452504b95d5f1fbbe3eed1ghsaWEB
- github.com/puma/puma/releases/tag/v5.6.7ghsaWEB
- github.com/puma/puma/releases/tag/v6.3.1ghsaWEB
- github.com/puma/puma/security/advisories/GHSA-68xg-gqqm-vgj8ghsax_refsource_CONFIRMWEB
- github.com/rubysec/ruby-advisory-db/blob/master/gems/puma/CVE-2023-40175.ymlghsaWEB
News mentions
0No linked articles in our index yet.