Skip to content

Commit b26a314

Browse files
jeremyevansioquatix
authored andcommitted
Limit the number of quoted escapes during multipart parsing
This sets a default limit of 8192 escapes, which can be modified using the RACK_MULTIPART_CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT environment variable.
1 parent 263c867 commit b26a314

3 files changed

Lines changed: 54 additions & 0 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ All notable changes to this project will be documented in this file. For info on
1818
- [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`.
1919
- [CVE-2026-34785](https://github.com/advisories/GHSA-h2jq-g4cq-5ppq) `Rack::Static` prefix matching can expose unintended files under the static root.
2020
- [CVE-2026-34829](https://github.com/advisories/GHSA-8vqr-qjwx-82mw) Multipart parsing without `Content-Length` header allows unbounded chunked file uploads.
21+
- [CVE-2026-34827](https://github.com/advisories/GHSA-v6x5-cg8r-vv6x) Quadratic-time multipart header parsing allows denial of service via escape-heavy quoted parameters.
2122

2223
### SPEC Changes
2324

lib/rack/multipart/parser.rb

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,9 @@ class Parser
8484
PARSER_BYTESIZE_LIMIT = bytesize_limit > 0 ? bytesize_limit : nil
8585
private_constant :PARSER_BYTESIZE_LIMIT
8686

87+
CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT = env_int.call("RACK_MULTIPART_CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT", 8 * 1024)
88+
private_constant :CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
89+
8790
class BoundedIO # :nodoc:
8891
def initialize(io, content_length)
8992
@io = io
@@ -264,6 +267,7 @@ def initialize(boundary, tempfile, bufsize, query_parser)
264267
@body_retained = nil
265268
@retained_size = 0
266269
@total_bytes_read = (0 if PARSER_BYTESIZE_LIMIT)
270+
@content_disposition_quoted_escapes = 0
267271
@collector = Collector.new tempfile
268272

269273
@sbuf = StringScanner.new("".b)
@@ -416,6 +420,11 @@ def handle_mime_head
416420
# stop parsing parameter value if found ending quote
417421
break if c == '"'
418422

423+
@content_disposition_quoted_escapes += 1
424+
if @content_disposition_quoted_escapes > CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
425+
raise Error, "number of quoted escapes during content disposition parsing exceeds limit"
426+
end
427+
419428
escaped_char = disposition.slice!(0, 1)
420429
if param == 'filename' && escaped_char != '"'
421430
# Possible IE uploaded filename, append both escape backslash and value

test/spec_multipart.rb

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -658,6 +658,50 @@ def rd.rewind; end
658658
x.must_equal "application/pdf"=>[""]
659659
end
660660

661+
quoted_escape_test_parse = lambda do |parts, escapes_per_part|
662+
boundary = '---------------------------932620571087722842402766118'
663+
escaped_quotes = '\\"' * (escapes_per_part/2)
664+
unescaped_quotes = '"' * (escapes_per_part/2)
665+
666+
data = StringIO.new
667+
parts.times do |i|
668+
data.write("--#{boundary}")
669+
data.write("\r\n")
670+
data.write("Content-Disposition: form-data; name=\"a#{i}#{escaped_quotes}\" filename=\"b#{i}#{escaped_quotes}\"")
671+
data.write("\r\n")
672+
data.write("content-type:application/pdf\r\n")
673+
data.write("\r\n")
674+
data.write("--#{boundary}--\r\n")
675+
end
676+
data.rewind
677+
678+
fixture = {
679+
"CONTENT_TYPE" => "multipart/form-data; boundary=#{boundary}",
680+
"CONTENT_LENGTH" => data.length.to_s,
681+
:input => data,
682+
}
683+
684+
env = Rack::MockRequest.env_for '/', fixture
685+
[Rack::Multipart.parse_multipart(env), unescaped_quotes]
686+
end
687+
688+
it "allows up to 8192 quoted escapes during parsing" do
689+
parts = 32
690+
x, unescaped_quotes = quoted_escape_test_parse.call(parts, 256)
691+
x.keys.must_equal Array.new(parts) {|i| "a#{i}#{unescaped_quotes}" }
692+
parts.times do |i|
693+
key = "a#{i}#{unescaped_quotes}"
694+
v = x[key]
695+
v[:filename].must_equal "b#{i}#{unescaped_quotes}"
696+
v[:name].must_equal key
697+
end
698+
end
699+
700+
it "disallows more than 8192 quoted escapes during parsing" do
701+
proc{quoted_escape_test_parse.call(32, 258)}.must_raise Rack::Multipart::Error
702+
proc{quoted_escape_test_parse.call(33, 256)}.must_raise Rack::Multipart::Error
703+
end
704+
661705
it 'raises an EOF error on content-length mismatch' do
662706
env = Rack::MockRequest.env_for("/", multipart_fixture(:empty))
663707
env['rack.input'] = StringIO.new

0 commit comments

Comments
 (0)