diff --git a/CVE-2022-24790.patch b/CVE-2022-24790.patch new file mode 100644 index 0000000000000000000000000000000000000000..40dffa6ff2ee2afb41452db3a429f4cb9fb4fb8e --- /dev/null +++ b/CVE-2022-24790.patch @@ -0,0 +1,453 @@ +From 5bb7d202e24dec00a898dca4aa11db391d7787a5 Mon Sep 17 00:00:00 2001 +From: Nate Berkopec +Date: Wed, 30 Mar 2022 08:06:46 -0600 +Subject: [PATCH] Merge pull request from GHSA-h99w-9q5r-gjq9 + +* Fix tests when run on GH Actions and repo isn't named 'puma' + +* Test updates for CVE + +* Lib Updates for CVE + +* cleint.rb - make validation values constants + +Co-authored-by: MSP-Greg +--- + lib/puma/client.rb | 65 +++++++++-- + lib/puma/const.rb | 8 +- + lib/puma/server.rb | 3 + + test/helper.rb | 3 + + test/test_puma_server.rb | 5 +- + test/test_request_invalid.rb | 220 +++++++++++++++++++++++++++++++++++ + 6 files changed, 289 insertions(+), 15 deletions(-) + create mode 100644 test/test_request_invalid.rb + +diff --git a/lib/puma/client.rb b/lib/puma/client.rb +index baa43f41fe..e966f995e8 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 ++ + # 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,16 +318,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 + +@@ -319,7 +346,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 + 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 +diff --git a/lib/puma/const.rb b/lib/puma/const.rb +index eaa6dfb6a4..ceaa24dfe5 100644 +--- a/lib/puma/const.rb ++++ b/lib/puma/const.rb +@@ -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 +diff --git a/lib/puma/server.rb b/lib/puma/server.rb +index f4aed5dfc5..9323d1b3c5 100644 +--- a/lib/puma/server.rb ++++ b/lib/puma/server.rb +@@ -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" +diff --git a/test/helper.rb b/test/helper.rb +index f2380625d0..b8cf5d7a09 100644 +--- a/test/helper.rb ++++ b/test/helper.rb +@@ -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 +diff --git a/test/test_puma_server.rb b/test/test_puma_server.rb +index bfa2b977e3..298e44b439 100644 +--- a/test/test_puma_server.rb ++++ b/test/test_puma_server.rb +@@ -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 +diff --git a/test/test_request_invalid.rb b/test/test_request_invalid.rb +new file mode 100644 +index 0000000000..8f6c2ec2aa +--- /dev/null ++++ b/test/test_request_invalid.rb +@@ -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 diff --git a/rubygem-puma.spec b/rubygem-puma.spec index 6f5976df77985936386027652becbafea3efa4dd..eec8340c1c3f49cf7ae785ae1170dfff027c544e 100644 --- a/rubygem-puma.spec +++ b/rubygem-puma.spec @@ -2,7 +2,7 @@ %bcond_with ragel Name: rubygem-%{gem_name} Version: 5.5.2 -Release: 4 +Release: 5 Summary: A simple, fast, threaded, and highly concurrent HTTP 1.1 server License: BSD-3-Clause URL: http://puma.io @@ -15,6 +15,8 @@ Patch1: Support-for-cert_pem-and-key_pem-with-ssl_bind-DSL.patch # https://github.com/puma/puma/commit/b70f451fe8abc0cff192c065d549778452e155bb Patch2: CVE-2022-23634.patch Patch3: CVE-2024-21647.patch +# https://github.com/puma/puma/commit/5bb7d202e24dec00a898dca4aa11db391d7787a5 +Patch4: CVE-2022-24790.patch BuildRequires: openssl-devel ruby(release) rubygems-devel ruby-devel rubygem(rack) BuildRequires: rubygem(minitest) rubygem(sd_notify) @@ -40,6 +42,7 @@ Documentation for %{name}. %patch1 -p1 %patch2 -p1 %patch3 -p1 +%patch4 -p1 rm -rf test/test_thread_pool.rb %if %{with ragel} @@ -124,6 +127,9 @@ ruby -e 'Dir.glob "./test/**/test_*.rb", &method(:require)' %{gem_instdir}/tools %changelog +* Wed Mar 27 2024 yaoxin - 5.5.2-5 +- Fix CVE-2022-24790 + * Fri Jan 12 2024 wangkai <13474090681@163.com> - 5.5.2-4 - Fix CVE-2024-21647