Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 14 additions & 14 deletions lib/protocol/multipart/form_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -16,27 +16,27 @@ module Multipart
class FormData < Mixed
include Escape

# The default maximum size of a buffered form field.
MAXIMUM_FIELD_SIZE = 2 * 1024 * 1024
# The buffered form field size limit.
FIELD_SIZE_LIMIT = 2 * 1024 * 1024

# The default maximum size of a streamed file upload.
MAXIMUM_UPLOAD_SIZE = 128 * 1024 * 1024
# The streamed file upload size limit.
UPLOAD_SIZE_LIMIT = 128 * 1024 * 1024

# The default maximum combined size of all form fields and file uploads.
MAXIMUM_TOTAL_SIZE = 256 * 1024 * 1024
# The combined size limit for all form fields and file uploads.
TOTAL_SIZE_LIMIT = 256 * 1024 * 1024

# A file upload yielded while parsing form data.
class Upload
# Initialize a streamed file upload.
# @parameter part [Parser::Part] The underlying multipart part.
# @parameter filename [String] The submitted filename.
# @parameter maximum_size [Integer | Nil] The maximum upload size.
# @parameter total_limit [ByteLimit] The shared form-data size limit.
def initialize(part, filename, maximum_size, total_limit)
# @parameter size_limit [Integer | Nil] The upload size limit.
# @parameter total_size_limit [ByteLimit] The shared form-data size limit.
def initialize(part, filename, size_limit, total_size_limit)
@part = part
@filename = filename
@limit = ByteLimit.new(maximum_size, name: :upload_size)
@total_limit = total_limit
@size_limit = ByteLimit.new(size_limit, name: :upload_size)
@total_size_limit = total_size_limit
end

# The submitted filename.
Expand All @@ -49,7 +49,7 @@ def headers

# The number of upload bytes consumed so far.
def size
@limit.size
@size_limit.size
end

# Whether the complete upload has been consumed.
Expand All @@ -63,8 +63,8 @@ def each(chunk_size = 8192)
return to_enum(:each, chunk_size) unless block_given?

@part.each(chunk_size) do |chunk|
@limit.consume(chunk.bytesize)
@total_limit.consume(chunk.bytesize)
@size_limit.consume(chunk.bytesize)
@total_size_limit.consume(chunk.bytesize)
yield chunk
end

Expand Down
32 changes: 16 additions & 16 deletions lib/protocol/multipart/form_data/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,20 @@ class Parser
CONTENT_TYPE = "multipart/form-data"

# Initialize the form data parser.
# @parameter maximum_field_size [Integer | Nil] The maximum size of each buffered field.
# @parameter maximum_upload_size [Integer | Nil] The maximum size of each file upload.
# @parameter maximum_total_size [Integer | Nil] The maximum combined size of all fields and uploads.
# @parameter maximum_depth [Integer | Nil] The maximum depth of a bracketed form name.
# @parameter field_size_limit [Integer | Nil] The buffered field size limit.
# @parameter upload_size_limit [Integer | Nil] The file upload size limit.
# @parameter total_size_limit [Integer | Nil] The combined size limit for all fields and uploads.
# @parameter depth_limit [Integer | Nil] The bracketed form name depth limit.
# @parameter options [Hash] Limits passed to the underlying multipart parser.
def initialize(maximum_field_size: MAXIMUM_FIELD_SIZE, maximum_upload_size: MAXIMUM_UPLOAD_SIZE, maximum_total_size: MAXIMUM_TOTAL_SIZE, maximum_depth: Protocol::URL::FormData::Nested::MAXIMUM_DEPTH, **options)
if maximum_depth and maximum_depth < 0
def initialize(field_size_limit: FIELD_SIZE_LIMIT, upload_size_limit: UPLOAD_SIZE_LIMIT, total_size_limit: TOTAL_SIZE_LIMIT, depth_limit: Protocol::URL::FormData::Nested::DEPTH_LIMIT, **options)
if depth_limit and depth_limit < 0
raise ArgumentError, "Form data limits must be non-negative!"
end

@maximum_field_size = maximum_field_size
@maximum_upload_size = maximum_upload_size
@maximum_total_size = maximum_total_size
@maximum_depth = maximum_depth
@field_size_limit = field_size_limit
@upload_size_limit = upload_size_limit
@total_size_limit = total_size_limit
@depth_limit = depth_limit
@options = options
end

Expand Down Expand Up @@ -66,7 +66,7 @@ def parse(readable, result = make_result, boundary:)
def each(readable, boundary:)
return to_enum(__method__, readable, boundary:) unless block_given?

total_limit = ByteLimit.new(@maximum_total_size, name: :total_size)
total_size_limit = ByteLimit.new(@total_size_limit, name: :total_size)
parser = Multipart::Parser.new(readable, boundary, **@options)

parser.each do |part|
Expand All @@ -77,16 +77,16 @@ def each(readable, boundary:)
end

if filename = disposition["filename"]
upload = Upload.new(part, filename, @maximum_upload_size, total_limit)
upload = Upload.new(part, filename, @upload_size_limit, total_size_limit)
yield name, upload
upload.discard
else
field_limit = ByteLimit.new(@maximum_field_size, name: :field_size)
field_size_limit = ByteLimit.new(@field_size_limit, name: :field_size)
value = String.new.b

part.each do |chunk|
field_limit.consume(chunk.bytesize)
total_limit.consume(chunk.bytesize)
field_size_limit.consume(chunk.bytesize)
total_size_limit.consume(chunk.bytesize)
value << chunk
end

Expand All @@ -98,7 +98,7 @@ def each(readable, boundary:)
private

def make_result
return Protocol::URL::FormData::Nested.new(maximum_depth: @maximum_depth)
return Protocol::URL::FormData::Nested.new(depth_limit: @depth_limit)
end
end
end
Expand Down
60 changes: 30 additions & 30 deletions lib/protocol/multipart/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -14,17 +14,17 @@ class Parser
HEADER_PATTERN = /\A([!-9;-~]+):[ \t]*([^\x00-\x08\x0a-\x1f\x7f]*)\z/.freeze
private_constant :HEADER_PATTERN

# The default maximum number of preamble bytes before the first boundary.
MAXIMUM_PREAMBLE_SIZE = 64 * 1024
# The preamble size limit.
PREAMBLE_SIZE_LIMIT = 64 * 1024

# The default maximum number of header bytes in each part.
MAXIMUM_HEADER_SIZE = 64 * 1024
# The header size limit for each part.
HEADER_SIZE_LIMIT = 64 * 1024

# The default maximum number of headers in each part.
MAXIMUM_HEADER_COUNT = 64
# The header count limit for each part.
HEADER_COUNT_LIMIT = 64

# The default maximum number of parts.
MAXIMUM_PART_COUNT = 128
# The part count limit.
PART_COUNT_LIMIT = 128

# Represents a single part within a multipart message.
class Part
Expand Down Expand Up @@ -163,23 +163,23 @@ def closing_boundary?
#
# @parameter readable [IO, IO::Stream] The readable stream containing multipart data.
# @parameter boundary [String] The boundary string that separates the parts.
# @parameter maximum_preamble_size [Integer | Nil] The maximum preamble size, or nil for no limit.
# @parameter maximum_header_size [Integer | Nil] The maximum header size per part, or nil for no limit.
# @parameter maximum_header_count [Integer | Nil] The maximum header count per part, or nil for no limit.
# @parameter maximum_part_count [Integer | Nil] The maximum part count, or nil for no limit.
def initialize(readable, boundary, maximum_preamble_size: MAXIMUM_PREAMBLE_SIZE, maximum_header_size: MAXIMUM_HEADER_SIZE, maximum_header_count: MAXIMUM_HEADER_COUNT, maximum_part_count: MAXIMUM_PART_COUNT)
limits = [maximum_preamble_size, maximum_header_size, maximum_header_count, maximum_part_count]
# @parameter preamble_size_limit [Integer | Nil] The preamble size limit, or nil for no limit.
# @parameter header_size_limit [Integer | Nil] The header size limit per part, or nil for no limit.
# @parameter header_count_limit [Integer | Nil] The header count limit per part, or nil for no limit.
# @parameter part_count_limit [Integer | Nil] The part count limit, or nil for no limit.
def initialize(readable, boundary, preamble_size_limit: PREAMBLE_SIZE_LIMIT, header_size_limit: HEADER_SIZE_LIMIT, header_count_limit: HEADER_COUNT_LIMIT, part_count_limit: PART_COUNT_LIMIT)
limits = [preamble_size_limit, header_size_limit, header_count_limit, part_count_limit]

if limits.any?{|limit| limit and limit < 0}
raise ArgumentError, "Multipart limits must be non-negative!"
end

@readable = IO::Stream(readable)
@boundary = boundary
@maximum_preamble_size = maximum_preamble_size
@maximum_header_size = maximum_header_size
@maximum_header_count = maximum_header_count
@maximum_part_count = maximum_part_count
@preamble_size_limit = preamble_size_limit
@header_size_limit = header_size_limit
@header_count_limit = header_count_limit
@part_count_limit = part_count_limit

@boundary_marker = "--#{@boundary}\r\n".freeze
end
Expand All @@ -195,12 +195,12 @@ def each

# Read lines until we find the first boundary:
while true
if line = read_line(preamble_size, @maximum_preamble_size, allowance: @boundary_marker.bytesize, chomp: false)
if line = read_line(preamble_size, @preamble_size_limit, allowance: @boundary_marker.bytesize, chomp: false)
if line == @boundary_marker
break
else
preamble_size += line.bytesize
check_limit(:preamble_size, preamble_size, @maximum_preamble_size)
check_limit(:preamble_size, preamble_size, @preamble_size_limit)
end
else
# End of stream reached without finding boundary:
Expand All @@ -212,7 +212,7 @@ def each

while true
part_count += 1
check_limit(:part_count, part_count, @maximum_part_count)
check_limit(:part_count, part_count, @part_count_limit)

part = read_part
break unless part
Expand All @@ -234,18 +234,18 @@ def each

private

def read_line(size, maximum, allowance: 0, chomp:)
if maximum
limit = maximum - size + allowance + 1
def read_line(size, limit, allowance: 0, chomp:)
if limit
limit = limit - size + allowance + 1
return @readable.gets("\r\n", limit, chomp: chomp)
else
return @readable.gets("\r\n", chomp: chomp)
end
end

def check_limit(name, value, maximum)
if maximum and value > maximum
raise RangeError, "Multipart #{name} exceeded limit of #{maximum}!"
def check_limit(name, value, limit)
if limit and value > limit
raise RangeError, "Multipart #{name} exceeded limit of #{limit}!"
end
end

Expand All @@ -255,18 +255,18 @@ def read_part
header_count = 0

# Read headers until empty line
while line = read_line(header_size, @maximum_header_size, allowance: 2, chomp: true)
while line = read_line(header_size, @header_size_limit, allowance: 2, chomp: true)
if line.empty?
break # End of headers
end

header_size += line.bytesize + 2
check_limit(:header_size, header_size, @maximum_header_size)
check_limit(:header_size, header_size, @header_size_limit)

if match = line.match(HEADER_PATTERN)
# Parse header line (name: value)
header_count += 1
check_limit(:header_count, header_count, @maximum_header_count)
check_limit(:header_count, header_count, @header_count_limit)

fields << [match[1], match[2].strip]
else
Expand Down
4 changes: 4 additions & 0 deletions releases.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Releases

## Unreleased

- Use consistent limit naming for multipart parser constraints.

## v0.3.0

- Add a configurable `Protocol::Multipart::FormData::Parser` which parses a streaming body and explicit boundary into nested arguments.
Expand Down
18 changes: 9 additions & 9 deletions test/protocol/multipart/form_data.rb
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,7 @@ def to_h
form_data.add_field("field", "content")

expect do
subject::Parser.new(maximum_field_size: 3).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
subject::Parser.new(field_size_limit: 3).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
end.to raise_exception(RangeError, message: be =~ /field_size exceeded/)
end

Expand All @@ -182,7 +182,7 @@ def to_h
)

expect do
subject::Parser.new(maximum_upload_size: 3).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
subject::Parser.new(upload_size_limit: 3).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
end.to raise_exception(RangeError, message: be =~ /upload_size exceeded/)
end

Expand All @@ -191,17 +191,17 @@ def to_h
form_data.add_field("second", "two")

expect do
subject::Parser.new(maximum_total_size: 5).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
subject::Parser.new(total_size_limit: 5).each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a
end.to raise_exception(RangeError, message: be =~ /total_size exceeded/)
end

it "allows content limits to be disabled" do
form_data.add_field("field", "content")

parser = subject::Parser.new(
maximum_field_size: nil,
maximum_upload_size: nil,
maximum_total_size: nil,
field_size_limit: nil,
upload_size_limit: nil,
total_size_limit: nil,
)
values = parser.each(StringIO.new(serialize(form_data)), boundary: form_data.boundary).to_a

Expand All @@ -210,21 +210,21 @@ def to_h

it "rejects negative content limits" do
expect do
subject::Parser.new(maximum_total_size: -1).each(StringIO.new, boundary: "boundary").to_a
subject::Parser.new(total_size_limit: -1).each(StringIO.new, boundary: "boundary").to_a
end.to raise_exception(ArgumentError, message: be =~ /must be non-negative/)
end

it "rejects a negative nesting limit" do
expect do
subject::Parser.new(maximum_depth: -1)
subject::Parser.new(depth_limit: -1)
end.to raise_exception(ArgumentError, message: be =~ /must be non-negative/)
end

it "limits nested form names" do
form_data.add_field("a[b][c]", "value")

expect do
subject::Parser.new(maximum_depth: 2).parse(StringIO.new(serialize(form_data)), boundary: form_data.boundary)
subject::Parser.new(depth_limit: 2).parse(StringIO.new(serialize(form_data)), boundary: form_data.boundary)
end.to raise_exception(RangeError, message: be =~ /depth exceeded/)
end

Expand Down
Loading
Loading