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:
parent
706534ad4e
commit
b8439ffc9d
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
Loading…
Reference in New Issue