class Rack::Multipart::Parser

Constants

BOUNDARY_START_LIMIT
BUFFERED_UPLOAD_BYTESIZE_LIMIT
BUFSIZE
CHARSET
CONTENT_DISPOSITION_MAX_BYTES
CONTENT_DISPOSITION_MAX_PARAMS
CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
EMPTY
MIME_HEADER_BYTESIZE_LIMIT
MultipartInfo
PARSER_BYTESIZE_LIMIT
TEMPFILE_FACTORY
TEXT_PLAIN

Attributes

state[R]

Public Class Methods

new(boundary, tempfile, bufsize, query_parser) click to toggle source
# File lib/rack/multipart/parser.rb, line 242
def initialize(boundary, tempfile, bufsize, query_parser)
  @query_parser   = query_parser
  @params         = query_parser.make_params
  @bufsize        = bufsize

  @state = :FAST_FORWARD
  @mime_index = 0
  @body_retained = nil
  @retained_size = 0
  @total_bytes_read = (0 if PARSER_BYTESIZE_LIMIT)
  @content_disposition_quoted_escapes = 0
  @collector = Collector.new tempfile

  @sbuf = StringScanner.new("".dup)
  @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m
  @body_regex_at_end = /#{@body_regex}\z/m
  @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish)
  @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish)
  @head_regex = /(.*?#{EOL})#{EOL}/m
end
parse(io, content_length, content_type, tmpfile, bufsize, qp) click to toggle source
# File lib/rack/multipart/parser.rb, line 125
def self.parse(io, content_length, content_type, tmpfile, bufsize, qp)
  return EMPTY if 0 == content_length

  boundary = parse_boundary content_type
  return EMPTY unless boundary

  if PARSER_BYTESIZE_LIMIT && content_length && content_length > PARSER_BYTESIZE_LIMIT
    raise Error, "multipart Content-Length #{content_length} exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
  end

  if boundary.length > 70
    # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary.
    # Most clients use no more than 55 characters.
    raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)"
  end

  io = BoundedIO.new(io, content_length) if content_length

  parser = new(boundary, tmpfile, bufsize, qp)
  parser.parse(io)

  parser.result
end
parse_boundary(content_type) click to toggle source
# File lib/rack/multipart/parser.rb, line 110
def self.parse_boundary(content_type)
  return unless content_type
  data = content_type.match(MULTIPART)
  return unless data

  unless data[1].empty?
    raise Error, "whitespace between boundary parameter name and equal sign"
  end
  if data.post_match.match?(/boundary\s*=/i)
    raise BoundaryTooLongError, "multiple boundary parameters found in multipart content type"
  end

  data[2]
end

Public Instance Methods

parse(io) click to toggle source
# File lib/rack/multipart/parser.rb, line 263
def parse(io)
  @total_bytes_read &&= nil if io.is_a?(BoundedIO)
  outbuf = String.new
  read_data(io, outbuf)

  loop do
    status =
      case @state
      when :FAST_FORWARD
        handle_fast_forward
      when :CONSUME_TOKEN
        handle_consume_token
      when :MIME_HEAD
        handle_mime_head
      when :MIME_BODY
        handle_mime_body
      else # when :DONE
        return
      end

    read_data(io, outbuf) if status == :want_read
  end
end
result() click to toggle source
# File lib/rack/multipart/parser.rb, line 287
def result
  @collector.each do |part|
    part.get_data do |data|
      tag_multipart_encoding(part.filename, part.content_type, part.name, data)
      @query_parser.normalize_params(@params, part.name, data)
    end
  end
  MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body)
end

Private Instance Methods

consume_boundary() click to toggle source

Scan until the we find the start or end of the boundary. If we find it, return the appropriate symbol for the start or end of the boundary. If we don’t find the start or end of the boundary, clear the buffer and return nil.

# File lib/rack/multipart/parser.rb, line 519
def consume_boundary
  if read_buffer = @sbuf.scan_until(@body_regex)
    read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY
  else
    @sbuf.terminate
    nil
  end
end
dequote(str) click to toggle source
# File lib/rack/multipart/parser.rb, line 299
def dequote(str) # From WEBrick::HTTPUtils
  ret = (/\A"(.*)"\Z/ =~ str) ? $1 : str.dup
  ret.gsub!(/\\(.)/, "\\1")
  ret
end
find_encoding(enc) click to toggle source

Return the related Encoding object. However, because enc is submitted by the user, it may be invalid, so use a binary encoding in that case.

# File lib/rack/multipart/parser.rb, line 574
def find_encoding(enc)
  Encoding.find enc
rescue ArgumentError
  Encoding::BINARY
end
handle_consume_token() click to toggle source
# File lib/rack/multipart/parser.rb, line 351
def handle_consume_token
  tok = consume_boundary
  # break if we're at the end of a buffer, but not if it is the end of a field
  @state = if tok == :END_BOUNDARY || (@sbuf.eos? && tok != :BOUNDARY)
    :DONE
  else
    :MIME_HEAD
  end
end
handle_empty_content!(content) click to toggle source
# File lib/rack/multipart/parser.rb, line 580
def handle_empty_content!(content)
  if content.nil? || content.empty?
    raise EmptyContentError
  end
end
handle_fast_forward() click to toggle source

This handles the initial parser state. We read until we find the starting boundary, then we can transition to the next state. If we find the ending boundary, this is an invalid multipart upload, but keep scanning for opening boundary in that case. If no boundary found, we need to keep reading data and retry. It’s highly unlikely the initial read will not consume the boundary. The client would have to deliberately craft a response with the opening boundary beyond the buffer size for that to happen.

# File lib/rack/multipart/parser.rb, line 324
def handle_fast_forward
  while true
    case consume_boundary
    when :BOUNDARY
      # found opening boundary, transition to next state
      @state = :MIME_HEAD
      return
    when :END_BOUNDARY
      # invalid multipart upload
      if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL
        # stop parsing a buffer if a buffer is only an end boundary.
        @state = :DONE
        return
      end

      # retry for opening boundary
    else
      # We raise if we don't find the multipart boundary, to avoid unbounded memory
      # buffering. Note that the actual limit is the higher of 16KB and the buffer size (1MB by default)
      raise Error, "multipart boundary not found within limit" if @sbuf.string.bytesize > BOUNDARY_START_LIMIT

      # no boundary found, keep reading data
      return :want_read
    end
  end
end
handle_mime_body() click to toggle source
# File lib/rack/multipart/parser.rb, line 486
def handle_mime_body
  if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet
    body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string
    update_retained_size(body.bytesize) if @body_retained
    @collector.on_mime_body @mime_index, body
    @sbuf.pos += body.length + 2 # skip \r\n after the content
    @state = :CONSUME_TOKEN
    @mime_index += 1
  else
    # Save what we have so far
    if @rx_max_size < @sbuf.rest_size
      delta = @sbuf.rest_size - @rx_max_size
      body = @sbuf.peek(delta)
      update_retained_size(body.bytesize) if @body_retained
      @collector.on_mime_body @mime_index, body
      @sbuf.pos += delta
      @sbuf.string = @sbuf.rest
    end
    :want_read
  end
end
handle_mime_head() click to toggle source
# File lib/rack/multipart/parser.rb, line 363
def handle_mime_head
  if @sbuf.scan_until(@head_regex)
    head = @sbuf[1]
    content_type = head[MULTIPART_CONTENT_TYPE, 1]
    if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) &&
        disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES

      # ignore actual content-disposition value (should always be form-data)
      i = disposition.index(';')
      disposition.slice!(0, i+1)
      param = nil
      num_params = 0

      # Parse parameter list
      while i = disposition.index('=')
        # Only parse up to max parameters, to avoid potential denial of service
        num_params += 1
        break if num_params > CONTENT_DISPOSITION_MAX_PARAMS

        # Found end of parameter name, ensure forward progress in loop
        param = disposition.slice!(0, i+1)

        # Remove ending equals and preceding whitespace from parameter name
        param.chomp!('=')
        param.lstrip!

        if disposition[0] == '"'
          # Parameter value is quoted, parse it, handling backslash escapes
          disposition.slice!(0, 1)
          value = String.new

          while i = disposition.index(/(["\\])/)
            c = $1

            # Append all content until ending quote or escape
            value << disposition.slice!(0, i)

            # Remove either backslash or ending quote,
            # ensures forward progress in loop
            disposition.slice!(0, 1)

            # stop parsing parameter value if found ending quote
            break if c == '"'

            @content_disposition_quoted_escapes += 1
            if @content_disposition_quoted_escapes > CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
              raise Error, "number of quoted escapes during content disposition parsing exceeds limit"
            end

            escaped_char = disposition.slice!(0, 1)
            if param == 'filename' && escaped_char != '"'
              # Possible IE uploaded filename, append both escape backslash and value
              value << c << escaped_char
            else
              # Other only append escaped value
              value << escaped_char
            end
          end
        else
          if i = disposition.index(';')
            # Parameter value unquoted (which may be invalid), value ends at semicolon
            value = disposition.slice!(0, i)
          else
            # If no ending semicolon, assume remainder of line is value and stop
            # parsing
            disposition.strip!
            value = disposition
            disposition = ''
          end
        end

        case param
        when 'name'
          name = value
        when 'filename'
          filename = value
        when 'filename*'
          filename_star = value
        # else
        # ignore other parameters
        end

        # skip trailing semicolon, to proceed to next parameter
        if i = disposition.index(';')
          disposition.slice!(0, i+1)
        end
      end
    else
      name = head[MULTIPART_CONTENT_ID, 1]
    end

    if filename_star
      encoding, _, filename = filename_star.split("'", 3)
      filename = normalize_filename(filename || '')
      filename.force_encoding(find_encoding(encoding))
    elsif filename
      filename = normalize_filename(filename)
    end

    if name.nil? || name.empty?
      name = filename || "#{content_type || TEXT_PLAIN}[]".dup
    end

    # Mime part head data is retained for both TempfilePart and BufferPart
    # for the entireity of the parse, even though it isn't used for BufferPart.
    update_retained_size(head.bytesize)

    # If a filename is given, a TempfilePart will be used, so the body will
    # not be buffered in memory. However, if a filename is not given, a BufferPart
    # will be used, and the body will be buffered in memory.
    @body_retained = !filename

    @collector.on_mime_head @mime_index, head, filename, content_type, name
    @state = :MIME_BODY
  else
    # We raise if the mime part header is too large, to avoid unbounded memory
    # buffering. Note that the actual limit is the higher of 64KB and the buffer size (1MB by default)
    raise Error, "multipart mime part header too large" if @sbuf.rest.bytesize > MIME_HEADER_BYTESIZE_LIMIT

    return :want_read
  end
end
normalize_filename(filename) click to toggle source
# File lib/rack/multipart/parser.rb, line 528
def normalize_filename(filename)
  if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
    filename = Utils.unescape_path(filename)
  end

  filename.scrub!

  filename.split(/[\/\\]/).last || String.new
end
read_data(io, outbuf) click to toggle source
# File lib/rack/multipart/parser.rb, line 305
def read_data(io, outbuf)
  content = io.read(@bufsize, outbuf)
  handle_empty_content!(content)
  if @total_bytes_read
    @total_bytes_read += content.bytesize
    if @total_bytes_read > PARSER_BYTESIZE_LIMIT
      raise Error, "multipart upload exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
    end
  end
  @sbuf.concat(content)
end
tag_multipart_encoding(filename, content_type, name, body) click to toggle source
# File lib/rack/multipart/parser.rb, line 541
def tag_multipart_encoding(filename, content_type, name, body)
  name = name.to_s
  encoding = Encoding::UTF_8

  name.force_encoding(encoding)

  return if filename

  if content_type
    list         = content_type.split(';')
    type_subtype = list.first
    type_subtype.strip!
    if TEXT_PLAIN == type_subtype
      rest = list.drop 1
      rest.each do |param|
        k, v = param.split('=', 2)
        k.strip!
        v.strip!
        v = v[1..-2] if v.start_with?('"') && v.end_with?('"')
        if k == "charset"
          encoding = find_encoding(v)
        end
      end
    end
  end

  name.force_encoding(encoding)
  body.force_encoding(encoding)
end
update_retained_size(size) click to toggle source
# File lib/rack/multipart/parser.rb, line 508
def update_retained_size(size)
  @retained_size += size
  if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT
    raise Error, "multipart data over retained size limit"
  end
end