Skip to content
Open
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
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
25 changes: 21 additions & 4 deletions lib/rack/multipart/parser.rb
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ class MultipartTotalPartLimitError < StandardError; end

class Parser
BUFSIZE = 16384
MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024
DUMMY = Struct.new(:parse).new

def self.create(env)
Expand Down Expand Up @@ -47,6 +48,8 @@ def initialize(boundary, io, content_length, env, tempfile, bufsize)

@rx = /(?:#{EOL})?#{Regexp.quote(@boundary)}(#{EOL}|--)/n
@full_boundary = @boundary + EOL

@retained_size = 0
end

def parse
Expand All @@ -70,13 +73,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 @@ -113,7 +117,7 @@ def fast_forward_to_first_boundary
return if read_buffer == full_boundary
end

raise EOFError, "bad content body" if Utils.bytesize(@buf) >= @bufsize
raise EOFError, "multipart boundary not found within limit" if Utils.bytesize(@buf) >= @bufsize
end
end

Expand All @@ -133,6 +137,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 +156,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)
body << @buf.slice!(0, @buf.size - (@boundary_size+4))
size_to_read = @buf.size - (@boundary_size+4)
if head && size_to_read > 0
body << @buf.slice!(0, size_to_read)
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 +275,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
55 changes: 48 additions & 7 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,15 +102,29 @@ 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
@variation = variation
@mappings = mappings.map do |internal, external|
[/^#{internal}/i, external]
[/\A#{internal}/i, external]
end
end

Expand Down Expand Up @@ -135,26 +154,48 @@ def call(env)
end
when '', nil
else
env['rack.errors'].puts "Unknown x-sendfile variation: '#{type}'.\n"
env['rack.errors'].puts "Unknown x-sendfile variation: #{type.inspect}"
end
end
[status, headers, body]
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'
return nil if @mappings.any?

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)
path.sub(/\A#{internal}/i, external)
end
end

end
end
44 changes: 42 additions & 2 deletions lib/rack/utils.rb
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,10 @@ class ParameterTypeError < TypeError; end
# sequence.
class InvalidParameterError < ArgumentError; end

# QueryLimitError is for errors raised when the query provided exceeds one
# of the query parser limits.
class QueryLimitError < RangeError; end

# URI escapes. (CGI style space to +)
def escape(s)
URI.encode_www_form_component(s)
Expand Down Expand Up @@ -64,6 +68,9 @@ class << self
attr_accessor :param_depth_limit
attr_accessor :multipart_total_part_limit
attr_accessor :multipart_file_limit
attr_accessor :bytesize_limit # CVE-2025-46727
attr_accessor :params_limit # CVE-2025-46727
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 +96,39 @@ 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 sets the default for the maximum query string bytesize that we will attempt to parse.
# Attempts to use a query string that exceeds this number of bytes will result in a
# `Rack::Utils::QueryLimitError` exception.
self.bytesize_limit = (ENV['RACK_QUERY_PARSER_BYTESIZE_LIMIT'] || 4194304).to_i

# This variable sets the default for the maximum number of query
# parameters that we will attempt to parse. Attempts to use a
# query string with more than this many query parameters will result in a
# `Rack::Utils::QueryLimitError` exception.
self.params_limit = (ENV['RACK_QUERY_PARSER_PARAMS_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 +139,7 @@ def parse_query(qs, d = nil, &unescaper)

params = KeySpaceConstrainedParams.new

(qs || '').split(d ? /[#{d}] */n : DEFAULT_SEP).each do |p|
check_query_string(qs, 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 +166,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(qs, 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