Skip to content

Commit c42e357

Browse files
th4s1sioquatix
authored andcommitted
Add Content-Length size check in Rack::Multipart::Parser
Compare the declared `Content-Length` against a configurable maximum (`PARSER_BYTESIZE_LIMIT`) before any parsing begins. If it exceeds the limit, raise an exception immediately.
1 parent 203730e commit c42e357

3 files changed

Lines changed: 72 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ All notable changes to this project will be documented in this file. For info on
1414
- [CVE-2026-34826](https://github.com/advisories/GHSA-x8cg-fq8g-mxfx) Multipart byte range processing allows denial of service via excessive overlapping ranges.
1515
- [CVE-2026-34830](https://github.com/advisories/GHSA-qv7j-4883-hwh7) `Rack::Sendfile` header-based `X-Accel-Mapping` regex injection enables unauthorized `X-Accel-Redirect`.
1616
- [CVE-2026-34785](https://github.com/advisories/GHSA-h2jq-g4cq-5ppq) `Rack::Static` prefix matching can expose unintended files under the static root.
17+
- [CVE-2026-34829](https://github.com/advisories/GHSA-8vqr-qjwx-82mw) Multipart parsing without `Content-Length` header allows unbounded chunked file uploads.
1718

1819
## [2.2.22] - 2026-02-16
1920

lib/rack/multipart/parser.rb

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ class Parser
4141
BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024)
4242
private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT
4343

44+
bytesize_limit = env_int.call("RACK_MULTIPART_PARSER_BYTESIZE_LIMIT", 10 * 1024 * 1024 * 1024)
45+
PARSER_BYTESIZE_LIMIT = bytesize_limit > 0 ? bytesize_limit : nil
46+
private_constant :PARSER_BYTESIZE_LIMIT
47+
4448
class BoundedIO # :nodoc:
4549
def initialize(io, content_length)
4650
@io = io
@@ -98,6 +102,10 @@ def self.parse(io, content_length, content_type, tmpfile, bufsize, qp)
98102
boundary = parse_boundary content_type
99103
return EMPTY unless boundary
100104

105+
if PARSER_BYTESIZE_LIMIT && content_length && content_length > PARSER_BYTESIZE_LIMIT
106+
raise EOFError, "multipart Content-Length #{content_length} exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
107+
end
108+
101109
io = BoundedIO.new(io, content_length) if content_length
102110
outbuf = String.new
103111

@@ -218,6 +226,7 @@ def initialize(boundary, tempfile, bufsize, query_parser)
218226
@mime_index = 0
219227
@body_retained = nil
220228
@retained_size = 0
229+
@total_bytes_read = (0 if PARSER_BYTESIZE_LIMIT)
221230
@collector = Collector.new tempfile
222231

223232
@sbuf = StringScanner.new("".dup)
@@ -229,6 +238,12 @@ def initialize(boundary, tempfile, bufsize, query_parser)
229238

230239
def on_read(content)
231240
handle_empty_content!(content)
241+
if @total_bytes_read
242+
@total_bytes_read += content.bytesize
243+
if @total_bytes_read > PARSER_BYTESIZE_LIMIT
244+
raise EOFError, "multipart upload exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
245+
end
246+
end
232247
@sbuf.concat content
233248
run_parser
234249
end

test/spec_multipart.rb

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,19 @@ def multipart_file(name)
2020
File.join(File.dirname(__FILE__), "multipart", name.to_s)
2121
end
2222

23+
24+
def with_multipart_limit(limit)
25+
previous = Rack::Multipart::Parser.send(:const_get, :PARSER_BYTESIZE_LIMIT)
26+
begin
27+
Rack::Multipart::Parser.send(:remove_const, :PARSER_BYTESIZE_LIMIT)
28+
Rack::Multipart::Parser.const_set(:PARSER_BYTESIZE_LIMIT, limit)
29+
yield
30+
ensure
31+
Rack::Multipart::Parser.send(:remove_const, :PARSER_BYTESIZE_LIMIT)
32+
Rack::Multipart::Parser.const_set(:PARSER_BYTESIZE_LIMIT, previous)
33+
end
34+
end
35+
2336
it "return nil if content type is not multipart" do
2437
env = Rack::MockRequest.env_for("/",
2538
"CONTENT_TYPE" => 'application/x-www-form-urlencoded')
@@ -49,6 +62,49 @@ def multipart_file(name)
4962
}.must_raise EOFError
5063
end
5164

65+
it "raises an exception if Content-Length exceeds total bytesize limit" do
66+
with_multipart_limit(1024) do
67+
env = Rack::MockRequest.env_for("/",
68+
"CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x",
69+
"CONTENT_LENGTH" => "2048",
70+
:input => StringIO.new("--AaB03x--\r\n"))
71+
72+
lambda {
73+
Rack::Multipart.parse_multipart(env)
74+
}.must_raise(EOFError)
75+
end
76+
end
77+
78+
it "allows requests within the total bytesize limit" do
79+
with_multipart_limit(1024 * 1024) do
80+
env = Rack::MockRequest.env_for("/", multipart_fixture(:text))
81+
params = Rack::Multipart.parse_multipart(env)
82+
params["submit-name"].must_equal "Larry"
83+
end
84+
end
85+
86+
it "skips total bytesize check when there is no limit" do
87+
with_multipart_limit(nil) do
88+
env = Rack::MockRequest.env_for("/", multipart_fixture(:text))
89+
params = Rack::Multipart.parse_multipart(env)
90+
params["submit-name"].must_equal "Larry"
91+
end
92+
end
93+
94+
it "enforces total bytesize limit during streaming when Content-Length is absent" do
95+
with_multipart_limit(1) do
96+
# Even without Content-Length, the streaming check catches oversized uploads
97+
fixture = multipart_fixture(:text)
98+
fixture.delete("CONTENT_LENGTH")
99+
env = Rack::MockRequest.env_for("/", fixture)
100+
env.delete("CONTENT_LENGTH")
101+
102+
lambda {
103+
Rack::Multipart.parse_multipart(env)
104+
}.must_raise EOFError
105+
end
106+
end
107+
52108
it "parses multipart content when content type is present but disposition is not" do
53109
env = Rack::MockRequest.env_for("/", multipart_fixture(:content_type_and_no_disposition))
54110
params = Rack::Multipart.parse_multipart(env)

0 commit comments

Comments
 (0)