VYPR
Critical severityNVD Advisory· Published Aug 18, 2023· Updated Oct 7, 2024

Inconsistent Interpretation of HTTP Requests in puma

CVE-2023-40175

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.

PackageAffected versionsPatched versions
pumaRubyGems
< 5.6.75.6.7
pumaRubyGems
>= 6.0.0, < 6.3.16.3.1

Affected products

12

Patches

3
7405a219801d

Merge pull request from GHSA-68xg-gqqm-vgj8

https://github.com/puma/pumaNate BerkopecAug 18, 2023via ghsa
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
    
ed0f2f94b569

Merge pull request from GHSA-68xg-gqqm-vgj8

https://github.com/puma/pumaNate BerkopecAug 18, 2023via ghsa
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
    
690155e7d644

Merge pull request from GHSA-68xg-gqqm-vgj8

https://github.com/puma/pumaNate BerkopecAug 18, 2023via ghsa
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

News mentions

0

No linked articles in our index yet.