@@ -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
@@ -302,24 +318,40 @@ def setup_body
302318 body = @parser . body
303319
304320 te = @env [ TRANSFER_ENCODING2 ]
305-
306321 if te
307- if te . include? ( "," )
308- te . split ( "," ) . each do |part |
309- if CHUNKED . casecmp ( part . strip ) == 0
310- return setup_chunked_body ( body )
311- end
322+ te_lwr = te . downcase
323+ if te . include? ','
324+ te_ary = te_lwr . split ','
325+ te_count = te_ary . count CHUNKED
326+ te_valid = te_ary [ 0 ..-2 ] . all? { |e | ALLOWED_TRANSFER_ENCODING . include? e }
327+ if te_ary . last == CHUNKED && te_count == 1 && te_valid
328+ @env . delete TRANSFER_ENCODING2
329+ return setup_chunked_body body
330+ elsif te_count >= 1
331+ raise HttpParserError , "#{ TE_ERR_MSG } , multiple chunked: '#{ te } '"
332+ elsif !te_valid
333+ raise HttpParserError501 , "#{ TE_ERR_MSG } , unknown value: '#{ te } '"
312334 end
313- elsif CHUNKED . casecmp ( te ) == 0
314- return setup_chunked_body ( body )
335+ elsif te_lwr == CHUNKED
336+ @env . delete TRANSFER_ENCODING2
337+ return setup_chunked_body body
338+ elsif ALLOWED_TRANSFER_ENCODING . include? te_lwr
339+ raise HttpParserError , "#{ TE_ERR_MSG } , single value must be chunked: '#{ te } '"
340+ else
341+ raise HttpParserError501 , "#{ TE_ERR_MSG } , unknown value: '#{ te } '"
315342 end
316343 end
317344
318345 @chunked_body = false
319346
320347 cl = @env [ CONTENT_LENGTH ]
321348
322- unless cl
349+ if cl
350+ # cannot contain characters that are not \d
351+ if cl =~ CONTENT_LENGTH_VALUE_INVALID
352+ raise HttpParserError , "Invalid Content-Length: #{ cl . inspect } "
353+ end
354+ else
323355 @buffer = body . empty? ? nil : body
324356 @body = EmptyBody
325357 set_ready
@@ -478,7 +510,13 @@ def decode_chunk(chunk)
478510 while !io . eof?
479511 line = io . gets
480512 if line . end_with? ( "\r \n " )
481- len = line . strip . to_i ( 16 )
513+ # Puma doesn't process chunk extensions, but should parse if they're
514+ # present, which is the reason for the semicolon regex
515+ chunk_hex = line . strip [ /\A [^;]+/ ]
516+ if chunk_hex =~ CHUNK_SIZE_INVALID
517+ raise HttpParserError , "Invalid chunk size: '#{ chunk_hex } '"
518+ end
519+ len = chunk_hex . to_i ( 16 )
482520 if len == 0
483521 @in_last_chunk = true
484522 @body . rewind
@@ -509,7 +547,12 @@ def decode_chunk(chunk)
509547
510548 case
511549 when got == len
512- write_chunk ( part [ 0 ..-3 ] ) # to skip the ending \r\n
550+ # proper chunked segment must end with "\r\n"
551+ if part . end_with? CHUNK_VALID_ENDING
552+ write_chunk ( part [ 0 ..-3 ] ) # to skip the ending \r\n
553+ else
554+ raise HttpParserError , "Chunk size mismatch"
555+ end
513556 when got <= len - 2
514557 write_chunk ( part )
515558 @partial_part_left = len - part . size
0 commit comments