@@ -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
0 commit comments