From c370dcd9405a6799763b70a83f06ae2d1aaa0e87 Mon Sep 17 00:00:00 2001
From: Jeremy Evans <code@jeremyevans.net>
Date: Mon, 15 Sep 2025 19:10:49 -0700
Subject: [PATCH] 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.
---
 CHANGELOG.md                 |   1 +
 README.rdoc                  |   8 +++
 lib/rack/multipart/parser.rb |  42 ++++++++++++++-
 test/spec_multipart.rb       | 102 +++++++++++++++++++++++++++++++++++
 4 files changed, 152 insertions(+), 1 deletion(-)

--- a/README.rdoc
+++ b/README.rdoc
@@ -159,6 +159,14 @@
 
     Rack::Utils.key_space_limit = 128
 
+=== `RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT`
+
+This environment variable sets the maximum amount of memory Rack will use
+to buffer multipart parameters when parsing a request body. This considers
+the size of the multipart mime headers and the body part for multipart
+parameters that are buffered in memory and do not use tempfiles. This
+defaults to 16MB if not provided.
+
 === key_space_limit
 
 The default number of bytes to allow a single parameter key to take up.
--- a/lib/rack/multipart/parser.rb
+++ b/lib/rack/multipart/parser.rb
@@ -26,6 +26,21 @@
       MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024
       private_constant :MIME_HEADER_BYTESIZE_LIMIT
 
+      env_int = lambda do |key, val|
+        if str_val = ENV[key]
+          begin
+            val = Integer(str_val, 10)
+          rescue ArgumentError
+            raise ArgumentError, "non-integer value provided for environment variable #{key}"
+          end
+        end
+
+        val
+      end
+
+      BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024)
+      private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT
+
       class BoundedIO # :nodoc:
         def initialize(io, content_length)
           @io             = io
@@ -199,6 +214,8 @@
         @end_boundary = @boundary + '--'
         @state = :FAST_FORWARD
         @mime_index = 0
+        @body_retained = nil
+        @retained_size = 0
         @collector = Collector.new tempfile
 
         @sbuf = StringScanner.new("".dup)
@@ -283,6 +300,15 @@
             name = filename || "#{content_type || TEXT_PLAIN}[]".dup
           end
 
+          # Mime part head data is retained for both TempfilePart and BufferPart
+          # for the entireity of the parse, even though it isn't used for BufferPart.
+          update_retained_size(head.bytesize)
+
+          # If a filename is given, a TempfilePart will be used, so the body will
+          # not be buffered in memory. However, if a filename is not given, a BufferPart
+          # will be used, and the body will be buffered in memory.
+          @body_retained = !filename
+
           @collector.on_mime_head @mime_index, head, filename, content_type, name
           @state = :MIME_BODY
         else
@@ -297,6 +323,7 @@
       def handle_mime_body
         if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet
           body = body_with_boundary.sub(/#{@body_regex}\z/m, '') # remove the boundary from the string
+          update_retained_size(body.bytesize) if @body_retained
           @collector.on_mime_body @mime_index, body
           @sbuf.pos += body.length + 2 # skip \r\n after the content
           @state = :CONSUME_TOKEN
@@ -305,7 +332,9 @@
           # Save what we have so far
           if @rx_max_size < @sbuf.rest_size
             delta = @sbuf.rest_size - @rx_max_size
-            @collector.on_mime_body @mime_index, @sbuf.peek(delta)
+            body = @sbuf.peek(delta)
+            update_retained_size(body.bytesize) if @body_retained
+            @collector.on_mime_body @mime_index, body
             @sbuf.pos += delta
             @sbuf.string = @sbuf.rest
           end
@@ -315,6 +344,17 @@
 
       def full_boundary; @full_boundary; end
 
+      def update_retained_size(size)
+        @retained_size += size
+        if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT
+          raise EOFError, "multipart data over retained size limit"
+        end
+      end
+
+      # Scan until the we find the start or end of the boundary.
+      # If we find it, return the appropriate symbol for the start or
+      # end of the boundary.  If we don't find the start or end of the
+      # boundary, clear the buffer and return nil.
       def consume_boundary
         while read_buffer = @sbuf.scan_until(BOUNDARY_REGEX)
           case read_buffer.strip
--- a/test/spec_multipart.rb
+++ b/test/spec_multipart.rb
@@ -230,6 +230,108 @@
     wr.close
   end
 
+  it "rejects excessive buffered mime data size in a single parameter" do
+    rd, wr = IO.pipe
+    def rd.rewind; end
+    wr.sync = true
+
+    thr = Thread.new do
+      wr.write("--AaB03x")
+      wr.write("\r\n")
+      wr.write('content-disposition: form-data; name="a"')
+      wr.write("\r\n")
+      wr.write("content-type: text/plain\r\n")
+      wr.write("\r\n")
+      wr.write("0" * 17 * 1024 * 1024)
+      wr.write("--AaB03x--\r\n")
+      wr.close
+      true
+    end
+
+    fixture = {
+      "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
+      "CONTENT_LENGTH" => (18 * 1024 * 1024).to_s,
+      :input => rd,
+    }
+
+    env = Rack::MockRequest.env_for '/', fixture
+    lambda {
+      Rack::Multipart.parse_multipart(env)
+    }.must_raise(EOFError).message.must_equal "multipart data over retained size limit"
+    rd.close
+
+    thr.value.must_equal true
+    wr.close
+  end
+
+  it "rejects excessive buffered mime data size when split into multiple parameters" do
+    rd, wr = IO.pipe
+    def rd.rewind; end
+    wr.sync = true
+
+    thr = Thread.new do
+      4.times do |i|
+        wr.write("\r\n--AaB03x")
+        wr.write("\r\n")
+        wr.write("content-disposition: form-data; name=\"a#{i}\"")
+        wr.write("\r\n")
+        wr.write("content-type: text/plain\r\n")
+        wr.write("\r\n")
+        wr.write("0" * 4 * 1024 * 1024)
+      end
+      wr.write("\r\n--AaB03x--\r\n")
+      wr.close
+      true
+    end
+
+    fixture = {
+      "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
+      "CONTENT_LENGTH" => (17 * 1024 * 1024).to_s,
+      :input => rd,
+    }
+
+    env = Rack::MockRequest.env_for '/', fixture
+    lambda {
+  p    Rack::Multipart.parse_multipart(env).keys
+    }.must_raise(EOFError).message.must_equal "multipart data over retained size limit"
+    rd.close
+
+    thr.value.must_equal true
+    wr.close
+  end
+
+  it "allows large nonbuffered mime parameters" do
+    rd, wr = IO.pipe
+    def rd.rewind; end
+    wr.sync = true
+
+    thr = Thread.new do
+      wr.write("\r\n\r\n--AaB03x")
+      wr.write("\r\n")
+      wr.write('content-disposition: form-data; name="a"; filename="a.txt"')
+      wr.write("\r\n")
+      wr.write("content-type: text/plain\r\n")
+      wr.write("\r\n")
+      wr.write("0" * 16 * 1024 * 1024)
+      wr.write("\r\n--AaB03x--\r\n")
+      wr.close
+      true
+    end
+
+    fixture = {
+      "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
+      "CONTENT_LENGTH" => (17 * 1024 * 1024).to_s,
+      :input => rd,
+    }
+
+    env = Rack::MockRequest.env_for '/', fixture
+    Rack::Multipart.parse_multipart(env)['a'][:tempfile].read.bytesize.must_equal(16 * 1024 * 1024)
+    rd.close
+
+    thr.value.must_equal true
+    wr.close
+  end
+
   # see https://github.com/rack/rack/pull/1309
   it "parse strange multipart pdf" do
     boundary = '---------------------------932620571087722842402766118'
