1
0
Fork 0
mirror of https://github.com/puma/puma.git synced 2022-11-09 13:48:40 -05:00
puma--puma/lib/puma/client.rb
Sutou Kouhei 7965484434 Fix a bug that the last CRLF of chunked body may be used in the next request (#1812)
* Fix a bug that the last CRLF of chunked body may be used in the next request

The last CRLF of chunked body is checked by #1607. But it's
incomplete. If a client sends the last CRLF (or just LF) after Puma
processes "0\r\n" line, the last CRLF (or just LF) isn't dropped in
the "0\r\n" process:

675344e860/lib/puma/client.rb (L183-L192)

    if line.end_with?("\r\n")
      len = line.strip.to_i(16)
      if len == 0
        @body.rewind
        rest = io.read
        # rest is "" with no the last CRLF case and
        # "\r" with no last LF case.
        # rest.start_with?("\r\n") returns false for
        # Both of these cases.
        rest = rest[2..-1] if rest.start_with?("\r\n")
        @buffer = rest.empty? ? nil : rest
        set_ready
        return true
      end

The unprocessed last CRLF (or LF) is used as the first data in the
next request. Because Puma::Client#reset sets `@parsed_bytes`
to 0.

675344e860/lib/puma/client.rb (L100-L109)

    def reset(fast_check=true)
      @parsed_bytes = 0

It means that data in `@buffer` (it's "\r" in no the last LF case) and
unread data in input socket (it's "\r\n" in no the last CRLF case and
"\n" in no the last LF case) are used used as the first data in the
next request.

This change fixes these cases by the followings:

  * Ensures reading the last CRLF by setting `@partial_part_left` when
    CRLF isn't read in processing "0\r\n" line.

  * Introduces a `@in_last_chunk` new state to detect whether the last
    CRLF is waiting or not. It's reset in Puma::Client#reset.

* Remove unnecessary returns

https://github.com/puma/puma/pull/1812#discussion_r307806310 is the
location where this rule is made.

* Add missing last CRLF for chunked request in tests
2019-08-03 15:52:09 -07:00

506 lines
11 KiB
Ruby

# frozen_string_literal: true
class IO
# We need to use this for a jruby work around on both 1.8 and 1.9.
# So this either creates the constant (on 1.8), or harmlessly
# reopens it (on 1.9).
module WaitReadable
end
end
require 'puma/detect'
require 'puma/delegation'
require 'tempfile'
if Puma::IS_JRUBY
# We have to work around some OpenSSL buffer/io-readiness bugs
# so we pull it in regardless of if the user is binding
# to an SSL socket
require 'openssl'
end
module Puma
class ConnectionError < RuntimeError; end
# An instance of this class represents a unique request from a client.
# For example a web request from a browser or from CURL. This
#
# An instance of `Puma::Client` can be used as if it were an IO object
# by the reactor, that's because the latter is expected to call `#to_io`
# on any non-IO objects it polls. For example nio4r internally calls
# `IO::try_convert` (which may call `#to_io`) when a new socket is
# registered.
#
# 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
include Puma::Const
extend Puma::Delegation
def initialize(io, env=nil)
@io = io
@to_io = io.to_io
@proto_env = env
if !env
@env = nil
else
@env = env.dup
end
@parser = HttpParser.new
@parsed_bytes = 0
@read_header = true
@ready = false
@body = nil
@body_read_start = nil
@buffer = nil
@tempfile = nil
@timeout_at = nil
@requests_served = 0
@hijacked = false
@peerip = nil
@remote_addr_header = nil
@body_remain = 0
@in_last_chunk = false
end
attr_reader :env, :to_io, :body, :io, :timeout_at, :ready, :hijacked,
:tempfile
attr_writer :peerip
attr_accessor :remote_addr_header
forward :closed?, :@io
def inspect
"#<Puma::Client:0x#{object_id.to_s(16)} @ready=#{@ready.inspect}>"
end
# For the hijack protocol (allows us to just put the Client object
# into the env)
def call
@hijacked = true
env[HIJACK_IO] ||= @io
end
def in_data_phase
!@read_header
end
def set_timeout(val)
@timeout_at = Time.now + val
end
def reset(fast_check=true)
@parser.reset
@read_header = true
@env = @proto_env.dup
@body = nil
@tempfile = nil
@parsed_bytes = 0
@ready = false
@body_remain = 0
@peerip = nil
@in_last_chunk = false
if @buffer
@parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
if @parser.finished?
return setup_body
elsif @parsed_bytes >= MAX_HEADER
raise HttpParserError,
"HEADER is longer than allowed, aborting client early."
end
return false
else
begin
if fast_check &&
IO.select([@to_io], nil, nil, FAST_TRACK_KA_TIMEOUT)
return try_to_finish
end
rescue IOError
# swallow it
end
end
end
def close
begin
@io.close
rescue IOError
Thread.current.purge_interrupt_queue if Thread.current.respond_to? :purge_interrupt_queue
end
end
# 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
def setup_chunked_body(body)
@chunked_body = true
@partial_part_left = 0
@prev_chunk = ""
@body = Tempfile.new(Const::PUMA_TMP_BASE)
@body.binmode
@tempfile = @body
return decode_chunk(body)
end
def decode_chunk(chunk)
if @partial_part_left > 0
if @partial_part_left <= chunk.size
if @partial_part_left > 2
@body << chunk[0..(@partial_part_left-3)] # skip the \r\n
end
chunk = chunk[@partial_part_left..-1]
@partial_part_left = 0
else
@body << chunk if @partial_part_left > 2 # don't include the last \r\n
@partial_part_left -= chunk.size
return false
end
end
if @prev_chunk.empty?
io = StringIO.new(chunk)
else
io = StringIO.new(@prev_chunk+chunk)
@prev_chunk = ""
end
while !io.eof?
line = io.gets
if line.end_with?("\r\n")
len = line.strip.to_i(16)
if len == 0
@in_last_chunk = true
@body.rewind
rest = io.read
last_crlf_size = "\r\n".bytesize
if rest.bytesize < last_crlf_size
@buffer = nil
@partial_part_left = last_crlf_size - rest.bytesize
return false
else
@buffer = rest[last_crlf_size..-1]
@buffer = nil if @buffer.empty?
set_ready
return true
end
end
len += 2
part = io.read(len)
unless part
@partial_part_left = len
next
end
got = part.size
case
when got == len
@body << part[0..-3] # to skip the ending \r\n
when got <= len - 2
@body << part
@partial_part_left = len - part.size
when got == len - 1 # edge where we get just \r but not \n
@body << part[0..-2]
@partial_part_left = len - part.size
end
else
@prev_chunk = line
return false
end
end
if @in_last_chunk
set_ready
true
else
false
end
end
def read_chunked_body
while true
begin
chunk = @io.read_nonblock(4096)
rescue IO::WaitReadable
return false
rescue SystemCallError, IOError
raise ConnectionError, "Connection error detected during read"
end
# No chunk means a closed socket
unless chunk
@body.close
@buffer = nil
set_ready
raise EOFError
end
return true if decode_chunk(chunk)
end
end
def setup_body
@body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond)
if @env[HTTP_EXPECT] == CONTINUE
# TODO allow a hook here to check the headers before
# going forward
@io << HTTP_11_100
@io.flush
end
@read_header = false
body = @parser.body
te = @env[TRANSFER_ENCODING2]
if te && CHUNKED.casecmp(te) == 0
return setup_chunked_body(body)
end
@chunked_body = false
cl = @env[CONTENT_LENGTH]
unless cl
@buffer = body.empty? ? nil : body
@body = EmptyBody
set_ready
return true
end
remain = cl.to_i - body.bytesize
if remain <= 0
@body = StringIO.new(body)
@buffer = nil
set_ready
return true
end
if remain > MAX_BODY
@body = Tempfile.new(Const::PUMA_TMP_BASE)
@body.binmode
@tempfile = @body
else
# The body[0,0] trick is to get an empty string in the same
# encoding as body.
@body = StringIO.new body[0,0]
end
@body.write body
@body_remain = remain
return false
end
def try_to_finish
return read_body unless @read_header
begin
data = @io.read_nonblock(CHUNK_SIZE)
rescue Errno::EAGAIN
return false
rescue SystemCallError, IOError, EOFError
raise ConnectionError, "Connection error detected during read"
end
# No data means a closed socket
unless data
@buffer = nil
set_ready
raise EOFError
end
if @buffer
@buffer << data
else
@buffer = data
end
@parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
if @parser.finished?
return setup_body
elsif @parsed_bytes >= MAX_HEADER
raise HttpParserError,
"HEADER is longer than allowed, aborting client early."
end
false
end
if IS_JRUBY
def jruby_start_try_to_finish
return read_body unless @read_header
begin
data = @io.sysread_nonblock(CHUNK_SIZE)
rescue OpenSSL::SSL::SSLError => e
return false if e.kind_of? IO::WaitReadable
raise e
end
# No data means a closed socket
unless data
@buffer = nil
set_ready
raise EOFError
end
if @buffer
@buffer << data
else
@buffer = data
end
@parsed_bytes = @parser.execute(@env, @buffer, @parsed_bytes)
if @parser.finished?
return setup_body
elsif @parsed_bytes >= MAX_HEADER
raise HttpParserError,
"HEADER is longer than allowed, aborting client early."
end
false
end
def eagerly_finish
return true if @ready
if @io.kind_of? OpenSSL::SSL::SSLSocket
return true if jruby_start_try_to_finish
end
return false unless IO.select([@to_io], nil, nil, 0)
try_to_finish
end
else
def eagerly_finish
return true if @ready
return false unless IO.select([@to_io], nil, nil, 0)
try_to_finish
end
end # IS_JRUBY
def finish
return true if @ready
until try_to_finish
IO.select([@to_io], nil, nil)
end
true
end
def read_body
if @chunked_body
return read_chunked_body
end
# Read an odd sized chunk so we can read even sized ones
# after this
remain = @body_remain
if remain > CHUNK_SIZE
want = CHUNK_SIZE
else
want = remain
end
begin
chunk = @io.read_nonblock(want)
rescue Errno::EAGAIN
return false
rescue SystemCallError, IOError
raise ConnectionError, "Connection error detected during read"
end
# No chunk means a closed socket
unless chunk
@body.close
@buffer = nil
set_ready
raise EOFError
end
remain -= @body.write(chunk)
if remain <= 0
@body.rewind
@buffer = nil
set_ready
return true
end
@body_remain = remain
false
end
def set_ready
if @body_read_start
@env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :millisecond) - @body_read_start
end
@requests_served += 1
@ready = true
end
def write_400
begin
@io << ERROR_400_RESPONSE
rescue StandardError
end
end
def write_408
begin
@io << ERROR_408_RESPONSE
rescue StandardError
end
end
def write_500
begin
@io << ERROR_500_RESPONSE
rescue StandardError
end
end
def peerip
return @peerip if @peerip
if @remote_addr_header
hdr = (@env[@remote_addr_header] || LOCALHOST_ADDR).split(/[\s,]/).first
@peerip = hdr
return hdr
end
@peerip ||= @io.peeraddr.last
end
end
end