Description: Applies security patch for CVE-2022-27490
Author: Mohammed Bilal <mdbilal@disroot.org>
Applied-Upstream: https://github.com/puma/puma/commit/b8439ffc9d37f69c45bdca0a74cb49ebd9d09e66#diff-267386f55151c0f4e7f1f02c72881be7560baa1e80d6e2e63d30b074dda37cd1
Last-Update: 2022-04-08
---
--- a/lib/puma/client.rb
+++ b/lib/puma/client.rb
@@ -23,6 +23,8 @@
 
   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 @@
   # 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
@@ -284,16 +300,27 @@
       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
 
@@ -301,7 +328,12 @@
 
       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
@@ -450,7 +482,13 @@
       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
@@ -481,7 +519,12 @@
 
           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
--- a/lib/puma/const.rb
+++ b/lib/puma/const.rb
@@ -76,7 +76,7 @@
     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 @@
     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
@@ -144,9 +144,11 @@
       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
--- a/lib/puma/server.rb
+++ b/lib/puma/server.rb
@@ -321,6 +321,10 @@
           client.close
 
           @events.parse_error self, client.env, e
+        rescue HttpParserError501 => e
+          client.write_error(501)
+          client.close
+          @events.parse_error self, client.env, e
         rescue ConnectionError, EOFError
           client.close
         else
@@ -530,7 +534,12 @@
         client.write_error(400)
 
         @events.parse_error self, client.env, e
+      rescue HttpParserError501 => e
+        lowlevel_error(e, client.env)
+
+        client.write_error(501)
 
+        @events.parse_error self, client.env, e
       # Server error
       rescue StandardError => e
         lowlevel_error(e, client.env)
--- a/test/test_puma_server.rb
+++ b/test/test_puma_server.rb
@@ -446,17 +446,20 @@
   def test_chunked_request
     body = nil
     content_length = nil
+    transfer_encoding = nil
     server_run app: ->(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_chunked_request_pause_before_value
--- /dev/null
+++ b/test/test_request_invalid.rb
@@ -0,0 +1,218 @@
+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]]
+    }
+
+    events = Puma::Events.strings
+    @server = Puma::Server.new app, 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
