VYPR
Critical severityNVD Advisory· Published Mar 30, 2022· Updated Apr 23, 2025

HTTP Request Smuggling in puma

CVE-2022-24790

Description

Puma is a simple, fast, multi-threaded, parallel HTTP 1.1 server for Ruby/Rack applications. When using Puma behind a proxy that does not properly validate that the incoming HTTP request matches the RFC7230 standard, Puma and the frontend proxy may disagree on where a request starts and ends. This would allow requests to be smuggled via the front-end proxy to Puma. The vulnerability has been fixed in 5.6.4 and 4.3.12. Users are advised to upgrade as soon as possible. Workaround: when deploying a proxy in front of Puma, turning on any and all functionality to make sure that the request matches the RFC7230 standard.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Puma HTTP server vulnerable to request smuggling when behind a proxy that does not validate RFC7230 compliance, fixed in 5.6.4 and 4.3.12.

Vulnerability

Puma versions before 5.6.4 and 4.3.12 contain an HTTP request smuggling vulnerability. When Puma is deployed behind a proxy that does not properly validate incoming HTTP requests against RFC7230, the proxy and Puma may disagree on request boundaries. This allows an attacker to craft requests that are interpreted differently by the front-end proxy and Puma, leading to request smuggling. The issue lies in how Puma parses Transfer-Encoding headers, as seen in the fix commit [4].

Exploitation

An attacker needs network access to send HTTP requests through the front-end proxy. The proxy must be configured in a way that does not enforce RFC7230 compliance. By sending a specially crafted request with ambiguous Transfer-Encoding or Content-Length headers, the attacker can cause the proxy to see one request while Puma sees a different one. This can be achieved by exploiting differences in header parsing, such as using multiple Transfer-Encoding values or malformed chunked encoding [2]. No authentication is required.

Impact

Successful exploitation allows an attacker to smuggle a malicious request past the proxy, potentially bypassing security controls, accessing sensitive data, or compromising other application users. The impact is similar to typical HTTP request smuggling attacks, which can lead to session hijacking, cache poisoning, or privilege escalation [2]. The attacker can effectively poison the connection between proxy and Puma, affecting subsequent requests.

Mitigation

The vulnerability is fixed in Puma versions 5.6.4 and 4.3.12, released on 2022-03-30 [1][3]. Users should upgrade immediately. As a workaround, ensure that any proxy in front of Puma validates incoming requests against RFC7230, including proper handling of Transfer-Encoding and Content-Length headers. Turning on request validation features in the proxy can mitigate the risk until patching is possible [1].

AI Insight generated on May 21, 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.0.0, < 5.6.45.6.4
pumaRubyGems
< 4.3.124.3.12

Affected products

63

Patches

1
5bb7d202e24d

Merge pull request from GHSA-h99w-9q5r-gjq9

https://github.com/puma/pumaNate BerkopecMar 30, 2022via ghsa
6 files changed · +289 15
  • lib/puma/client.rb+54 11 modified
    @@ -23,6 +23,8 @@ module Puma
     
       class ConnectionError < RuntimeError; end
     
    +  class HttpParserError501 < IOError; end
    +
       # An instance of this class represents a unique request from a client.
       # For example, this could be a web request from a browser or from CURL.
       #
    @@ -35,7 +37,21 @@ class ConnectionError < RuntimeError; end
       # Instances of this class are responsible for knowing if
       # the header and body are fully buffered via the `try_to_finish` method.
       # They can be used to "time out" a response via the `timeout_at` reader.
    +  #
       class Client
    +
    +    # this tests all values but the last, which must be chunked
    +    ALLOWED_TRANSFER_ENCODING = %w[compress deflate gzip].freeze
    +
    +    # chunked body validation
    +    CHUNK_SIZE_INVALID = /[^\h]/.freeze
    +    CHUNK_VALID_ENDING = "\r\n".freeze
    +
    +    # Content-Length header value validation
    +    CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze
    +
    +    TE_ERR_MSG = 'Invalid Transfer-Encoding'
    +
         # The object used for a request with no body. All requests with
         # no body share this one object since it has no state.
         EmptyBody = NullIO.new
    @@ -302,24 +318,40 @@ def setup_body
           body = @parser.body
     
           te = @env[TRANSFER_ENCODING2]
    -
           if te
    -        if te.include?(",")
    -          te.split(",").each do |part|
    -            if CHUNKED.casecmp(part.strip) == 0
    -              return setup_chunked_body(body)
    -            end
    +        te_lwr = te.downcase
    +        if te.include? ','
    +          te_ary = te_lwr.split ','
    +          te_count = te_ary.count CHUNKED
    +          te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
    +          if te_ary.last == CHUNKED && te_count == 1 && te_valid
    +            @env.delete TRANSFER_ENCODING2
    +            return setup_chunked_body body
    +          elsif te_count >= 1
    +            raise HttpParserError   , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
    +          elsif !te_valid
    +            raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
               end
    -        elsif CHUNKED.casecmp(te) == 0
    -          return setup_chunked_body(body)
    +        elsif te_lwr == CHUNKED
    +          @env.delete TRANSFER_ENCODING2
    +          return setup_chunked_body body
    +        elsif ALLOWED_TRANSFER_ENCODING.include? te_lwr
    +          raise HttpParserError     , "#{TE_ERR_MSG}, single value must be chunked: '#{te}'"
    +        else
    +          raise HttpParserError501  , "#{TE_ERR_MSG}, unknown value: '#{te}'"
             end
           end
     
           @chunked_body = false
     
           cl = @env[CONTENT_LENGTH]
     
    -      unless cl
    +      if cl
    +        # cannot contain characters that are not \d
    +        if cl =~ CONTENT_LENGTH_VALUE_INVALID
    +          raise HttpParserError, "Invalid Content-Length: #{cl.inspect}"
    +        end
    +      else
             @buffer = body.empty? ? nil : body
             @body = EmptyBody
             set_ready
    @@ -478,7 +510,13 @@ def decode_chunk(chunk)
           while !io.eof?
             line = io.gets
             if line.end_with?("\r\n")
    -          len = line.strip.to_i(16)
    +          # 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[^;]+/]
    +          if chunk_hex =~ CHUNK_SIZE_INVALID
    +            raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'"
    +          end
    +          len = chunk_hex.to_i(16)
               if len == 0
                 @in_last_chunk = true
                 @body.rewind
    @@ -509,7 +547,12 @@ def decode_chunk(chunk)
     
               case
               when got == len
    -            write_chunk(part[0..-3]) # to skip the ending \r\n
    +            # proper chunked segment must end with "\r\n"
    +            if part.end_with? CHUNK_VALID_ENDING
    +              write_chunk(part[0..-3]) # to skip the ending \r\n
    +            else
    +              raise HttpParserError, "Chunk size mismatch"
    +            end
               when got <= len - 2
                 write_chunk(part)
                 @partial_part_left = len - part.size
    
  • lib/puma/const.rb+5 3 modified
    @@ -76,7 +76,7 @@ class UnsupportedOption < RuntimeError
         508 => 'Loop Detected',
         510 => 'Not Extended',
         511 => 'Network Authentication Required'
    -  }
    +  }.freeze
     
       # For some HTTP status codes the client only expects headers.
       #
    @@ -85,7 +85,7 @@ class UnsupportedOption < RuntimeError
         204 => true,
         205 => true,
         304 => true
    -  }
    +  }.freeze
     
       # Frequently used constants when constructing requests or responses.  Many times
       # the constant just refers to a string with the same contents.  Using these constants
    @@ -145,9 +145,11 @@ module Const
           408 => "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\nServer: Puma #{PUMA_VERSION}\r\n\r\n".freeze,
           # Indicate that there was an internal error, obviously.
           500 => "HTTP/1.1 500 Internal Server Error\r\n\r\n".freeze,
    +      # Incorrect or invalid header value
    +      501 => "HTTP/1.1 501 Not Implemented\r\n\r\n".freeze,
           # A common header for indicating the server is too busy.  Not used yet.
           503 => "HTTP/1.1 503 Service Unavailable\r\n\r\nBUSY".freeze
    -    }
    +    }.freeze
     
         # The basic max request size we'll try to read.
         CHUNK_SIZE = 16 * 1024
    
  • lib/puma/server.rb+3 0 modified
    @@ -515,6 +515,9 @@ def client_error(e, client)
           when HttpParserError
             client.write_error(400)
             @events.parse_error e, client
    +      when HttpParserError501
    +        client.write_error(501)
    +        @events.parse_error e, client
           else
             client.write_error(500)
             @events.unknown_error e, nil, "Read"
    
  • test/helper.rb+3 0 modified
    @@ -174,6 +174,9 @@ def skip_unless(eng, bt: caller)
     Minitest::Test.include TestSkips
     
     class Minitest::Test
    +
    +  REPO_NAME = ENV['GITHUB_REPOSITORY'] ? ENV['GITHUB_REPOSITORY'][/[^\/]+\z/] : 'puma'
    +
       def self.run(reporter, options = {}) # :nodoc:
         prove_it!
         super
    
  • test/test_puma_server.rb+4 1 modified
    @@ -602,17 +602,20 @@ def test_Expect_100
       def test_chunked_request
         body = nil
         content_length = nil
    +    transfer_encoding = nil
         server_run { |env|
           body = env['rack.input'].read
           content_length = env['CONTENT_LENGTH']
    +      transfer_encoding = env['HTTP_TRANSFER_ENCODING']
           [200, {}, [""]]
         }
     
    -    data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n"
    +    data = send_http_and_read "GET / HTTP/1.1\r\nConnection: close\r\nTransfer-Encoding: gzip,chunked\r\n\r\n1\r\nh\r\n4\r\nello\r\n0\r\n\r\n"
     
         assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data
         assert_equal "hello", body
         assert_equal "5", content_length
    +    assert_nil transfer_encoding
       end
     
       def test_large_chunked_request
    
  • test/test_request_invalid.rb+220 0 added
    @@ -0,0 +1,220 @@
    +require_relative "helper"
    +require "puma/events"
    +
    +# These tests check for invalid request headers and metadata.
    +# Content-Length, Transfer-Encoding, and chunked body size
    +# values are checked for validity
    +#
    +# See https://datatracker.ietf.org/doc/html/rfc7230
    +#
    +# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.2 Content-Length
    +# https://datatracker.ietf.org/doc/html/rfc7230#section-3.3.1 Transfer-Encoding
    +# https://datatracker.ietf.org/doc/html/rfc7230#section-4.1   chunked body size
    +#
    +class TestRequestInvalid < Minitest::Test
    +  # running parallel seems to take longer...
    +  # parallelize_me! unless JRUBY_HEAD
    +
    +  GET_PREFIX = "GET / HTTP/1.1\r\nConnection: close\r\n"
    +  CHUNKED = "1\r\nH\r\n4\r\nello\r\n5\r\nWorld\r\n0\r\n\r\n"
    +
    +  def setup
    +    @host = '127.0.0.1'
    +
    +    @ios = []
    +
    +    # this app should never be called, used for debugging
    +    app = ->(env) {
    +      body = ''.dup
    +      env.each do |k,v|
    +        body << "#{k} = #{v}\n"
    +        if k == 'rack.input'
    +          body << "#{v.read}\n"
    +        end
    +      end
    +      [200, {}, [body]]
    +    }
    +
    +    @log_writer = Puma::LogWriter.strings
    +    events = Puma::Events.new
    +    @server = Puma::Server.new app, @log_writer, events
    +    @port = (@server.add_tcp_listener @host, 0).addr[1]
    +    @server.run
    +    sleep 0.15 if Puma.jruby?
    +  end
    +
    +  def teardown
    +    @server.stop(true)
    +    @ios.each { |io| io.close if io && !io.closed? }
    +  end
    +
    +  def send_http_and_read(req)
    +    send_http(req).read
    +  end
    +
    +  def send_http(req)
    +    new_connection << req
    +  end
    +
    +  def new_connection
    +    TCPSocket.new(@host, @port).tap {|sock| @ios << sock}
    +  end
    +
    +  def assert_status(str, status = 400)
    +    assert str.start_with?("HTTP/1.1 #{status}"), "'#{str[/[^\r]+/]}' should be #{status}"
    +  end
    +
    +  # ──────────────────────────────────── below are invalid Content-Length
    +
    +  def test_content_length_multiple
    +    te = [
    +      'Content-Length: 5',
    +      'Content-Length: 5'
    +    ].join "\r\n"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_content_length_bad_characters_1
    +    te = 'Content-Length: 5.01'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_content_length_bad_characters_2
    +    te = 'Content-Length: +5'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_content_length_bad_characters_3
    +    te = 'Content-Length: 5 test'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\nHello\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  # ──────────────────────────────────── below are invalid Transfer-Encoding
    +
    +  def test_transfer_encoding_chunked_not_last
    +    te = [
    +      'Transfer-Encoding: chunked',
    +      'Transfer-Encoding: gzip'
    +    ].join "\r\n"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}"
    +
    +    assert_status data
    +  end
    +
    +  def test_transfer_encoding_chunked_multiple
    +    te = [
    +      'Transfer-Encoding: chunked',
    +      'Transfer-Encoding: gzip',
    +      'Transfer-Encoding: chunked'
    +    ].join "\r\n"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}"
    +
    +    assert_status data
    +  end
    +
    +  def test_transfer_encoding_invalid_single
    +    te = 'Transfer-Encoding: xchunked'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}"
    +
    +    assert_status data, 501
    +  end
    +
    +  def test_transfer_encoding_invalid_multiple
    +    te = [
    +      'Transfer-Encoding: x_gzip',
    +      'Transfer-Encoding: gzip',
    +      'Transfer-Encoding: chunked'
    +    ].join "\r\n"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}"
    +
    +    assert_status data, 501
    +  end
    +
    +  def test_transfer_encoding_single_not_chunked
    +    te = 'Transfer-Encoding: gzip'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{CHUNKED}"
    +
    +    assert_status data
    +  end
    +
    +  # ──────────────────────────────────── below are invalid chunked size
    +
    +  def test_chunked_size_bad_characters_1
    +    te = 'Transfer-Encoding: chunked'
    +    chunked ='5.01'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_chunked_size_bad_characters_2
    +    te = 'Transfer-Encoding: chunked'
    +    chunked ='+5'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_chunked_size_bad_characters_3
    +    te = 'Transfer-Encoding: chunked'
    +    chunked ='5 bad'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHello\r\n0\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  def test_chunked_size_bad_characters_4
    +    te = 'Transfer-Encoding: chunked'
    +    chunked ='0xA'
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n1\r\nh\r\n#{chunked}\r\nHelloHello\r\n0\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  # size is less than bytesize
    +  def test_chunked_size_mismatch_1
    +    te = 'Transfer-Encoding: chunked'
    +    chunked =
    +      "5\r\nHello\r\n" \
    +      "4\r\nWorld\r\n" \
    +      "0"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{chunked}\r\n\r\n"
    +
    +    assert_status data
    +  end
    +
    +  # size is greater than bytesize
    +  def test_chunked_size_mismatch_2
    +    te = 'Transfer-Encoding: chunked'
    +    chunked =
    +      "5\r\nHello\r\n" \
    +      "6\r\nWorld\r\n" \
    +      "0"
    +
    +    data = send_http_and_read "#{GET_PREFIX}#{te}\r\n\r\n#{chunked}\r\n\r\n"
    +
    +    assert_status data
    +  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

15

News mentions

0

No linked articles in our index yet.