Skip to content
Closed
Show file tree
Hide file tree
Changes from 4 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
9 changes: 9 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -245,6 +245,15 @@ Set to 0 for no limit.
Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable.


### multipart_buffered_upload_bytesize_limit

The limit of the bytesize of all multipart parts (header and body), excluding the (body) of parts with a "filename".

Defaults to 16 MB, which means it is not possible for multipart forms to contain form data of a total size greater than 16 MB. Uploaded files can be larger.

Can also be set via the `RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT` environment variable.


## History

See <https://github.com/rack/HISTORY.md>.
Expand Down
2 changes: 1 addition & 1 deletion lib/rack/mock.rb
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ def self.env_for(uri="", opts={})
rack_input.set_encoding(Encoding::BINARY) if rack_input.respond_to?(:set_encoding)
env['rack.input'] = rack_input

env["CONTENT_LENGTH"] ||= env["rack.input"].length.to_s
env["CONTENT_LENGTH"] ||= env["rack.input"].length.to_s if env["rack.input"].respond_to?(:length)

opts.each { |field, value|
env[field] = value if String === field
Expand Down
20 changes: 18 additions & 2 deletions lib/rack/multipart/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ class MultipartTotalPartLimitError < StandardError; end
class Parser
BUFSIZE = 16384
DUMMY = Struct.new(:parse).new
MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024

def self.create(env)
return DUMMY unless env['CONTENT_TYPE'] =~ MULTIPART
Expand Down Expand Up @@ -40,6 +41,7 @@ def initialize(boundary, io, content_length, env, tempfile, bufsize)
@env = env
@tempfile = tempfile
@bufsize = bufsize
@retained_size = 0

if @content_length
@content_length -= @boundary_size
Expand Down Expand Up @@ -70,13 +72,14 @@ def parse
parts += 1
if parts >= Utils.multipart_total_part_limit
close_tempfiles
raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached'
raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached'
end
end

# Save the rest.
if i = @buf.index(rx)
body << @buf.slice!(0, i)
update_retained_size(i) unless filename
@buf.slice!(0, @boundary_size+2)

@content_length = -1 if $1 == "--"
Expand Down Expand Up @@ -133,6 +136,7 @@ def get_current_head_and_filename_and_content_type_and_name_and_body

@buf.slice!(0, 2) # Second \r\n

update_retained_size(head.bytesize)
content_type = head[MULTIPART_CONTENT_TYPE, 1]
name = head[MULTIPART_CONTENT_DISPOSITION, 1] || head[MULTIPART_CONTENT_ID, 1]

Expand All @@ -151,14 +155,19 @@ def get_current_head_and_filename_and_content_type_and_name_and_body
end

# Save the read body part.
if head && (@boundary_size+4 < @buf.size)
size_to_read = @buf.size - (@boundary_size+4)
if head && size_to_read > 0
body << @buf.slice!(0, @buf.size - (@boundary_size+4))
update_retained_size(size_to_read) unless filename
end

content = @io.read(@content_length && @bufsize >= @content_length ? @content_length : @bufsize)
raise EOFError, "bad content body" if content.nil? || content.empty?

@buf << content

raise EOFError, "multipart mime part header too large" if @buf.size > MIME_HEADER_BYTESIZE_LIMIT

@content_length -= content.size if @content_length
end

Expand Down Expand Up @@ -265,6 +274,13 @@ def get_data(filename, body, content_type, name, head)

yield data
end

def update_retained_size(size)
@retained_size += size
if @retained_size > Utils.buffered_upload_bytesize_limit
raise EOFError, "multipart data over retained size limit"
end
end
end
end
end
5 changes: 4 additions & 1 deletion lib/rack/request.rb
Original file line number Diff line number Diff line change
Expand Up @@ -209,7 +209,10 @@ def POST
@env["rack.request.form_hash"]
elsif form_data? || parseable_data?
unless @env["rack.request.form_hash"] = parse_multipart(env)
form_vars = @env["rack.input"].read
# Add 2 bytes. One to check whether it is over the limit, and a second
# in case the slice! call below removes the last byte
# If read returns nil, use the empty string
form_vars = @env["rack.input"].read(Rack::Utils.bytesize_limit + 2) || ''

# Fix for Safari Ajax postings that always append \0
# form_vars.sub!(/\0\z/, '') # performance replacement:
Expand Down
47 changes: 43 additions & 4 deletions lib/rack/sendfile.rb
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,23 @@ module Rack
# proxy_set_header X-Real-IP $remote_addr;
# proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
#
# proxy_set_header X-Sendfile-Type X-Accel-Redirect;
# proxy_set_header X-Accel-Mapping /var/www/=/files/;
#
# proxy_pass http://127.0.0.1:8080/;
# }
#
# Note that the X-Sendfile-Type header must be set exactly as shown above.
# The X-Accel-Mapping header should specify the location on the file system,
# followed by an equals sign (=), followed name of the private URL pattern
# that it maps to. The middleware performs a simple substitution on the
# resulting path.
#
# # To enable X-Accel-Redirect, you must configure the middleware explicitly:
#
# use Rack::Sendfile, "X-Accel-Redirect"
#
# For security reasons, "X-Accel-Redirect" may not be set via the X-Sendfile-Type header.
# The sendfile variation must be set via the middleware constructor.
#
# See Also: http://wiki.codemongers.com/NginxXSendfile
#
# === lighttpd
Expand Down Expand Up @@ -97,9 +102,23 @@ module Rack
# X-Accel-Mapping header. Mappings should be provided in tuples of internal to
# external. The internal values may contain regular expression syntax, they
# will be matched with case indifference.
#
# When X-Accel-Redirect is explicitly enabled via the variation parameter,
# and no application-level mappings are provided, the middleware will read
# the X-Accel-Mapping header from the proxy. This allows nginx to control
# the path mapping without requiring application-level configuration.
#
# === Security
#
# For security reasons, the X-Sendfile-Type header from HTTP requests may only
# be set to "X-Sendfile" or "X-Lighttpd-Send-File". Other values such as
# "X-Accel-Redirect" are not permitted to prevent information disclosure
# vulnerabilities where attackers could bypass proxy restrictions.


class Sendfile
F = ::File
SAFE_SENDFILE_VARIATIONS = ['X-Sendfile', 'X-Lighttpd-Send-File']

def initialize(app, variation=nil, mappings=[])
@app = app
Expand Down Expand Up @@ -142,16 +161,36 @@ def call(env)
end

private

def x_sendfile_type(env)
sendfile_type = env['HTTP_X_SENDFILE_TYPE']
if SAFE_SENDFILE_VARIATIONS.include?(sendfile_type)
sendfile_type
else
env['rack.errors'].puts "Unknown or unsafe x-sendfile variation: #{sendfile_type.inspect}"
end
end

def variation(env)
@variation ||
env['sendfile.type'] ||
env['HTTP_X_SENDFILE_TYPE']
x_sendfile_type(env)
end

def x_accel_mapping(env)
# Only allow header when:
# 1. X-Accel-Redirect is explicitly enabled via constructor.
# 2. No application-level mappings are configured.
return nil unless @variation == 'X-Accel-Redirect'

env['HTTP_X_ACCEL_MAPPING']
end

def map_accel_path(env, path)
if mapping = @mappings.find { |internal,_| internal =~ path }
path.sub(*mapping)
elsif mapping = env['HTTP_X_ACCEL_MAPPING']
elsif mapping = x_accel_mapping(env)
# Safe to use header: explicit config + no app mappings
internal, external = mapping.split('=', 2).map{ |p| p.strip }
path.sub(/^#{internal}/i, external)
end
Expand Down
27 changes: 25 additions & 2 deletions lib/rack/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class << self
attr_accessor :param_depth_limit
attr_accessor :multipart_total_part_limit
attr_accessor :multipart_file_limit
attr_accessor :buffered_upload_bytesize_limit # CVE-2025-61771

# multipart_part_limit is the original name of multipart_file_limit, but
# the limit only counts parts with filenames.
Expand All @@ -89,6 +90,28 @@ class << self
# many can lead to excessive memory use and parsing time.
self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i

# This variable sets the maximum total size of all parts and headers
# of a multipart request. Parts with filenames are written to tempfiles
# and do not count. Defaults to 16 MB.
self.buffered_upload_bytesize_limit = (ENV['RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT'] || 16 * 1024 * 1024).to_i

def check_query_string(qs, sep)
if qs
if qs.bytesize > Rack::Utils.bytesize_limit
raise QueryLimitError, "total query size exceeds limit (#{Rack::Utils.bytesize_limit})"
end

if (param_count = qs.count(sep.is_a?(String) ? sep : '&')) >= Rack::Utils.params_limit
raise QueryLimitError, "total number of query parameters (#{param_count+1}) exceeds limit (#{Rack::Utils.params_limit})"
end

qs
else
''
end
end
module_function :check_query_string

# Stolen from Mongrel, with some small modifications:
# Parses a query string by breaking it up at the '&'
# and ';' characters. You can also use this to parse
Expand All @@ -99,7 +122,7 @@ def parse_query(qs, d = nil, &unescaper)

params = KeySpaceConstrainedParams.new

(qs || '').split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p|
check_query_string(ds, d).split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p|
next if p.empty?
k, v = p.split('=', 2).map(&unescaper)

Expand All @@ -126,7 +149,7 @@ def parse_query(qs, d = nil, &unescaper)
def parse_nested_query(qs, d = nil)
params = KeySpaceConstrainedParams.new

(qs || '').split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p|
check_query_string(ds, d).split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p|
k, v = p.split('=', 2).map { |s| unescape(s) }

normalize_params(params, k, v)
Expand Down
Loading