lib/puma/request.rb

Summary

Maintainability
F
3 days
Test Coverage
# frozen_string_literal: true

module Puma

  # The methods here are included in Server, but are separated into this file.
  # All the methods here pertain to passing the request to the app, then
  # writing the response back to the client.
  #
  # None of the methods here are called externally, with the exception of
  # #handle_request, which is called in Server#process_client.
  # @version 5.0.3
  #
  module Request

    include Puma::Const

    # Takes the request contained in +client+, invokes the Rack application to construct
    # the response and writes it back to +client.io+.
    #
    # It'll return +false+ when the connection is closed, this doesn't mean
    # that the response wasn't successful.
    #
    # It'll return +:async+ if the connection remains open but will be handled
    # elsewhere, i.e. the connection has been hijacked by the Rack application.
    #
    # Finally, it'll return +true+ on keep-alive connections.
    # @param client [Puma::Client]
    # @param lines [Puma::IOBuffer]
    # @return [Boolean,:async]
    #
    def handle_request(client, lines)
      env = client.env
      io = client.io

      return false if closed_socket?(io)

      normalize_env env, client

      env[PUMA_SOCKET] = io

      if env[HTTPS_KEY] && io.peercert
        env[PUMA_PEERCERT] = io.peercert
      end

      env[HIJACK_P] = true
      env[HIJACK] = client

      body = client.body

      head = env[REQUEST_METHOD] == HEAD

      env[RACK_INPUT] = body
      env[RACK_URL_SCHEME] = default_server_port(env) == PORT_443 ? HTTPS : HTTP

      if @early_hints
        env[EARLY_HINTS] = lambda { |headers|
          begin
            fast_write io, str_early_hints(headers)
          rescue ConnectionError => e
            @events.debug_error e
            # noop, if we lost the socket we just won't send the early hints
          end
        }
      end

      req_env_post_parse env

      # A rack extension. If the app writes #call'ables to this
      # array, we will invoke them when the request is done.
      #
      after_reply = env[RACK_AFTER_REPLY] = []

      begin
        begin
          status, headers, res_body = @thread_pool.with_force_shutdown do
            @app.call(env)
          end

          return :async if client.hijacked

          status = status.to_i

          if status == -1
            unless headers.empty? and res_body == []
              raise "async response must have empty headers and body"
            end

            return :async
          end
        rescue ThreadPool::ForceShutdown => e
          @events.unknown_error e, client, "Rack app"
          @events.log "Detected force shutdown of a thread"

          status, headers, res_body = lowlevel_error(e, env, 503)
        rescue Exception => e
          @events.unknown_error e, client, "Rack app"

          status, headers, res_body = lowlevel_error(e, env, 500)
        end

        res_info = {}
        res_info[:content_length] = nil
        res_info[:no_body] = head

        res_info[:content_length] = if res_body.kind_of? Array and res_body.size == 1
          res_body[0].bytesize
        else
          nil
        end

        cork_socket io

        str_headers(env, status, headers, res_info, lines)

        line_ending = LINE_END

        content_length  = res_info[:content_length]
        response_hijack = res_info[:response_hijack]

        if res_info[:no_body]
          if content_length and status != 204
            lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending
          end

          lines << LINE_END
          fast_write io, lines.to_s
          return res_info[:keep_alive]
        end

        if content_length
          lines.append CONTENT_LENGTH_S, content_length.to_s, line_ending
          chunked = false
        elsif !response_hijack and res_info[:allow_chunked]
          lines << TRANSFER_ENCODING_CHUNKED
          chunked = true
        end

        lines << line_ending

        fast_write io, lines.to_s

        if response_hijack
          response_hijack.call io
          return :async
        end

        begin
          res_body.each do |part|
            next if part.bytesize.zero?
            if chunked
              str = part.bytesize.to_s(16) << line_ending << part << line_ending
              fast_write io, str
            else
              fast_write io, part
            end
            io.flush
          end

          if chunked
            fast_write io, CLOSE_CHUNKED
            io.flush
          end
        rescue SystemCallError, IOError
          raise ConnectionError, "Connection error detected during write"
        end

      ensure
        uncork_socket io

        body.close
        client.tempfile.unlink if client.tempfile
        res_body.close if res_body.respond_to? :close

        after_reply.each { |o| o.call }
      end

      return res_info[:keep_alive]
    end

    # @param env [Hash] see Puma::Client#env, from request
    # @return [Puma::Const::PORT_443,Puma::Const::PORT_80]
    #
    def default_server_port(env)
      if ['on', HTTPS].include?(env[HTTPS_KEY]) || env[HTTP_X_FORWARDED_PROTO].to_s[0...5] == HTTPS || env[HTTP_X_FORWARDED_SCHEME] == HTTPS || env[HTTP_X_FORWARDED_SSL] == "on"
        PORT_443
      else
        PORT_80
      end
    end

    # Writes to an io (normally Client#io) using #syswrite
    # @param io [#syswrite] the io to write to
    # @param str [String] the string written to the io
    # @raise [ConnectionError]
    #
    def fast_write(io, str)
      n = 0
      while true
        begin
          n = io.syswrite str
        rescue Errno::EAGAIN, Errno::EWOULDBLOCK
          if !IO.select(nil, [io], nil, WRITE_TIMEOUT)
            raise ConnectionError, "Socket timeout writing data"
          end

          retry
        rescue  Errno::EPIPE, SystemCallError, IOError
          raise ConnectionError, "Socket timeout writing data"
        end

        return if n == str.bytesize
        str = str.byteslice(n..-1)
      end
    end
    private :fast_write

    # @param status [Integer] status from the app
    # @return [String] the text description from Puma::HTTP_STATUS_CODES
    #
    def fetch_status_code(status)
      HTTP_STATUS_CODES.fetch(status) { 'CUSTOM' }
    end
    private :fetch_status_code

    # Given a Hash +env+ for the request read from +client+, add
    # and fixup keys to comply with Rack's env guidelines.
    # @param env [Hash] see Puma::Client#env, from request
    # @param client [Puma::Client] only needed for Client#peerip
    # @todo make private in 6.0.0
    #
    def normalize_env(env, client)
      if host = env[HTTP_HOST]
        if colon = host.index(":")
          env[SERVER_NAME] = host[0, colon]
          env[SERVER_PORT] = host[colon+1, host.bytesize]
        else
          env[SERVER_NAME] = host
          env[SERVER_PORT] = default_server_port(env)
        end
      else
        env[SERVER_NAME] = LOCALHOST
        env[SERVER_PORT] = default_server_port(env)
      end

      unless env[REQUEST_PATH]
        # it might be a dumbass full host request header
        uri = URI.parse(env[REQUEST_URI])
        env[REQUEST_PATH] = uri.path

        raise "No REQUEST PATH" unless env[REQUEST_PATH]

        # A nil env value will cause a LintError (and fatal errors elsewhere),
        # so only set the env value if there actually is a value.
        env[QUERY_STRING] = uri.query if uri.query
      end

      env[PATH_INFO] = env[REQUEST_PATH]

      # From https://www.ietf.org/rfc/rfc3875 :
      # "Script authors should be aware that the REMOTE_ADDR and
      # REMOTE_HOST meta-variables (see sections 4.1.8 and 4.1.9)
      # may not identify the ultimate source of the request.
      # They identify the client for the immediate request to the
      # server; that client may be a proxy, gateway, or other
      # intermediary acting on behalf of the actual source client."
      #

      unless env.key?(REMOTE_ADDR)
        begin
          addr = client.peerip
        rescue Errno::ENOTCONN
          # Client disconnects can result in an inability to get the
          # peeraddr from the socket; default to localhost.
          addr = LOCALHOST_IP
        end

        # Set unix socket addrs to localhost
        addr = LOCALHOST_IP if addr.empty?

        env[REMOTE_ADDR] = addr
      end
    end
    # private :normalize_env

    # @param header_key [#to_s]
    # @return [Boolean]
    #
    def illegal_header_key?(header_key)
      !!(ILLEGAL_HEADER_KEY_REGEX =~ header_key.to_s)
    end

    # @param header_value [#to_s]
    # @return [Boolean]
    #
    def illegal_header_value?(header_value)
      !!(ILLEGAL_HEADER_VALUE_REGEX =~ header_value.to_s)
    end
    private :illegal_header_key?, :illegal_header_value?

    # Fixup any headers with `,` in the name to have `_` now. We emit
    # headers with `,` in them during the parse phase to avoid ambiguity
    # with the `-` to `_` conversion for critical headers. But here for
    # compatibility, we'll convert them back. This code is written to
    # avoid allocation in the common case (ie there are no headers
    # with `,` in their names), that's why it has the extra conditionals.
    # @param env [Hash] see Puma::Client#env, from request, modifies in place
    # @version 5.0.3
    #
    def req_env_post_parse(env)
      to_delete = nil
      to_add = nil

      env.each do |k,v|
        if k.start_with?("HTTP_") and k.include?(",") and k != "HTTP_TRANSFER,ENCODING"
          if to_delete
            to_delete << k
          else
            to_delete = [k]
          end

          unless to_add
            to_add = {}
          end

          to_add[k.tr(",", "_")] = v
        end
      end

      if to_delete
        to_delete.each { |k| env.delete(k) }
        env.merge! to_add
      end
    end
    private :req_env_post_parse

    # Used in the lambda for env[ `Puma::Const::EARLY_HINTS` ]
    # @param headers [Hash] the headers returned by the Rack application
    # @return [String]
    # @version 5.0.3
    #
    def str_early_hints(headers)
      eh_str = "HTTP/1.1 103 Early Hints\r\n".dup
      headers.each_pair do |k, vs|
        next if illegal_header_key?(k)

        if vs.respond_to?(:to_s) && !vs.to_s.empty?
          vs.to_s.split(NEWLINE).each do |v|
            next if illegal_header_value?(v)
            eh_str << "#{k}: #{v}\r\n"
          end
        else
          eh_str << "#{k}: #{vs}\r\n"
        end
      end
      "#{eh_str}\r\n".freeze
    end
    private :str_early_hints

    # Processes and write headers to the IOBuffer.
    # @param env [Hash] see Puma::Client#env, from request
    # @param status [Integer] the status returned by the Rack application
    # @param headers [Hash] the headers returned by the Rack application
    # @param res_info [Hash] used to pass info between this method and #handle_request
    # @param lines [Puma::IOBuffer] modified inn place
    # @version 5.0.3
    #
    def str_headers(env, status, headers, res_info, lines)
      line_ending = LINE_END
      colon = COLON

      http_11 = env[HTTP_VERSION] == HTTP_11
      if http_11
        res_info[:allow_chunked] = true
        res_info[:keep_alive] = env.fetch(HTTP_CONNECTION, "").downcase != CLOSE

        # An optimization. The most common response is 200, so we can
        # reply with the proper 200 status without having to compute
        # the response header.
        #
        if status == 200
          lines << HTTP_11_200
        else
          lines.append "HTTP/1.1 ", status.to_s, " ",
                       fetch_status_code(status), line_ending

          res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status]
        end
      else
        res_info[:allow_chunked] = false
        res_info[:keep_alive] = env.fetch(HTTP_CONNECTION, "").downcase == KEEP_ALIVE

        # Same optimization as above for HTTP/1.1
        #
        if status == 200
          lines << HTTP_10_200
        else
          lines.append "HTTP/1.0 ", status.to_s, " ",
                       fetch_status_code(status), line_ending

          res_info[:no_body] ||= status < 200 || STATUS_WITH_NO_ENTITY_BODY[status]
        end
      end

      # regardless of what the client wants, we always close the connection
      # if running without request queueing
      res_info[:keep_alive] &&= @queue_requests

      res_info[:response_hijack] = nil

      headers.each do |k, vs|
        next if illegal_header_key?(k)

        case k.downcase
        when CONTENT_LENGTH2
          next if illegal_header_value?(vs)
          res_info[:content_length] = vs
          next
        when TRANSFER_ENCODING
          res_info[:allow_chunked] = false
          res_info[:content_length] = nil
        when HIJACK
          res_info[:response_hijack] = vs
          next
        when BANNED_HEADER_KEY
          next
        end

        if vs.respond_to?(:to_s) && !vs.to_s.empty?
          vs.to_s.split(NEWLINE).each do |v|
            next if illegal_header_value?(v)
            lines.append k, colon, v, line_ending
          end
        else
          lines.append k, colon, line_ending
        end
      end

      # HTTP/1.1 & 1.0 assume different defaults:
      # - HTTP 1.0 assumes the connection will be closed if not specified
      # - HTTP 1.1 assumes the connection will be kept alive if not specified.
      # Only set the header if we're doing something which is not the default
      # for this protocol version
      if http_11
        lines << CONNECTION_CLOSE if !res_info[:keep_alive]
      else
        lines << CONNECTION_KEEP_ALIVE if res_info[:keep_alive]
      end
    end
    private :str_headers
  end
end