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 <Greg.mpls@gmail.com>
This commit is contained in:
Nate Berkopec 2022-03-30 08:06:46 -06:00
parent 706534ad4e
commit b8439ffc9d
No known key found for this signature in database
GPG Key ID: 19616755F4328D71
6 changed files with 293 additions and 15 deletions

View File

@ -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 @@ module Puma
# 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 @@ module Puma
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 @@ module Puma
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 @@ module Puma
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 @@ module Puma
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

View File

@ -76,7 +76,7 @@ module Puma
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 @@ module Puma
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 @@ module Puma
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

View File

@ -320,6 +320,10 @@ module Puma
client.write_error(400)
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
@ -530,7 +534,12 @@ module Puma
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)

View File

@ -130,6 +130,9 @@ end
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

View File

@ -446,17 +446,20 @@ EOF
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

View File

@ -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