class Puma::Client
:nodoc:
in ‘Puma::ClientEnv`, which is included.
Most of the code for env processing and verification is contained
They can be used to “time out” a response via the `timeout_at` reader.
All verification of each request is done in the `Client` object.
headers and body are fully buffered and verified via the `try_to_finish` method.
Instances of this class are responsible for knowing if the request line,
registered.
`IO::try_convert` (which may call `#to_io`) when a new socket is
on any non-IO objects it polls. For example, nio4r internally calls
by the reactor. The reactor is expected to call `#to_io`
An instance of `Puma::Client` can be used as if it were an IO object
For example, this could be an http request from a browser or from CURL.
An instance of this class wraps a connection/socket.
def can_close?
- Version: - 5.0.0
def can_close? # Allow connection to close if we're not in the middle of parsing a request. @parsed_bytes == 0 end
def close
def close tempfile_close begin @io.close rescue IOError, Errno::EBADF end end
def closed?
def closed? @to_io.closed? end
def decode_chunk(chunk)
def decode_chunk(chunk) if @partial_part_left > 0 if @partial_part_left <= chunk.size if @partial_part_left > 2 write_chunk(chunk[0..(@partial_part_left-3)]) # skip the \r\n end chunk = chunk[@partial_part_left..-1] @partial_part_left = 0 else if @partial_part_left > 2 if @partial_part_left == chunk.size + 1 # Don't include the last \r write_chunk(chunk[0..(@partial_part_left-3)]) else # don't include the last \r\n write_chunk(chunk) end end @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?(CHUNK_VALID_ENDING) # 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_SIZE_INVALID.match? chunk_hex raise HttpParserError, "Invalid chunk size: '#{chunk_hex}'" end len = chunk_hex.to_i(16) if len == 0 @in_last_chunk = true @body.rewind rest = io.read if rest.bytesize < CHUNK_VALID_ENDING_SIZE @buffer = nil @partial_part_left = CHUNK_VALID_ENDING_SIZE - rest.bytesize return false else # if the next character is a CRLF, set buffer to everything after that CRLF start_of_rest = if rest.start_with?(CHUNK_VALID_ENDING) CHUNK_VALID_ENDING_SIZE else # we have started a trailer section, which we do not support. skip it! rest.index(CHUNK_VALID_ENDING*2) + CHUNK_VALID_ENDING_SIZE*2 end @buffer = rest[start_of_rest..-1] @buffer = nil if @buffer.empty? set_ready return true end end # Track the excess as a function of the size of the # header vs the size of the actual data. Excess can # go negative (and is expected to) when the body is # significant. # The additional of chunk_hex.size and 2 compensates # for a client sending 1 byte in a chunked body over # a long period of time, making sure that that client # isn't accidentally eventually punished. @excess_cr += (line.size - len - chunk_hex.size - 2) if @excess_cr >= MAX_CHUNK_EXCESS raise HttpParserError, "Maximum chunk excess detected" end len += 2 part = io.read(len) unless part @partial_part_left = len next end got = part.size case when got == len # 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 when got == len - 1 # edge where we get just \r but not \n write_chunk(part[0..-2]) @partial_part_left = len - part.size end else if @prev_chunk.size + line.size >= MAX_CHUNK_HEADER_SIZE raise HttpParserError, "maximum size of chunk header exceeded" end @prev_chunk = line return false end end if @in_last_chunk set_ready true else false end end
def eagerly_finish
def eagerly_finish return true if @ready while @to_io.wait_readable(0) # rubocop: disable Style/WhileUntilModifier return true if try_to_finish end false end
def expect_proxy_proto=(val)
def expect_proxy_proto=(val) if val if @read_header @read_proxy = true end else @read_proxy = false end @expect_proxy_proto = val end
def finish(timeout)
def finish(timeout) return if @ready @to_io.wait_readable(timeout) || timeout! until try_to_finish end
def full_hijack
For the full hijack protocol, `env['rack.hijack']` is set to
def full_hijack @hijacked = true env[HIJACK_IO] ||= @io end
def has_back_to_back_requests?
if a client sends back-to-back requests, the buffer may contain one or more
def has_back_to_back_requests? !(@buffer.nil? || @buffer.empty?) end
def in_data_phase
def in_data_phase !(@read_header || @read_proxy) end
def initialize(io, env=nil)
def initialize(io, env=nil) @io = io @to_io = io.to_io @io_buffer = IOBuffer.new @proto_env = env @env = env&.dup @parser = HttpParser.new @parsed_bytes = 0 @read_header = true @read_proxy = false @ready = false @body = nil @body_read_start = nil @buffer = nil @tempfile = nil @timeout_at = nil @requests_served = 0 @hijacked = false @http_content_length_limit = nil @http_content_length_limit_exceeded = nil @error_status_code = nil @peerip = nil @peer_family = nil @listener = nil @remote_addr_header = nil @expect_proxy_proto = false @body_remain = 0 @in_last_chunk = false # need unfrozen ASCII-8BIT, +'' is UTF-8 @read_buffer = String.new # rubocop: disable Performance/UnfreezeString end
def inspect
def inspect "#<Puma::Client:0x#{object_id.to_s(16)} @ready=#{@ready.inspect}>" end
def io_ok?
Test to see if io meets a bare minimum of functioning, @to_io needs to be
def io_ok? @to_io.is_a?(::BasicSocket) && !closed? end
def parser_execute
-
(Integer)- bytes of buffer read by parser
def parser_execute ret = @parser.execute(@env, @buffer, @parsed_bytes) if @env[REQUEST_METHOD] && @supported_http_methods != :any && !@supported_http_methods.key?(@env[REQUEST_METHOD]) raise HttpParserError501, "#{@env[REQUEST_METHOD]} method is not supported" end ret rescue => e @env[HTTP_CONNECTION] = 'close' raise e unless HttpParserError === e && e.message.include?('non-SSL') req, _ = @buffer.split "\r\n\r\n" request_line, headers = req.split "\r\n", 2 # below checks for request issues and changes error message accordingly if !@env.key? REQUEST_METHOD if request_line.count(' ') != 2 # maybe this is an SSL connection ? raise e else method = request_line[/\A[^ ]+/] raise e, "Invalid HTTP format, parsing fails. Bad method #{method}" end elsif !@env.key? REQUEST_PATH path = request_line[/\A[^ ]+ +([^ ?\r\n]+)/, 1] raise e, "Invalid HTTP format, parsing fails. Bad path #{path}" elsif request_line.match?(/\A[^ ]+ +[^ ?\r\n]+\?/) && !@env.key?(QUERY_STRING) query = request_line[/\A[^ ]+ +[^? ]+\?([^ ]+)/, 1] raise e, "Invalid HTTP format, parsing fails. Bad query #{query}" elsif !@env.key? SERVER_PROTOCOL # protocol is bad text = request_line[/[^ ]*\z/] raise HttpParserError, "Invalid HTTP format, parsing fails. Bad protocol #{text}" elsif !headers.empty? # headers are bad hdrs = headers.split("\r\n").map { |h| h.gsub "\n", '\n'}.join "\n" raise HttpParserError, "Invalid HTTP format, parsing fails. Bad headers\n#{hdrs}" end end
def peer_family
def peer_family return @peer_family if @peer_family @peer_family ||= begin @io.local_address.afamily rescue Socket::AF_INET end end
def peerip
def peerip return @peerip if @peerip if @remote_addr_header hdr = (@env[@remote_addr_header] || socket_peerip).split(/[\s,]/).first @peerip = hdr return hdr end @peerip ||= socket_peerip end
def process_back_to_back_requests
def process_back_to_back_requests if @buffer return false unless try_to_parse_proxy_protocol @parsed_bytes = parser_execute @parser.finished? ? process_env_body : false end end
def process_env_body
def process_env_body temp = setup_body normalize_env req_env_post_parse raise HttpParserError if @error_status_code temp end
def raise_above_http_content_limit
def raise_above_http_content_limit @http_content_length_limit_exceeded = true @buffer = nil @body = EmptyBody @error_status_code = 413 @env[HTTP_CONNECTION] = 'close' raise HttpParserError, "Payload Too Large" end
def read_body
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 # don't bother with reading zero bytes unless remain.zero? begin chunk = @io.read_nonblock(remain.clamp(0, CHUNK_SIZE), @read_buffer) 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 remain -= @body.write(chunk) end if remain <= 0 @body.rewind @buffer = nil set_ready true else @body_remain = remain false end end
def read_chunked_body
def read_chunked_body while true begin chunk = @io.read_nonblock(CHUNK_SIZE, @read_buffer) 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 if decode_chunk(chunk) @env[CONTENT_LENGTH] = @chunked_content_length.to_s return true end end end
def reset
def reset @parser.reset @io_buffer.reset @read_header = true @read_proxy = !!@expect_proxy_proto && @requests_served.zero? @env = @proto_env.dup @parsed_bytes = 0 @ready = false @body_remain = 0 @peerip = nil if @remote_addr_header @in_last_chunk = false @http_content_length_limit_exceeded = nil @error_status_code = nil end
def set_ready
def set_ready if @body_read_start @env['puma.request_body_wait'] = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) - @body_read_start end @requests_served += 1 @ready = true end
def set_timeout(val)
def set_timeout(val) @timeout_at = Process.clock_gettime(Process::CLOCK_MONOTONIC) + val end
def setup_body
-
(Boolean)- true if the body can be completely read, false otherwise
def setup_body @body_read_start = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_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 parser_body = @parser.body te = @env[TRANSFER_ENCODING2] if te te_lwr = te.downcase if te.include? ',' te_ary = te_lwr.split(',').each { |te| te.gsub!(STRIP_OWS, "") } te_count = te_ary.count CHUNKED te_valid = te_ary[0..-2].all? { |e| ALLOWED_TRANSFER_ENCODING.include? e } if te_count > 1 raise HttpParserError , "#{TE_ERR_MSG}, multiple chunked: '#{te}'" elsif te_ary.last != CHUNKED raise HttpParserError , "#{TE_ERR_MSG}, last value must be chunked: '#{te}'" elsif !te_valid raise HttpParserError501, "#{TE_ERR_MSG}, unknown value: '#{te}'" end @env.delete TRANSFER_ENCODING2 return setup_chunked_body parser_body elsif te_lwr == CHUNKED @env.delete TRANSFER_ENCODING2 return setup_chunked_body parser_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 @chunked_body = false cl = @env[CONTENT_LENGTH] if cl # cannot contain characters that are not \d, or be empty if CONTENT_LENGTH_VALUE_INVALID.match?(cl) || cl.empty? @error_status_code = 400 @env[HTTP_CONNECTION] = 'close' raise HttpParserError, "Invalid Content-Length: #{cl.inspect}" end else @buffer = parser_body.empty? ? nil : parser_body @body = EmptyBody set_ready return true end content_length = cl.to_i raise_above_http_content_limit if @http_content_length_limit&.< content_length remain = content_length - parser_body.bytesize if remain <= 0 # Part of the parser_body is a pipelined request OR garbage. We'll deal with that later. if content_length == 0 @body = EmptyBody if parser_body.empty? @buffer = nil else @buffer = parser_body end elsif remain == 0 @body = StringIO.new parser_body @buffer = nil else @body = StringIO.new(parser_body[0,content_length]) @buffer = parser_body[content_length..-1] end set_ready return true end if remain > MAX_BODY @body = Tempfile.create(Const::PUMA_TMP_BASE) File.unlink @body.path unless IS_WINDOWS @body.binmode @tempfile = @body else # The parser_body[0,0] trick is to get an empty string in the same # encoding as parser_body. @body = StringIO.new parser_body[0,0] end @body.write parser_body @body_remain = remain false end
def setup_chunked_body(body)
def setup_chunked_body(body) @chunked_body = true @partial_part_left = 0 @prev_chunk = "" @excess_cr = 0 @body = Tempfile.create(Const::PUMA_TMP_BASE) File.unlink @body.path unless IS_WINDOWS @body.binmode @tempfile = @body @chunked_content_length = 0 if decode_chunk(body) @env[CONTENT_LENGTH] = @chunked_content_length.to_s return true end end
def socket_peerip
def socket_peerip unmap_ipv6(@io.peeraddr.last) end
def tempfile_close
def tempfile_close tf_path = @tempfile&.path @tempfile&.close File.unlink(tf_path) if tf_path @tempfile = nil @body = nil rescue Errno::ENOENT, IOError end
def timeout
Number of seconds until the timeout elapses.
def timeout [@timeout_at - Process.clock_gettime(Process::CLOCK_MONOTONIC), 0].max end
def timeout!
def timeout! write_error(408) if in_data_phase raise ConnectionError end
def try_to_finish
def try_to_finish return read_body if in_data_phase data = nil begin data = @io.read_nonblock(CHUNK_SIZE) rescue IO::WaitReadable return false rescue EOFError # Swallow error, don't log rescue SystemCallError, IOError 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 return false unless try_to_parse_proxy_protocol @parsed_bytes = parser_execute @parser.finished? ? process_env_body : false end
def try_to_parse_proxy_protocol
If necessary, read the PROXY protocol from the buffer. Returns
def try_to_parse_proxy_protocol if @read_proxy if @expect_proxy_proto == :v1 crlf_index = @buffer.index "\r\n" unless crlf_index if "PROXY ".start_with? @buffer return false elsif @buffer.start_with? "PROXY " if @buffer.bytesize >= PROXY_PROTOCOL_V1_MAX_LENGTH raise ConnectionError, "PROXY protocol v1 line is too long" end return false end @read_proxy = false return true end if @buffer.start_with?("PROXY ") && crlf_index + 2 > PROXY_PROTOCOL_V1_MAX_LENGTH raise ConnectionError, "PROXY protocol v1 line is too long" end if md = PROXY_PROTOCOL_V1_REGEX.match(@buffer) if md[1] @peerip = md[1].split(" ")[0] end @buffer = md.post_match end # if the buffer has a \r\n but doesn't have a PROXY protocol # request, this is just HTTP from a non-PROXY client; move on @read_proxy = false return @buffer.size > 0 end end true end
def unmap_ipv6(addr)
their IPv4 form. These addresses appear when IPv4 clients connect to
Converts IPv4-mapped IPv6 addresses (e.g. ::ffff:127.0.0.1) back to
def unmap_ipv6(addr) addr.delete_prefix(IPV4_MAPPED_IPV6_PREFIX) end
def write_chunk(str)
- Version: - 5.0.0
def write_chunk(str) if @http_content_length_limit&.< @chunked_content_length + str.bytesize raise_above_http_content_limit end @chunked_content_length += @body.write(str) end
def write_error(status_code)
def write_error(status_code) begin @io << ERROR_RESPONSE[status_code] rescue StandardError end end