rack 3.1.20 → 3.1.21

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a488ddfe5c740f9e46a70c38210547991b02820facbdcc083dcfc8819c356a13
4
- data.tar.gz: b8725e15bfe9c87116be051393bfb50347be679eabd108ee8cfefc8a0fa31925
3
+ metadata.gz: 7aaf1007eae50de14eec6eaa6a4d586f1fe15b614bfe77bf68bea65c6ad59423
4
+ data.tar.gz: 56ce4db8b9c1aeffaf750836ea16bf264841909aeb4befb81719e45189742ac3
5
5
  SHA512:
6
- metadata.gz: 0af4c23fa7431ad434e0ea9dad90ced022c47bff29bf8a161b3f7feb107ae7ee0ff69c487548d0da29c80cee24848c2922b6d78e3da14687212a57fa61a16f84
7
- data.tar.gz: 1ef0da78779595bc3f56d80b9959feb0a725b2190d717a2c4df9ef5c04bb76bbf1888297383a9cb058974e3107d693e8779b2410b55869da4916367bce091b0f
6
+ metadata.gz: 3917f67c856cf670e2f3eaf4432b74366f8b5ee09a78ae2acad9075b10cca355fc718b5025fed92091b56187e4e614fc5ab24066c26298a3af4352ec064fe490
7
+ data.tar.gz: 6a741930a02797a64a50bf7fb8e11957e4bf0c5771a26e0491c131c3ad71d0e120f5a70ceec80fe12c0d866daaa822eb0cdcb9f55a9a2d509ef01659fde08231
data/CHANGELOG.md CHANGED
@@ -2,7 +2,24 @@
2
2
 
3
3
  All notable changes to this project will be documented in this file. For info on how to format all future additions to this file please reference [Keep A Changelog](https://keepachangelog.com/en/1.0.0/).
4
4
 
5
- ## Unreleased
5
+ ## [3.1.21] - 2026-04-01
6
+
7
+ ### Security
8
+
9
+ - [CVE-2026-34763](https://github.com/advisories/GHSA-7mqq-6cf9-v2qp) Root directory disclosure via unescaped regex interpolation in `Rack::Directory`.
10
+ - [CVE-2026-34230](https://github.com/advisories/GHSA-v569-hp3g-36wr) Avoid O(n^2) algorithm in `Rack::Utils.select_best_encoding` which could lead to denial of service.
11
+ - [CVE-2026-32762](https://github.com/advisories/GHSA-qfgr-crr9-7r49) Forwarded header semicolon injection enables Host and Scheme spoofing.
12
+ - [CVE-2026-26961](https://github.com/advisories/GHSA-vgpv-f759-9wx3) Raise error for multipart requests with multiple boundary parameters.
13
+ - [CVE-2026-34786](https://github.com/advisories/GHSA-q4qf-9j86-f5mh) `Rack::Static` `header_rules` bypass via URL-encoded path mismatch.
14
+ - [CVE-2026-34831](https://github.com/advisories/GHSA-q2ww-5357-x388) `Content-Length` mismatch in `Rack::Files` error responses.
15
+ - [CVE-2026-34826](https://github.com/advisories/GHSA-x8cg-fq8g-mxfx) Multipart byte range processing allows denial of service via excessive overlapping ranges.
16
+ - [CVE-2026-34835](https://github.com/advisories/GHSA-g2pf-xv49-m2h5) `Rack::Request` accepts invalid Host characters, enabling host allowlist bypass.
17
+ - [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`.
18
+ - [CVE-2026-34785](https://github.com/advisories/GHSA-h2jq-g4cq-5ppq) `Rack::Static` prefix matching can expose unintended files under the static root.
19
+ - [CVE-2026-34829](https://github.com/advisories/GHSA-8vqr-qjwx-82mw) Multipart parsing without `Content-Length` header allows unbounded chunked file uploads.
20
+ - [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.
21
+
22
+ ## [3.1.20] - 2026-02-16
6
23
 
7
24
  ### Security
8
25
 
@@ -393,6 +410,13 @@ Rack v3.1 is primarily a maintenance release that removes features deprecated in
393
410
  - Fix multipart filename generation for filenames that contain spaces. Encode spaces as "%20" instead of "+" which will be decoded properly by the multipart parser. ([#1736](https://github.com/rack/rack/pull/1645), [@muirdm](https://github.com/muirdm))
394
411
  - `Rack::Request#scheme` returns `ws` or `wss` when one of the `X-Forwarded-Scheme` / `X-Forwarded-Proto` headers is set to `ws` or `wss`, respectively. ([#1730](https://github.com/rack/rack/issues/1730), [@erwanst](https://github.com/erwanst))
395
412
 
413
+ ## [2.2.22] - 2026-02-16
414
+
415
+ ### Security
416
+
417
+ - [CVE-2026-25500](https://github.com/advisories/GHSA-whrj-4476-wvmp) XSS injection via malicious filename in `Rack::Directory`.
418
+ - [CVE-2026-22860](https://github.com/advisories/GHSA-mxw3-3hh2-x2mh) Directory traversal via root prefix bypass in `Rack::Directory`.
419
+
396
420
  ## [2.2.21] - 2025-11-03
397
421
 
398
422
  ### Fixed
@@ -51,7 +51,7 @@ table { width:100%%; }
51
51
  class DirectoryBody < Struct.new(:root, :path, :files)
52
52
  # Yield strings for each part of the directory entry
53
53
  def each
54
- show_path = Utils.escape_html(path.sub(/^#{root}/, ''))
54
+ show_path = Utils.escape_html(path.sub(/\A#{Regexp.escape(root)}/, ''))
55
55
  yield(DIR_PAGE_HEADER % [ show_path, show_path ])
56
56
 
57
57
  unless path.chomp('/') == root
data/lib/rack/files.rb CHANGED
@@ -194,7 +194,7 @@ EOF
194
194
  status,
195
195
  {
196
196
  CONTENT_TYPE => "text/plain",
197
- CONTENT_LENGTH => body.size.to_s,
197
+ CONTENT_LENGTH => body.bytesize.to_s,
198
198
  "x-cascade" => "pass"
199
199
  }.merge!(headers),
200
200
  [body]
@@ -33,7 +33,7 @@ module Rack
33
33
  EOL = "\r\n"
34
34
  FWS = /[ \t]+(?:\r\n[ \t]+)?/ # whitespace with optional folding
35
35
  HEADER_VALUE = "(?:[^\r\n]|\r\n[ \t])*" # anything but a non-folding CRLF
36
- MULTIPART = %r|\Amultipart/.*boundary=\"?([^\";,]+)\"?|ni
36
+ MULTIPART = %r|\Amultipart/.*?boundary(\s*)=\"?([^\";,]+)\"?|ni
37
37
  MULTIPART_CONTENT_TYPE = /^Content-Type:#{FWS}?(#{HEADER_VALUE})/ni
38
38
  MULTIPART_CONTENT_DISPOSITION = /^Content-Disposition:#{FWS}?(#{HEADER_VALUE})/ni
39
39
  MULTIPART_CONTENT_ID = /^Content-ID:#{FWS}?(#{HEADER_VALUE})/ni
@@ -68,6 +68,13 @@ module Rack
68
68
  BUFFERED_UPLOAD_BYTESIZE_LIMIT = env_int.call("RACK_MULTIPART_BUFFERED_UPLOAD_BYTESIZE_LIMIT", 16 * 1024 * 1024)
69
69
  private_constant :BUFFERED_UPLOAD_BYTESIZE_LIMIT
70
70
 
71
+ bytesize_limit = env_int.call("RACK_MULTIPART_PARSER_BYTESIZE_LIMIT", 10 * 1024 * 1024 * 1024)
72
+ PARSER_BYTESIZE_LIMIT = bytesize_limit > 0 ? bytesize_limit : nil
73
+ private_constant :PARSER_BYTESIZE_LIMIT
74
+
75
+ CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT = env_int.call("RACK_MULTIPART_CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT", 8 * 1024)
76
+ private_constant :CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
77
+
71
78
  class BoundedIO # :nodoc:
72
79
  def initialize(io, content_length)
73
80
  @io = io
@@ -104,7 +111,15 @@ module Rack
104
111
  return unless content_type
105
112
  data = content_type.match(MULTIPART)
106
113
  return unless data
107
- data[1]
114
+
115
+ unless data[1].empty?
116
+ raise Error, "whitespace between boundary parameter name and equal sign"
117
+ end
118
+ if data.post_match.match?(/boundary\s*=/i)
119
+ raise BoundaryTooLongError, "multiple boundary parameters found in multipart content type"
120
+ end
121
+
122
+ data[2]
108
123
  end
109
124
 
110
125
  def self.parse(io, content_length, content_type, tmpfile, bufsize, qp)
@@ -113,6 +128,10 @@ module Rack
113
128
  boundary = parse_boundary content_type
114
129
  return EMPTY unless boundary
115
130
 
131
+ if PARSER_BYTESIZE_LIMIT && content_length && content_length > PARSER_BYTESIZE_LIMIT
132
+ raise Error, "multipart Content-Length #{content_length} exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
133
+ end
134
+
116
135
  if boundary.length > 70
117
136
  # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary.
118
137
  # Most clients use no more than 55 characters.
@@ -229,6 +248,8 @@ module Rack
229
248
  @mime_index = 0
230
249
  @body_retained = nil
231
250
  @retained_size = 0
251
+ @total_bytes_read = (0 if PARSER_BYTESIZE_LIMIT)
252
+ @content_disposition_quoted_escapes = 0
232
253
  @collector = Collector.new tempfile
233
254
 
234
255
  @sbuf = StringScanner.new("".dup)
@@ -240,6 +261,7 @@ module Rack
240
261
  end
241
262
 
242
263
  def parse(io)
264
+ @total_bytes_read &&= nil if io.is_a?(BoundedIO)
243
265
  outbuf = String.new
244
266
  read_data(io, outbuf)
245
267
 
@@ -283,6 +305,12 @@ module Rack
283
305
  def read_data(io, outbuf)
284
306
  content = io.read(@bufsize, outbuf)
285
307
  handle_empty_content!(content)
308
+ if @total_bytes_read
309
+ @total_bytes_read += content.bytesize
310
+ if @total_bytes_read > PARSER_BYTESIZE_LIMIT
311
+ raise Error, "multipart upload exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
312
+ end
313
+ end
286
314
  @sbuf.concat(content)
287
315
  end
288
316
 
@@ -376,6 +404,11 @@ module Rack
376
404
  # stop parsing parameter value if found ending quote
377
405
  break if c == '"'
378
406
 
407
+ @content_disposition_quoted_escapes += 1
408
+ if @content_disposition_quoted_escapes > CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
409
+ raise Error, "number of quoted escapes during content disposition parsing exceeds limit"
410
+ end
411
+
379
412
  escaped_char = disposition.slice!(0, 1)
380
413
  if param == 'filename' && escaped_char != '"'
381
414
  # Possible IE uploaded filename, append both escape backslash and value
data/lib/rack/request.rb CHANGED
@@ -728,8 +728,8 @@ module Rack
728
728
  # Match IPv6 as a string of hex digits and colons in square brackets
729
729
  \[(?<address>#{ipv6})\]
730
730
  |
731
- # Match any other printable string (except square brackets) as a hostname
732
- (?<address>[[[:graph:]&&[^\[\]]]]*?)
731
+ # Match characters allowed by RFC 3986 Section 3.2.2
732
+ (?<address>[-a-zA-Z0-9._~%!$&'()*+,;=]*?)
733
733
  )
734
734
  (:(?<port>\d+))?
735
735
  \z
data/lib/rack/sendfile.rb CHANGED
@@ -51,7 +51,7 @@ module Rack
51
51
  #
52
52
  # The `x-accel-mapping` header should specify the location on the file system,
53
53
  # followed by an equals sign (=), followed name of the private URL pattern
54
- # that it maps to. The middleware performs a simple substitution on the
54
+ # that it maps to. The middleware performs a case-insensitive substitution on the
55
55
  # resulting path.
56
56
  #
57
57
  # To enable `x-accel-redirect`, you must configure the middleware explicitly:
@@ -186,7 +186,7 @@ module Rack
186
186
  # Safe to use header: explicit config + no app mappings:
187
187
  mapping.split(',').map(&:strip).each do |m|
188
188
  internal, external = m.split('=', 2).map(&:strip)
189
- new_path = path.sub(/\A#{internal}/i, external)
189
+ new_path = path.sub(/\A#{Regexp.escape(internal)}/i, external)
190
190
  return new_path unless path == new_path
191
191
  end
192
192
 
data/lib/rack/static.rb CHANGED
@@ -93,6 +93,9 @@ module Rack
93
93
  def initialize(app, options = {})
94
94
  @app = app
95
95
  @urls = options[:urls] || ["/favicon.ico"]
96
+ if @urls.kind_of?(Array)
97
+ @urls = @urls.map { |url| [url, url.end_with?('/') ? url : "#{url}/".freeze].freeze }.freeze
98
+ end
96
99
  @index = options[:index]
97
100
  @gzip = options[:gzip]
98
101
  @cascade = options[:cascade]
@@ -115,7 +118,7 @@ module Rack
115
118
  end
116
119
 
117
120
  def route_file(path)
118
- @urls.kind_of?(Array) && @urls.any? { |url| path.index(url) == 0 }
121
+ @urls.kind_of?(Array) && @urls.any? { |url, url_slash| path == url || path.start_with?(url_slash) }
119
122
  end
120
123
 
121
124
  def can_serve(path)
@@ -165,6 +168,8 @@ module Rack
165
168
 
166
169
  # Convert HTTP header rules to HTTP headers
167
170
  def applicable_rules(path)
171
+ path = ::Rack::Utils.unescape_path(path)
172
+
168
173
  @header_rules.find_all do |rule, new_headers|
169
174
  case rule
170
175
  when :all
@@ -172,10 +177,9 @@ module Rack
172
177
  when :fonts
173
178
  /\.(?:ttf|otf|eot|woff2|woff|svg)\z/.match?(path)
174
179
  when String
175
- path = ::Rack::Utils.unescape(path)
176
180
  path.start_with?(rule) || path.start_with?('/' + rule)
177
181
  when Array
178
- /\.(#{rule.join('|')})\z/.match?(path)
182
+ /\.#{Regexp.union(rule)}\z/.match?(path)
179
183
  when Regexp
180
184
  rule.match?(path)
181
185
  else
data/lib/rack/utils.rb CHANGED
@@ -146,17 +146,77 @@ module Rack
146
146
  end
147
147
  end
148
148
 
149
+ ALLOWED_FORWARED_PARAMS = %w[by for host proto].to_h { |name| [name, name.to_sym] }.freeze
150
+ private_constant :ALLOWED_FORWARED_PARAMS
151
+
149
152
  def forwarded_values(forwarded_header)
150
- return nil unless forwarded_header
151
- forwarded_header = forwarded_header.to_s.gsub("\n", ";")
152
-
153
- forwarded_header.split(';').each_with_object({}) do |field, values|
154
- field.split(',').each do |pair|
155
- pair = pair.split('=').map(&:strip).join('=')
156
- return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i
157
- (values[$1.downcase.to_sym] ||= []) << $2
153
+ return unless forwarded_header
154
+ header = forwarded_header.to_s.tr("\n", ";")
155
+ header.sub!(/\A[\s;,]+/, '')
156
+ num_params = num_escapes = 0
157
+ max_params = max_escapes = 1024
158
+ params = {}
159
+
160
+ # Parse parameter list
161
+ while i = header.index('=')
162
+ # Only parse up to max parameters, to avoid potential denial of service
163
+ num_params += 1
164
+ return if num_params > max_params
165
+
166
+ # Found end of parameter name, ensure forward progress in loop
167
+ param = header.slice!(0, i+1)
168
+
169
+ # Remove ending equals and preceding whitespace from parameter name
170
+ param.chomp!('=')
171
+ param.strip!
172
+ param.downcase!
173
+ return unless param = ALLOWED_FORWARED_PARAMS[param]
174
+
175
+ if header[0] == '"'
176
+ # Parameter value is quoted, parse it, handling backslash escapes
177
+ header.slice!(0, 1)
178
+ value = String.new
179
+
180
+ while i = header.index(/(["\\])/)
181
+ c = $1
182
+
183
+ # Append all content until ending quote or escape
184
+ value << header.slice!(0, i)
185
+
186
+ # Remove either backslash or ending quote,
187
+ # ensures forward progress in loop
188
+ header.slice!(0, 1)
189
+
190
+ # stop parsing parameter value if found ending quote
191
+ break if c == '"'
192
+
193
+ # Only allow up to max escapes, to avoid potential denial of service
194
+ num_escapes += 1
195
+ return if num_escapes > max_escapes
196
+ escaped_char = header.slice!(0, 1)
197
+ value << escaped_char
198
+ end
199
+ else
200
+ if i = header.index(/[;,]/)
201
+ # Parameter value unquoted (which may be invalid), value ends at comma or semicolon
202
+ value = header.slice!(0, i)
203
+ value.sub!(/[\s;,]+\z/, '')
204
+ else
205
+ # If no ending semicolon, assume remainder of line is value and stop parsing
206
+ header.strip!
207
+ value = header
208
+ header = ''
209
+ end
210
+ value.lstrip!
158
211
  end
212
+
213
+ (params[param] ||= []) << value
214
+
215
+ # skip trailing semicolons/commas/whitespace, to proceed to next parameter
216
+ header.sub!(/\A[\s;,]+/, '') unless header.empty?
159
217
  end
218
+
219
+ params
160
220
  end
161
221
  module_function :forwarded_values
162
222
 
@@ -189,17 +249,41 @@ module Rack
189
249
  end
190
250
  end
191
251
 
252
+ # Given an array of available encoding strings, and an array of
253
+ # acceptable encodings for a request, where each element of the
254
+ # acceptable encodings array is an array where the first element
255
+ # is an encoding name and the second element is the numeric
256
+ # priority for the encoding, return the available encoding with
257
+ # the highest priority.
258
+ #
259
+ # The accept_encoding argument is typically generated by calling
260
+ # Request#accept_encoding.
261
+ #
262
+ # Example:
263
+ #
264
+ # select_best_encoding(%w(compress gzip identity),
265
+ # [["compress", 0.5], ["gzip", 1.0]])
266
+ # # => "gzip"
267
+ #
268
+ # To reduce denial of service potential, only the first 16
269
+ # acceptable encodings are considered.
192
270
  def select_best_encoding(available_encodings, accept_encoding)
193
271
  # http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html
194
272
 
273
+ # Only process the first 16 encodings
274
+ accept_encoding = accept_encoding[0...16]
195
275
  expanded_accept_encoding = []
276
+ wildcard_seen = false
196
277
 
197
278
  accept_encoding.each do |m, q|
198
279
  preference = available_encodings.index(m) || available_encodings.size
199
280
 
200
281
  if m == "*"
201
- (available_encodings - accept_encoding.map(&:first)).each do |m2|
202
- expanded_accept_encoding << [m2, q, preference]
282
+ unless wildcard_seen
283
+ (available_encodings - accept_encoding.map(&:first)).each do |m2|
284
+ expanded_accept_encoding << [m2, q, preference]
285
+ end
286
+ wildcard_seen = true
203
287
  end
204
288
  else
205
289
  expanded_accept_encoding << [m, q, preference]
@@ -207,7 +291,13 @@ module Rack
207
291
  end
208
292
 
209
293
  encoding_candidates = expanded_accept_encoding
210
- .sort_by { |_, q, p| [-q, p] }
294
+ .sort do |(_, q1, p1), (_, q2, p2)|
295
+ if r = (q1 <=> q2).nonzero?
296
+ -r
297
+ else
298
+ (p1 <=> p2).nonzero? || 0
299
+ end
300
+ end
211
301
  .map!(&:first)
212
302
 
213
303
  unless encoding_candidates.include?("identity")
@@ -406,17 +496,19 @@ module Rack
406
496
  # Parses the "Range:" header, if present, into an array of Range objects.
407
497
  # Returns nil if the header is missing or syntactically invalid.
408
498
  # Returns an empty array if none of the ranges are satisfiable.
409
- def byte_ranges(env, size)
410
- get_byte_ranges env['HTTP_RANGE'], size
499
+ def byte_ranges(env, size, max_ranges: 100)
500
+ get_byte_ranges env['HTTP_RANGE'], size, max_ranges: max_ranges
411
501
  end
412
502
 
413
- def get_byte_ranges(http_range, size)
503
+ def get_byte_ranges(http_range, size, max_ranges: 100)
414
504
  # See <http://www.w3.org/Protocols/rfc2616/rfc2616-sec14.html#sec14.35>
415
505
  # Ignore Range when file size is 0 to avoid a 416 error.
416
506
  return nil if size.zero?
417
507
  return nil unless http_range && http_range =~ /bytes=([^;]+)/
508
+ byte_range = $1
509
+ return nil if byte_range.count(',') >= max_ranges
418
510
  ranges = []
419
- $1.split(/,\s*/).each do |range_spec|
511
+ byte_range.split(/,[ \t]*/).each do |range_spec|
420
512
  return nil unless range_spec.include?('-')
421
513
  range = range_spec.split('-')
422
514
  r0, r1 = range[0], range[1]
data/lib/rack/version.rb CHANGED
@@ -12,7 +12,7 @@
12
12
  # so it should be enough just to <tt>require 'rack'</tt> in your code.
13
13
 
14
14
  module Rack
15
- RELEASE = "3.1.20"
15
+ RELEASE = "3.1.21"
16
16
 
17
17
  # Return the Rack release as a dotted string.
18
18
  def self.release
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: rack
3
3
  version: !ruby/object:Gem::Version
4
- version: 3.1.20
4
+ version: 3.1.21
5
5
  platform: ruby
6
6
  authors:
7
7
  - Leah Neukirchen
@@ -156,7 +156,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
156
156
  - !ruby/object:Gem::Version
157
157
  version: '0'
158
158
  requirements: []
159
- rubygems_version: 4.0.3
159
+ rubygems_version: 4.0.6
160
160
  specification_version: 4
161
161
  summary: A modular Ruby webserver interface.
162
162
  test_files: []