Skip to content

Commit 3beacfc

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 589127f commit 3beacfc

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.2.1] -- 2025-09-02

README.md

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

233+
### `RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT`
234+
235+
This environment variable sets the maximum amount of memory Rack will use
236+
to buffer multipart parameters when parsing a request body. This considers
237+
the size of the multipart mime headers and the body part for multipart
238+
parameters that are buffered in memory and do not use tempfiles. This
239+
defaults to 16MB if not provided.
240+
233241
### `param_depth_limit`
234242

235243
```ruby

lib/rack/multipart/parser.rb

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,21 @@ class Parser
6565
MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024
6666
private_constant :MIME_HEADER_BYTESIZE_LIMIT
6767

68+
env_int = lambda do |key, val|
69+
if str_val = ENV[key]
70+
begin
71+
val = Integer(str_val, 10)
72+
rescue ArgumentError
73+
raise ArgumentError, "non-integer value provided for environment variable #{key}"
74+
end
75+
end
76+
77+
val
78+
end
79+
80+
BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024)
81+
private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT
82+
6883
class BoundedIO # :nodoc:
6984
def initialize(io, content_length)
7085
@io = io
@@ -224,6 +239,8 @@ def initialize(boundary, tempfile, bufsize, query_parser)
224239

225240
@state = :FAST_FORWARD
226241
@mime_index = 0
242+
@body_retained = nil
243+
@retained_size = 0
227244
@collector = Collector.new tempfile
228245

229246
@sbuf = StringScanner.new("".dup)
@@ -420,6 +437,15 @@ def handle_mime_head
420437
name = filename || "#{content_type || TEXT_PLAIN}[]".dup
421438
end
422439

440+
# Mime part head data is retained for both TempfilePart and BufferPart
441+
# for the entireity of the parse, even though it isn't used for BufferPart.
442+
update_retained_size(head.bytesize)
443+
444+
# If a filename is given, a TempfilePart will be used, so the body will
445+
# not be buffered in memory. However, if a filename is not given, a BufferPart
446+
# will be used, and the body will be buffered in memory.
447+
@body_retained = !filename
448+
423449
@collector.on_mime_head @mime_index, head, filename, content_type, name
424450
@state = :MIME_BODY
425451
else
@@ -434,6 +460,7 @@ def handle_mime_head
434460
def handle_mime_body
435461
if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet
436462
body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string
463+
update_retained_size(body.bytesize) if @body_retained
437464
@collector.on_mime_body @mime_index, body
438465
@sbuf.pos += body.length + 2 # skip \r\n after the content
439466
@state = :CONSUME_TOKEN
@@ -442,14 +469,23 @@ def handle_mime_body
442469
# Save what we have so far
443470
if @rx_max_size < @sbuf.rest_size
444471
delta = @sbuf.rest_size - @rx_max_size
445-
@collector.on_mime_body @mime_index, @sbuf.peek(delta)
472+
body = @sbuf.peek(delta)
473+
update_retained_size(body.bytesize) if @body_retained
474+
@collector.on_mime_body @mime_index, body
446475
@sbuf.pos += delta
447476
@sbuf.string = @sbuf.rest
448477
end
449478
:want_read
450479
end
451480
end
452481

482+
def update_retained_size(size)
483+
@retained_size += size
484+
if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT
485+
raise Error, "multipart data over retained size limit"
486+
end
487+
end
488+
453489
# Scan until the we find the start or end of the boundary.
454490
# If we find it, return the appropriate symbol for the start or
455491
# 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
@@ -294,6 +294,108 @@ def rd.rewind; end
294294
wr.close
295295
end
296296

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

0 commit comments

Comments
 (0)