Skip to content

Commit b8439ff

Browse files
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>
1 parent 706534a commit b8439ff

File tree

6 files changed

+293
-15
lines changed

6 files changed

+293
-15
lines changed

lib/puma/client.rb

+54-11
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@ module Puma
2323

2424
class ConnectionError < RuntimeError; end
2525

26+
class HttpParserError501 < IOError; end
27+
2628
# An instance of this class represents a unique request from a client.
2729
# For example, this could be a web request from a browser or from CURL.
2830
#
@@ -35,7 +37,21 @@ class ConnectionError < RuntimeError; end
3537
# Instances of this class are responsible for knowing if
3638
# the header and body are fully buffered via the `try_to_finish` method.
3739
# They can be used to "time out" a response via the `timeout_at` reader.
40+
#
3841
class Client
42+
43+
# this tests all values but the last, which must be chunked
44+
ALLOWED_TRANSFER_ENCODING = %w[compress deflate gzip].freeze
45+
46+
# chunked body validation
47+
CHUNK_SIZE_INVALID = /[^\h]/.freeze
48+
CHUNK_VALID_ENDING = "\r\n".freeze
49+
50+
# Content-Length header value validation
51+
CONTENT_LENGTH_VALUE_INVALID = /[^\d]/.freeze
52+
53+
TE_ERR_MSG = 'Invalid Transfer-Encoding'
54+
3955
# The object used for a request with no body. All requests with
4056
# no body share this one object since it has no state.
4157
EmptyBody = NullIO.new
@@ -284,24 +300,40 @@ def setup_body
284300
body = @parser.body
285301

286302
te = @env[TRANSFER_ENCODING2]
287-
288303
if te
289-
if te.include?(",")
290-
te.split(",").each do |part|
291-
if CHUNKED.casecmp(part.strip) == 0
292-
return setup_chunked_body(body)
293-
end
304+
te_lwr = te.downcase
305+
if te.include? ','
306+
te_ary = te_lwr.split ','
307+
te_count = te_ary.count CHUNKED
308+
te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e }
309+
if te_ary.last == CHUNKED && te_count == 1 && te_valid
310+
@env.delete TRANSFER_ENCODING2
311+
return setup_chunked_body body
312+
elsif te_count >= 1
313+
raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'"
314+
elsif !te_valid
315+
raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'"
294316
end
295-
elsif CHUNKED.casecmp(te) == 0
296-
return setup_chunked_body(body)
317+
elsif te_lwr == CHUNKED
318+
@env.delete TRANSFER_ENCODING2
319+
return setup_chunked_body body
320+
elsif ALLOWED_TRANSFER_ENCODING.include? te_lwr
321+
raise HttpParserError , "#{TE_ERR_MSG}, single value must be chunked: '#{te}'"
322+
else
323+
raise HttpParserError501 , "#{TE_ERR_MSG}, unknown value: '#{te}'"
297324
end
298325
end
299326

300327
@chunked_body = false
301328

302329
cl = @env[CONTENT_LENGTH]
303330

304-
unless cl
331+
if cl
332+
# cannot contain characters that are not \d
333+
if cl =~ CONTENT_LENGTH_VALUE_INVALID
334+
raise HttpParserError, "Invalid Content-Length: #{cl.inspect}"
335+
end
336+
else
305337
@buffer = body.empty? ? nil : body
306338
@body = EmptyBody
307339
set_ready
@@ -450,7 +482,13 @@ def decode_chunk(chunk)
450482
while !io.eof?
451483
line = io.gets
452484
if line.end_with?("\r\n")
453-
len = line.strip.to_i(16)
485+
# Puma doesn't process chunk extensions, but should parse if they're
486+
# present, which is the reason for the semicolon regex
487+
chunk_hex = line.strip[/\A[^;]+/]
488+
if chunk_hex =~ CHUNK_SIZE_INVALID
489+
raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'"
490+
end
491+
len = chunk_hex.to_i(16)
454492
if len == 0
455493
@in_last_chunk = true
456494
@body.rewind
@@ -481,7 +519,12 @@ def decode_chunk(chunk)
481519

482520
case
483521
when got == len
484-
write_chunk(part[0..-3]) # to skip the ending \r\n
522+
# proper chunked segment must end with "\r\n"
523+
if part.end_with? CHUNK_VALID_ENDING
524+
write_chunk(part[0..-3]) # to skip the ending \r\n
525+
else
526+
raise HttpParserError, "Chunk size mismatch"
527+
end
485528
when got <= len - 2
486529
write_chunk(part)
487530
@partial_part_left = len - part.size

lib/puma/const.rb

+5-3
Original file line numberDiff line numberDiff line change
@@ -76,7 +76,7 @@ class UnsupportedOption < RuntimeError
7676
508 => 'Loop Detected',
7777
510 => 'Not Extended',
7878
511 => 'Network Authentication Required'
79-
}
79+
}.freeze
8080

8181
# For some HTTP status codes the client only expects headers.
8282
#
@@ -85,7 +85,7 @@ class UnsupportedOption < RuntimeError
8585
204 => true,
8686
205 => true,
8787
304 => true
88-
}
88+
}.freeze
8989

9090
# Frequently used constants when constructing requests or responses. Many times
9191
# the constant just refers to a string with the same contents. Using these constants
@@ -144,9 +144,11 @@ module Const
144144
408 => "HTTP/1.1 408 Request Timeout\r\nConnection: close\r\nServer: Puma #{PUMA_VERSION}\r\n\r\n".freeze,
145145
# Indicate that there was an internal error, obviously.
146146
500 => "HTTP/1.1 500 Internal Server Error\r\n\r\n".freeze,
147+
# Incorrect or invalid header value
148+
501 => "HTTP/1.1 501 Not Implemented\r\n\r\n".freeze,
147149
# A common header for indicating the server is too busy. Not used yet.
148150
503 => "HTTP/1.1 503 Service Unavailable\r\n\r\nBUSY".freeze
149-
}
151+
}.freeze
150152

151153
# The basic max request size we'll try to read.
152154
CHUNK_SIZE = 16 * 1024

lib/puma/server.rb

+9
Original file line numberDiff line numberDiff line change
@@ -320,6 +320,10 @@ def run(background=true)
320320
client.write_error(400)
321321
client.close
322322

323+
@events.parse_error self, client.env, e
324+
rescue HttpParserError501 => e
325+
client.write_error(501)
326+
client.close
323327
@events.parse_error self, client.env, e
324328
rescue ConnectionError, EOFError
325329
client.close
@@ -530,7 +534,12 @@ def process_client(client, buffer)
530534
client.write_error(400)
531535

532536
@events.parse_error self, client.env, e
537+
rescue HttpParserError501 => e
538+
lowlevel_error(e, client.env)
533539

540+
client.write_error(501)
541+
542+
@events.parse_error self, client.env, e
534543
# Server error
535544
rescue StandardError => e
536545
lowlevel_error(e, client.env)

test/helper.rb

+3
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,9 @@ def skip_unless(eng, bt: caller)
130130
Minitest::Test.include TestSkips
131131

132132
class Minitest::Test
133+
134+
REPO_NAME = ENV['GITHUB_REPOSITORY'] ? ENV['GITHUB_REPOSITORY'][/[^\/]+\z/] : 'puma'
135+
133136
def self.run(reporter, options = {}) # :nodoc:
134137
prove_it!
135138
super

test/test_puma_server.rb

+4-1
Original file line numberDiff line numberDiff line change
@@ -446,17 +446,20 @@ def test_Expect_100
446446
def test_chunked_request
447447
body = nil
448448
content_length = nil
449+
transfer_encoding = nil
449450
server_run app: ->(env) {
450451
body = env['rack.input'].read
451452
content_length = env['CONTENT_LENGTH']
453+
transfer_encoding = env['HTTP_TRANSFER_ENCODING']
452454
[200, {}, [""]]
453455
}
454456

455-
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"
457+
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"
456458

457459
assert_equal "HTTP/1.1 200 OK\r\nConnection: close\r\nContent-Length: 0\r\n\r\n", data
458460
assert_equal "hello", body
459461
assert_equal 5, content_length
462+
assert_nil transfer_encoding
460463
end
461464

462465
def test_chunked_request_pause_before_value

0 commit comments

Comments
 (0)