From b8439ffc9d37f69c45bdca0a74cb49ebd9d09e66 Mon Sep 17 00:00:00 2001
From: Nate Berkopec <nate.berkopec@gmail.com>
Date: Wed, 30 Mar 2022 08:06:46 -0600
Subject: [PATCH] Merge pull request from GHSA-h99w-9q5r-gjq9

* Test updates for CVE

* Lib Updates for CVE

Co-authored-by: MSP-Greg <Greg.mpls@gmail.com>
---
 lib/puma/client.rb           |  65 +++++++++--
 lib/puma/const.rb            |   8 +-
 lib/puma/server.rb           |   9 ++
 test/test_puma_server.rb     |   5 +-
 test/test_request_invalid.rb | 218 +++++++++++++++++++++++++++++++++++
 6 files changed, 293 insertions(+), 15 deletions(-)
 create mode 100644 test/test_request_invalid.rb

diff --git a/lib/puma/client.rb b/lib/puma/client.rb
index 8a9b7f1f5..5c6b676ae 100644
--- a/lib/puma/client.rb
+++ b/lib/puma/client.rb
@@ -23,6 +23,8 @@ module Puma
 
   class ConnectionError < RuntimeError; end
 
+  class HttpParserError501 < IOError; end
+
   class Client
     include Puma::Const
 
@@ -35,6 +37,18 @@ class ConnectionError < RuntimeError; end
       end
     end
 
+    # 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
@@ -450,7 +482,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
             @body.rewind
             @buffer = nil
@@ -481,7 +519,12 @@ def decode_chunk(chunk)
 
           case
           when got == len
-            @body << part[0..-3] # to skip the ending \r\n
+            # proper chunked segment must end with "\r\n"
+            if part.end_with? CHUNK_VALID_ENDING
+              @body << part[0..-3] # to skip the ending \r\n
+            else
+              raise HttpParserError, "Chunk size mismatch"
+            end
           when got <= len - 2
             @body << part
             @partial_part_left = len - part.size
@@ -284,16 +300,27 @@ 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
 
@@ -301,7 +328,12 @@ def setup_body
 
       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
         @requests_served += 1
@@ -423,6 +464,13 @@ module Puma
       end
     end
 
+    def write_501
+      begin
+        @io << ERROR_501_RESPONSE
+      rescue StandardError
+      end
+    end
+
     def peerip
       return @peerip if @peerip
 
diff --git a/lib/puma/const.rb b/lib/puma/const.rb
index 9210c2263..6b80e8943 100644
--- a/lib/puma/const.rb
+++ b/lib/puma/const.rb
@@ -144,8 +144,11 @@ module Const
     # Indicate that there was an internal error, obviously.
     ERROR_500_RESPONSE = "HTTP/1.1 500 Internal Server Error\r\n\r\n".freeze
 
+    # Incorrect or invalid header value
+    ERROR_501_RESPONSE = "HTTP/1.1 501 Not Implemented\r\n\r\n".freeze
+
     # A common header for indicating the server is too busy.  Not used yet.
     ERROR_503_RESPONSE = "HTTP/1.1 503 Service Unavailable\r\n\r\nBUSY".freeze
 
     # The basic max request size we'll try to read.
     CHUNK_SIZE = 16 * 1024
diff --git a/lib/puma/server.rb b/lib/puma/server.rb
index d1a35677b..84515d7be 100644
--- a/lib/puma/server.rb
+++ b/lib/puma/server.rb
@@ -320,6 +320,10 @@ def run(background=true)
           client.write_400
           client.close
 
+          @events.parse_error self, client.env, e
+        rescue HttpParserError501 => e
+          client.write_501
+          client.close
           @events.parse_error self, client.env, e
         rescue ConnectionError
           client.close
@@ -530,7 +534,12 @@ def process_client(client, buffer)
         client.write_400
 
         @events.parse_error self, client.env, e
+      rescue HttpParserError501 => e
+        lowlevel_error(e, client.env)
 
+        client.write_501
+
+        @events.parse_error self, client.env, e
       # Server error
       rescue StandardError => e
         client.write_500
