Skip to content

Commit f224f93

Browse files
jeremyevansioquatix
authored andcommitted
Limit amount of retained data when parsing multipart requests
The limit is 16MB by default, and it can be adjusted with the RACK_MULTIPART_MAX_BUFFERED_UPLOAD_SIZE environment variable. Data stored in temporary files is not counted against this limit. However data for other parameters, as well as the data for the mime headers for each parameter (which is retained during parsing) is counted against the limit.
1 parent e08f78c commit f224f93

File tree

4 files changed

+148
-1
lines changed

4 files changed

+148
-1
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ All notable changes to this project will be documented in this file. For info on
77
### Security
88

99
- [CVE-2025-61772](https://github.com/advisories/GHSA-wpv5-97wm-hp9c) Multipart parser buffers unbounded per-part headers, enabling DoS (memory exhaustion)
10+
- [CVE-2025-61771](https://github.com/advisories/GHSA-w9pc-fmgc-vxvw) Multipart parser buffers large non‑file fields entirely in memory, enabling DoS (memory exhaustion)
1011
- [CVE-2025-61770](https://github.com/advisories/GHSA-p543-xpfm-54cp) Unbounded multipart preamble buffering enables DoS (memory exhaustion)
1112

1213
## [3.1.16] - 2025-06-04

README.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -210,6 +210,14 @@ query string, before attempting parsing, so if the same parameter key is
210210
used multiple times in the query, each counts as a separate parameter for
211211
this check.
212212

213+
### `RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT`
214+
215+
This environment variable sets the maximum amount of memory Rack will use
216+
to buffer multipart parameters when parsing a request body. This considers
217+
the size of the multipart mime headers and the body part for multipart
218+
parameters that are buffered in memory and do not use tempfiles. This
219+
defaults to 16MB if not provided.
220+
213221
### `param_depth_limit`
214222

215223
```ruby

lib/rack/multipart/parser.rb

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,21 @@ class Parser
5353
MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024
5454
private_constant :MIME_HEADER_BYTESIZE_LIMIT
5555

56+
env_int = lambda do |key, val|
57+
if str_val = ENV[key]
58+
begin
59+
val = Integer(str_val, 10)
60+
rescue ArgumentError
61+
raise ArgumentError, "non-integer value provided for environment variable #{key}"
62+
end
63+
end
64+
65+
val
66+
end
67+
68+
BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024)
69+
private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT
70+
5671
class BoundedIO # :nodoc:
5772
def initialize(io, content_length)
5873
@io = io
@@ -212,6 +227,8 @@ def initialize(boundary, tempfile, bufsize, query_parser)
212227

213228
@state = :FAST_FORWARD
214229
@mime_index = 0
230+
@body_retained = nil
231+
@retained_size = 0
215232
@collector = Collector.new tempfile
216233

217234
@sbuf = StringScanner.new("".dup)
@@ -413,6 +430,15 @@ def handle_mime_head
413430
name = filename || "#{content_type || TEXT_PLAIN}[]".dup
414431
end
415432

433+
# Mime part head data is retained for both TempfilePart and BufferPart
434+
# for the entireity of the parse, even though it isn't used for BufferPart.
435+
update_retained_size(head.bytesize)
436+
437+
# If a filename is given, a TempfilePart will be used, so the body will
438+
# not be buffered in memory. However, if a filename is not given, a BufferPart
439+
# will be used, and the body will be buffered in memory.
440+
@body_retained = !filename
441+
416442
@collector.on_mime_head @mime_index, head, filename, content_type, name
417443
@state = :MIME_BODY
418444
else
@@ -427,6 +453,7 @@ def handle_mime_head
427453
def handle_mime_body
428454
if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet
429455
body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string
456+
update_retained_size(body.bytesize) if @body_retained
430457
@collector.on_mime_body @mime_index, body
431458
@sbuf.pos += body.length + 2 # skip \r\n after the content
432459
@state = :CONSUME_TOKEN
@@ -435,14 +462,23 @@ def handle_mime_body
435462
# Save what we have so far
436463
if @rx_max_size < @sbuf.rest_size
437464
delta = @sbuf.rest_size - @rx_max_size
438-
@collector.on_mime_body @mime_index, @sbuf.peek(delta)
465+
body = @sbuf.peek(delta)
466+
update_retained_size(body.bytesize) if @body_retained
467+
@collector.on_mime_body @mime_index, body
439468
@sbuf.pos += delta
440469
@sbuf.string = @sbuf.rest
441470
end
442471
:want_read
443472
end
444473
end
445474

475+
def update_retained_size(size)
476+
@retained_size += size
477+
if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT
478+
raise Error, "multipart data over retained size limit"
479+
end
480+
end
481+
446482
# Scan until the we find the start or end of the boundary.
447483
# If we find it, return the appropriate symbol for the start or
448484
# end of the boundary. If we don't find the start or end of the

test/spec_multipart.rb

Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -288,6 +288,108 @@ def rd.rewind; end
288288
wr.close
289289
end
290290

291+
it "rejects excessive buffered mime data size in a single parameter" do
292+
rd, wr = IO.pipe
293+
def rd.rewind; end
294+
wr.sync = true
295+
296+
thr = Thread.new do
297+
wr.write("--AaB03x")
298+
wr.write("\r\n")
299+
wr.write('content-disposition: form-data; name="a"')
300+
wr.write("\r\n")
301+
wr.write("content-type: text/plain\r\n")
302+
wr.write("\r\n")
303+
wr.write("0" * 17 * 1024 * 1024)
304+
wr.write("--AaB03x--\r\n")
305+
wr.close
306+
true
307+
end
308+
309+
fixture = {
310+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
311+
"CONTENT_LENGTH" => (18 * 1024 * 1024).to_s,
312+
:input => rd,
313+
}
314+
315+
env = Rack::MockRequest.env_for '/', fixture
316+
lambda {
317+
Rack::Multipart.parse_multipart(env)
318+
}.must_raise(Rack::Multipart::Error).message.must_equal "multipart data over retained size limit"
319+
rd.close
320+
321+
thr.value.must_equal true
322+
wr.close
323+
end
324+
325+
it "rejects excessive buffered mime data size when split into multiple parameters" do
326+
rd, wr = IO.pipe
327+
def rd.rewind; end
328+
wr.sync = true
329+
330+
thr = Thread.new do
331+
4.times do |i|
332+
wr.write("\r\n--AaB03x")
333+
wr.write("\r\n")
334+
wr.write("content-disposition: form-data; name=\"a#{i}\"")
335+
wr.write("\r\n")
336+
wr.write("content-type: text/plain\r\n")
337+
wr.write("\r\n")
338+
wr.write("0" * 4 * 1024 * 1024)
339+
end
340+
wr.write("\r\n--AaB03x--\r\n")
341+
wr.close
342+
true
343+
end
344+
345+
fixture = {
346+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
347+
"CONTENT_LENGTH" => (17 * 1024 * 1024).to_s,
348+
:input => rd,
349+
}
350+
351+
env = Rack::MockRequest.env_for '/', fixture
352+
lambda {
353+
p Rack::Multipart.parse_multipart(env).keys
354+
}.must_raise(Rack::Multipart::Error).message.must_equal "multipart data over retained size limit"
355+
rd.close
356+
357+
thr.value.must_equal true
358+
wr.close
359+
end
360+
361+
it "allows large nonbuffered mime parameters" do
362+
rd, wr = IO.pipe
363+
def rd.rewind; end
364+
wr.sync = true
365+
366+
thr = Thread.new do
367+
wr.write("\r\n\r\n--AaB03x")
368+
wr.write("\r\n")
369+
wr.write('content-disposition: form-data; name="a"; filename="a.txt"')
370+
wr.write("\r\n")
371+
wr.write("content-type: text/plain\r\n")
372+
wr.write("\r\n")
373+
wr.write("0" * 16 * 1024 * 1024)
374+
wr.write("\r\n--AaB03x--\r\n")
375+
wr.close
376+
true
377+
end
378+
379+
fixture = {
380+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
381+
"CONTENT_LENGTH" => (17 * 1024 * 1024).to_s,
382+
:input => rd,
383+
}
384+
385+
env = Rack::MockRequest.env_for '/', fixture
386+
Rack::Multipart.parse_multipart(env)['a'][:tempfile].read.bytesize.must_equal(16 * 1024 * 1024)
387+
rd.close
388+
389+
thr.value.must_equal true
390+
wr.close
391+
end
392+
291393
# see https://github.com/rack/rack/pull/1309
292394
it "parses strange multipart pdf" do
293395
boundary = '---------------------------932620571087722842402766118'

0 commit comments

Comments
 (0)