rack 2.2.17 → 3.2.8

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.
Files changed (87) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +712 -74
  3. data/CONTRIBUTING.md +63 -55
  4. data/MIT-LICENSE +1 -1
  5. data/README.md +384 -0
  6. data/SPEC.rdoc +243 -277
  7. data/lib/rack/auth/abstract/handler.rb +3 -1
  8. data/lib/rack/auth/abstract/request.rb +5 -1
  9. data/lib/rack/auth/basic.rb +1 -3
  10. data/lib/rack/bad_request.rb +8 -0
  11. data/lib/rack/body_proxy.rb +21 -3
  12. data/lib/rack/builder.rb +108 -69
  13. data/lib/rack/cascade.rb +2 -3
  14. data/lib/rack/common_logger.rb +22 -17
  15. data/lib/rack/conditional_get.rb +20 -16
  16. data/lib/rack/constants.rb +68 -0
  17. data/lib/rack/content_length.rb +12 -16
  18. data/lib/rack/content_type.rb +8 -5
  19. data/lib/rack/deflater.rb +40 -26
  20. data/lib/rack/directory.rb +15 -6
  21. data/lib/rack/etag.rb +17 -23
  22. data/lib/rack/events.rb +25 -6
  23. data/lib/rack/files.rb +16 -18
  24. data/lib/rack/head.rb +8 -8
  25. data/lib/rack/headers.rb +238 -0
  26. data/lib/rack/lint.rb +817 -648
  27. data/lib/rack/lock.rb +2 -5
  28. data/lib/rack/media_type.rb +6 -7
  29. data/lib/rack/method_override.rb +5 -1
  30. data/lib/rack/mime.rb +14 -5
  31. data/lib/rack/mock.rb +1 -300
  32. data/lib/rack/mock_request.rb +161 -0
  33. data/lib/rack/mock_response.rb +156 -0
  34. data/lib/rack/multipart/generator.rb +7 -5
  35. data/lib/rack/multipart/parser.rb +337 -95
  36. data/lib/rack/multipart/uploaded_file.rb +46 -4
  37. data/lib/rack/multipart.rb +53 -40
  38. data/lib/rack/null_logger.rb +9 -0
  39. data/lib/rack/query_parser.rb +118 -121
  40. data/lib/rack/recursive.rb +2 -0
  41. data/lib/rack/reloader.rb +0 -2
  42. data/lib/rack/request.rb +277 -143
  43. data/lib/rack/response.rb +151 -66
  44. data/lib/rack/rewindable_input.rb +27 -5
  45. data/lib/rack/runtime.rb +7 -6
  46. data/lib/rack/sendfile.rb +69 -34
  47. data/lib/rack/show_exceptions.rb +25 -6
  48. data/lib/rack/show_status.rb +17 -9
  49. data/lib/rack/static.rb +15 -11
  50. data/lib/rack/tempfile_reaper.rb +15 -4
  51. data/lib/rack/urlmap.rb +3 -1
  52. data/lib/rack/utils.rb +326 -244
  53. data/lib/rack/version.rb +3 -15
  54. data/lib/rack.rb +13 -90
  55. metadata +15 -41
  56. data/README.rdoc +0 -347
  57. data/Rakefile +0 -130
  58. data/bin/rackup +0 -5
  59. data/contrib/rack.png +0 -0
  60. data/contrib/rack.svg +0 -150
  61. data/contrib/rack_logo.svg +0 -164
  62. data/contrib/rdoc.css +0 -412
  63. data/example/lobster.ru +0 -6
  64. data/example/protectedlobster.rb +0 -16
  65. data/example/protectedlobster.ru +0 -10
  66. data/lib/rack/auth/digest/md5.rb +0 -131
  67. data/lib/rack/auth/digest/nonce.rb +0 -53
  68. data/lib/rack/auth/digest/params.rb +0 -54
  69. data/lib/rack/auth/digest/request.rb +0 -43
  70. data/lib/rack/chunked.rb +0 -117
  71. data/lib/rack/core_ext/regexp.rb +0 -14
  72. data/lib/rack/file.rb +0 -7
  73. data/lib/rack/handler/cgi.rb +0 -59
  74. data/lib/rack/handler/fastcgi.rb +0 -100
  75. data/lib/rack/handler/lsws.rb +0 -61
  76. data/lib/rack/handler/scgi.rb +0 -71
  77. data/lib/rack/handler/thin.rb +0 -36
  78. data/lib/rack/handler/webrick.rb +0 -129
  79. data/lib/rack/handler.rb +0 -104
  80. data/lib/rack/lobster.rb +0 -70
  81. data/lib/rack/logger.rb +0 -20
  82. data/lib/rack/server.rb +0 -466
  83. data/lib/rack/session/abstract/id.rb +0 -523
  84. data/lib/rack/session/cookie.rb +0 -203
  85. data/lib/rack/session/memcache.rb +0 -10
  86. data/lib/rack/session/pool.rb +0 -90
  87. data/rack.gemspec +0 -46
@@ -2,14 +2,55 @@
2
2
 
3
3
  require 'strscan'
4
4
 
5
+ require_relative '../utils'
6
+ require_relative '../bad_request'
7
+
5
8
  module Rack
6
9
  module Multipart
7
- class MultipartPartLimitError < Errno::EMFILE; end
8
- class MultipartTotalPartLimitError < StandardError; end
10
+ class MultipartPartLimitError < Errno::EMFILE
11
+ include BadRequest
12
+ end
9
13
 
10
- class Parser
11
- (require_relative '../core_ext/regexp'; using ::Rack::RegexpExtensions) if RUBY_VERSION < '2.4'
14
+ class MultipartTotalPartLimitError < StandardError
15
+ include BadRequest
16
+ end
17
+
18
+ # Use specific error class when parsing multipart request
19
+ # that ends early.
20
+ class EmptyContentError < ::EOFError
21
+ include BadRequest
22
+ end
12
23
 
24
+ # Base class for multipart exceptions that do not subclass from
25
+ # other exception classes for backwards compatibility.
26
+ class BoundaryTooLongError < StandardError
27
+ include BadRequest
28
+ end
29
+
30
+ # Prefer to use the BoundaryTooLongError class or Rack::BadRequest.
31
+ Error = BoundaryTooLongError
32
+
33
+ EOL = "\r\n"
34
+ FWS = /[ \t]+(?:\r\n[ \t]+)?/ # whitespace with optional folding
35
+ HEADER_VALUE = "(?:[^\r\n]|\r\n[ \t])*" # anything but a non-folding CRLF
36
+ MULTIPART = %r|\Amultipart/.*?boundary(\s*)=\"?([^\";,]+)\"?|ni
37
+ MULTIPART_CONTENT_TYPE = /^Content-Type:#{FWS}?(#{HEADER_VALUE})/ni
38
+ MULTIPART_CONTENT_DISPOSITION = /^Content-Disposition:#{FWS}?(#{HEADER_VALUE})/ni
39
+ MULTIPART_CONTENT_ID = /^Content-ID:#{FWS}?(#{HEADER_VALUE})/ni
40
+
41
+ # Rack::Multipart::Parser handles parsing of multipart/form-data requests.
42
+ #
43
+ # File Parameter Contents
44
+ #
45
+ # When processing file uploads, the parser returns a hash containing
46
+ # information about uploaded files. For +file+ parameters, the hash includes:
47
+ #
48
+ # * +:filename+ - The original filename, already URL decoded by the parser
49
+ # * +:type+ - The content type of the uploaded file
50
+ # * +:name+ - The parameter name from the form
51
+ # * +:tempfile+ - A Tempfile object containing the uploaded data
52
+ # * +:head+ - The raw header content for this part
53
+ class Parser
13
54
  BUFSIZE = 1_048_576
14
55
  TEXT_PLAIN = "text/plain"
15
56
  TEMPFILE_FACTORY = lambda { |filename, content_type|
@@ -18,7 +59,33 @@ module Rack
18
59
  Tempfile.new(["RackMultipart", extension])
19
60
  }
20
61
 
21
- BOUNDARY_REGEX = /\A([^\n]*(?:\n|\Z))/
62
+ BOUNDARY_START_LIMIT = 16 * 1024
63
+ private_constant :BOUNDARY_START_LIMIT
64
+
65
+ MIME_HEADER_BYTESIZE_LIMIT = 64 * 1024
66
+ private_constant :MIME_HEADER_BYTESIZE_LIMIT
67
+
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
+
83
+ bytesize_limit = env_int.call("RACK_MULTIPART_PARSER_BYTESIZE_LIMIT", 10 * 1024 * 1024 * 1024)
84
+ PARSER_BYTESIZE_LIMIT = bytesize_limit > 0 ? bytesize_limit : nil
85
+ private_constant :PARSER_BYTESIZE_LIMIT
86
+
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
22
89
 
23
90
  class BoundedIO # :nodoc:
24
91
  def initialize(io, content_length)
@@ -41,16 +108,12 @@ module Rack
41
108
  if str
42
109
  @cursor += str.bytesize
43
110
  else
44
- # Raise an error for mismatching Content-Length and actual contents
111
+ # Raise an error for mismatching content-length and actual contents
45
112
  raise EOFError, "bad content body"
46
113
  end
47
114
 
48
115
  str
49
116
  end
50
-
51
- def rewind
52
- @io.rewind
53
- end
54
117
  end
55
118
 
56
119
  MultipartInfo = Struct.new :params, :tmp_files
@@ -60,7 +123,19 @@ module Rack
60
123
  return unless content_type
61
124
  data = content_type.match(MULTIPART)
62
125
  return unless data
63
- data[1]
126
+
127
+ unless data[1].empty?
128
+ raise Error, "whitespace between boundary parameter name and equal sign"
129
+ end
130
+ # Count across the whole header rather than only after the match: the
131
+ # lazy prefix in MULTIPART can skip a first boundary parameter whose
132
+ # value is empty or starts with a quote, semicolon, or comma. Also
133
+ # count RFC 2231 boundary* parameters, which some parsers prefer.
134
+ if content_type.scan(/boundary(?:\*\d*\*?)?\s*=/i).size > 1
135
+ raise BoundaryTooLongError, "multiple boundary parameters found in multipart content type"
136
+ end
137
+
138
+ data[2]
64
139
  end
65
140
 
66
141
  def self.parse(io, content_length, content_type, tmpfile, bufsize, qp)
@@ -69,18 +144,21 @@ module Rack
69
144
  boundary = parse_boundary content_type
70
145
  return EMPTY unless boundary
71
146
 
147
+ if PARSER_BYTESIZE_LIMIT && content_length && content_length > PARSER_BYTESIZE_LIMIT
148
+ raise Error, "multipart Content-Length #{content_length} exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
149
+ end
150
+
151
+ if boundary.length > 70
152
+ # RFC 1521 Section 7.2.1 imposes a 70 character maximum for the boundary.
153
+ # Most clients use no more than 55 characters.
154
+ raise BoundaryTooLongError, "multipart boundary size too large (#{boundary.length} characters)"
155
+ end
156
+
72
157
  io = BoundedIO.new(io, content_length) if content_length
73
- outbuf = String.new
74
158
 
75
159
  parser = new(boundary, tmpfile, bufsize, qp)
76
- parser.on_read io.read(bufsize, outbuf)
77
-
78
- loop do
79
- break if parser.state == :DONE
80
- parser.on_read io.read(bufsize, outbuf)
81
- end
160
+ parser.parse(io)
82
161
 
83
- io.rewind
84
162
  parser.result
85
163
  end
86
164
 
@@ -180,33 +258,54 @@ module Rack
180
258
  def initialize(boundary, tempfile, bufsize, query_parser)
181
259
  @query_parser = query_parser
182
260
  @params = query_parser.make_params
183
- @boundary = "--#{boundary}"
184
261
  @bufsize = bufsize
185
262
 
186
- @full_boundary = @boundary
187
- @end_boundary = @boundary + '--'
188
263
  @state = :FAST_FORWARD
189
264
  @mime_index = 0
265
+ @body_retained = nil
266
+ @retained_size = 0
267
+ @total_bytes_read = (0 if PARSER_BYTESIZE_LIMIT)
268
+ @content_disposition_quoted_escapes = 0
190
269
  @collector = Collector.new tempfile
191
270
 
192
271
  @sbuf = StringScanner.new("".dup)
193
- @body_regex = /(?:#{EOL})?#{Regexp.quote(@boundary)}(?:#{EOL}|--)/m
194
- @end_boundary_size = boundary.bytesize + 6 # (-- at start, -- at finish, EOL at end)
195
- @rx_max_size = EOL.size + @boundary.bytesize + [EOL.size, '--'.size].max
272
+ @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m
273
+ @body_regex_at_end = /#{@body_regex}\z/m
274
+ @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish)
275
+ @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish)
196
276
  @head_regex = /(.*?#{EOL})#{EOL}/m
197
277
  end
198
278
 
199
- def on_read(content)
200
- handle_empty_content!(content)
201
- @sbuf.concat content
202
- run_parser
279
+ def parse(io)
280
+ @total_bytes_read &&= nil if io.is_a?(BoundedIO)
281
+ outbuf = String.new
282
+ read_data(io, outbuf)
283
+
284
+ loop do
285
+ status =
286
+ case @state
287
+ when :FAST_FORWARD
288
+ handle_fast_forward
289
+ when :CONSUME_TOKEN
290
+ handle_consume_token
291
+ when :MIME_HEAD
292
+ handle_mime_head
293
+ when :MIME_BODY
294
+ handle_mime_body
295
+ else # when :DONE
296
+ return
297
+ end
298
+
299
+ read_data(io, outbuf) if status == :want_read
300
+ end
203
301
  end
204
302
 
205
303
  def result
206
304
  @collector.each do |part|
207
305
  part.get_data do |data|
208
306
  tag_multipart_encoding(part.filename, part.content_type, part.name, data)
209
- @query_parser.normalize_params(@params, part.name, data, @query_parser.param_depth_limit)
307
+ name, data = handle_dummy_encoding(part.name, data)
308
+ @query_parser.normalize_params(@params, name, data)
210
309
  end
211
310
  end
212
311
  MultipartInfo.new @params.to_params_hash, @collector.find_all(&:file?).map(&:body)
@@ -214,34 +313,49 @@ module Rack
214
313
 
215
314
  private
216
315
 
217
- def run_parser
218
- loop do
219
- case @state
220
- when :FAST_FORWARD
221
- break if handle_fast_forward == :want_read
222
- when :CONSUME_TOKEN
223
- break if handle_consume_token == :want_read
224
- when :MIME_HEAD
225
- break if handle_mime_head == :want_read
226
- when :MIME_BODY
227
- break if handle_mime_body == :want_read
228
- when :DONE
229
- break
316
+ def read_data(io, outbuf)
317
+ content = io.read(@bufsize, outbuf)
318
+ handle_empty_content!(content)
319
+ if @total_bytes_read
320
+ @total_bytes_read += content.bytesize
321
+ if @total_bytes_read > PARSER_BYTESIZE_LIMIT
322
+ raise Error, "multipart upload exceeds limit of #{PARSER_BYTESIZE_LIMIT} bytes"
230
323
  end
231
324
  end
325
+ @sbuf.concat(content)
232
326
  end
233
327
 
328
+ # This handles the initial parser state. We read until we find the starting
329
+ # boundary, then we can transition to the next state. If we find the ending
330
+ # boundary, this is an invalid multipart upload, but keep scanning for opening
331
+ # boundary in that case. If no boundary found, we need to keep reading data
332
+ # and retry. It's highly unlikely the initial read will not consume the
333
+ # boundary. The client would have to deliberately craft a response
334
+ # with the opening boundary beyond the buffer size for that to happen.
234
335
  def handle_fast_forward
235
- tok = consume_boundary
336
+ while true
337
+ case consume_boundary
338
+ when :BOUNDARY
339
+ # found opening boundary, transition to next state
340
+ @state = :MIME_HEAD
341
+ return
342
+ when :END_BOUNDARY
343
+ # invalid multipart upload
344
+ if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL
345
+ # stop parsing a buffer if a buffer is only an end boundary.
346
+ @state = :DONE
347
+ return
348
+ end
236
349
 
237
- if tok == :END_BOUNDARY && @sbuf.pos == @end_boundary_size && @sbuf.eos?
238
- # stop parsing a buffer if a buffer is only an end boundary.
239
- @state = :DONE
240
- elsif tok
241
- @state = :MIME_HEAD
242
- else
243
- raise EOFError, "bad content body" if @sbuf.rest_size >= @bufsize
244
- :want_read
350
+ # retry for opening boundary
351
+ else
352
+ # We raise if we don't find the multipart boundary, to avoid unbounded memory
353
+ # buffering. Note that the actual limit is the higher of 16KB and the buffer size (1MB by default)
354
+ raise Error, "multipart boundary not found within limit" if @sbuf.string.bytesize > BOUNDARY_START_LIMIT
355
+
356
+ # no boundary found, keep reading data
357
+ return :want_read
358
+ end
245
359
  end
246
360
  end
247
361
 
@@ -255,32 +369,144 @@ module Rack
255
369
  end
256
370
  end
257
371
 
372
+ CONTENT_DISPOSITION_MAX_PARAMS = 16
373
+ CONTENT_DISPOSITION_MAX_BYTES = 1536
374
+ OBS_UNFOLD = /\r\n([ \t])/
375
+ private_constant :OBS_UNFOLD
376
+
258
377
  def handle_mime_head
259
378
  if @sbuf.scan_until(@head_regex)
260
379
  head = @sbuf[1]
261
380
  content_type = head[MULTIPART_CONTENT_TYPE, 1]
262
- if name = head[MULTIPART_CONTENT_DISPOSITION, 1]
263
- name = Rack::Auth::Digest::Params::dequote(name)
381
+ content_type.gsub!(OBS_UNFOLD, '\1') if content_type
382
+
383
+ if (disposition = head[MULTIPART_CONTENT_DISPOSITION, 1]) &&
384
+ disposition.bytesize <= CONTENT_DISPOSITION_MAX_BYTES
385
+
386
+ # Implement OBS unfolding (RFC 5322 Section 2.2.3)
387
+ disposition.gsub!(OBS_UNFOLD, '\1')
388
+
389
+ # ignore actual content-disposition value (should always be form-data)
390
+ i = disposition.index(';')
391
+ disposition.slice!(0, i+1)
392
+ param = nil
393
+ num_params = 0
394
+
395
+ # Parse parameter list
396
+ while i = disposition.index('=')
397
+ # Only parse up to max parameters, to avoid potential denial of service
398
+ num_params += 1
399
+ break if num_params > CONTENT_DISPOSITION_MAX_PARAMS
400
+
401
+ # Found end of parameter name, ensure forward progress in loop
402
+ param = disposition.slice!(0, i+1)
403
+
404
+ # Remove ending equals and preceding whitespace from parameter name
405
+ param.chomp!('=')
406
+ param.lstrip!
407
+
408
+ if disposition[0] == '"'
409
+ # Parameter value is quoted, parse it, handling backslash escapes
410
+ disposition.slice!(0, 1)
411
+ value = String.new
412
+
413
+ while i = disposition.index(/(["\\])/)
414
+ c = $1
415
+
416
+ # Append all content until ending quote or escape
417
+ value << disposition.slice!(0, i)
418
+
419
+ # Remove either backslash or ending quote,
420
+ # ensures forward progress in loop
421
+ disposition.slice!(0, 1)
422
+
423
+ # stop parsing parameter value if found ending quote
424
+ break if c == '"'
425
+
426
+ @content_disposition_quoted_escapes += 1
427
+ if @content_disposition_quoted_escapes > CONTENT_DISPOSITION_QUOTED_ESCAPES_LIMIT
428
+ raise Error, "number of quoted escapes during content disposition parsing exceeds limit"
429
+ end
430
+
431
+ escaped_char = disposition.slice!(0, 1)
432
+ if param == 'filename' && escaped_char != '"'
433
+ # Possible IE uploaded filename, append both escape backslash and value
434
+ value << c << escaped_char
435
+ else
436
+ # Other only append escaped value
437
+ value << escaped_char
438
+ end
439
+ end
440
+ else
441
+ if i = disposition.index(';')
442
+ # Parameter value unquoted (which may be invalid), value ends at semicolon
443
+ value = disposition.slice!(0, i)
444
+ else
445
+ # If no ending semicolon, assume remainder of line is value and stop
446
+ # parsing
447
+ disposition.strip!
448
+ value = disposition
449
+ disposition = ''
450
+ end
451
+ end
452
+
453
+ case param
454
+ when 'name'
455
+ name = value
456
+ when 'filename'
457
+ filename = value
458
+ when 'filename*'
459
+ filename_star = value
460
+ # else
461
+ # ignore other parameters
462
+ end
463
+
464
+ # skip trailing semicolon, to proceed to next parameter
465
+ if i = disposition.index(';')
466
+ disposition.slice!(0, i+1)
467
+ end
468
+ end
264
469
  else
265
470
  name = head[MULTIPART_CONTENT_ID, 1]
266
471
  end
267
472
 
268
- filename = get_filename(head)
473
+ if filename_star
474
+ encoding, _, filename = filename_star.split("'", 3)
475
+ filename = normalize_filename(filename || '')
476
+ filename.force_encoding(find_encoding(encoding))
477
+ elsif filename
478
+ filename = normalize_filename(filename)
479
+ end
269
480
 
270
481
  if name.nil? || name.empty?
271
482
  name = filename || "#{content_type || TEXT_PLAIN}[]".dup
272
483
  end
273
484
 
485
+ # Mime part head data is retained for both TempfilePart and BufferPart
486
+ # for the entireity of the parse, even though it isn't used for BufferPart.
487
+ update_retained_size(head.bytesize)
488
+
489
+ # If a filename is given, a TempfilePart will be used, so the body will
490
+ # not be buffered in memory. However, if a filename is not given, a BufferPart
491
+ # will be used, and the body will be buffered in memory.
492
+ @body_retained = !filename
493
+
274
494
  @collector.on_mime_head @mime_index, head, filename, content_type, name
275
495
  @state = :MIME_BODY
276
496
  else
277
- :want_read
497
+ # We raise if the mime part header is too large, to avoid unbounded memory
498
+ # buffering. Note that the actual limit is the higher of 64KB and the buffer size (1MB by default)
499
+ raise Error, "multipart mime part header too large" if @sbuf.rest.bytesize > MIME_HEADER_BYTESIZE_LIMIT
500
+
501
+ @sbuf.string = @sbuf.rest
502
+ return :want_read
278
503
  end
279
504
  end
280
505
 
281
506
  def handle_mime_body
282
507
  if (body_with_boundary = @sbuf.check_until(@body_regex)) # check but do not advance the pointer yet
283
- body = body_with_boundary.sub(/#{@body_regex}\z/m, '') # remove the boundary from the string
508
+ body = body_with_boundary.sub(@body_regex_at_end, '') # remove the boundary from the string
509
+ update_retained_size(body.bytesize) if @body_retained
284
510
  @collector.on_mime_body @mime_index, body
285
511
  @sbuf.pos += body.length + 2 # skip \r\n after the content
286
512
  @state = :CONSUME_TOKEN
@@ -289,62 +515,48 @@ module Rack
289
515
  # Save what we have so far
290
516
  if @rx_max_size < @sbuf.rest_size
291
517
  delta = @sbuf.rest_size - @rx_max_size
292
- @collector.on_mime_body @mime_index, @sbuf.peek(delta)
518
+ body = @sbuf.peek(delta)
519
+ update_retained_size(body.bytesize) if @body_retained
520
+ @collector.on_mime_body @mime_index, body
293
521
  @sbuf.pos += delta
294
- @sbuf.string = @sbuf.rest
295
522
  end
523
+ @sbuf.string = @sbuf.rest
296
524
  :want_read
297
525
  end
298
526
  end
299
527
 
300
- def full_boundary; @full_boundary; end
301
-
302
- def consume_boundary
303
- while read_buffer = @sbuf.scan_until(BOUNDARY_REGEX)
304
- case read_buffer.strip
305
- when full_boundary then return :BOUNDARY
306
- when @end_boundary then return :END_BOUNDARY
307
- end
308
- return if @sbuf.eos?
528
+ def update_retained_size(size)
529
+ @retained_size += size
530
+ if @retained_size > BUFFERED_UPLOAD_BYTESIZE_LIMIT
531
+ raise Error, "multipart data over retained size limit"
309
532
  end
310
533
  end
311
534
 
312
- def get_filename(head)
313
- filename = nil
314
- case head
315
- when RFC2183
316
- params = Hash[*head.scan(DISPPARM).flat_map(&:compact)]
317
-
318
- if filename = params['filename']
319
- filename = $1 if filename =~ /^"(.*)"$/
320
- elsif filename = params['filename*']
321
- encoding, _, filename = filename.split("'", 3)
322
- end
323
- when BROKEN
324
- filename = $1
325
- filename = $1 if filename =~ /^"(.*)"$/
535
+ # Scan until the we find the start or end of the boundary.
536
+ # If we find it, return the appropriate symbol for the start or
537
+ # end of the boundary. If we don't find the start or end of the
538
+ # boundary, clear the buffer and return nil.
539
+ def consume_boundary
540
+ if read_buffer = @sbuf.scan_until(@body_regex)
541
+ read_buffer.end_with?(EOL) ? :BOUNDARY : :END_BOUNDARY
542
+ else
543
+ @sbuf.terminate
544
+ nil
326
545
  end
546
+ end
327
547
 
328
- return unless filename
329
-
548
+ def normalize_filename(filename)
330
549
  if filename.scan(/%.?.?/).all? { |s| /%[0-9a-fA-F]{2}/.match?(s) }
331
550
  filename = Utils.unescape_path(filename)
332
551
  end
333
552
 
334
553
  filename.scrub!
335
554
 
336
- if filename !~ /\\[^\\"]/
337
- filename = filename.gsub(/\\(.)/, '\1')
338
- end
339
-
340
- if encoding
341
- filename.force_encoding ::Encoding.find(encoding)
342
- end
343
-
344
- filename
555
+ filename.split(/[\/\\]/).last || String.new
345
556
  end
346
557
 
347
558
  CHARSET = "charset"
559
+ deprecate_constant :CHARSET
348
560
 
349
561
  def tag_multipart_encoding(filename, content_type, name, body)
350
562
  name = name.to_s
@@ -365,7 +577,9 @@ module Rack
365
577
  k.strip!
366
578
  v.strip!
367
579
  v = v[1..-2] if v.start_with?('"') && v.end_with?('"')
368
- encoding = Encoding.find v if k == CHARSET
580
+ if k == "charset"
581
+ encoding = find_encoding(v)
582
+ end
369
583
  end
370
584
  end
371
585
  end
@@ -374,9 +588,37 @@ module Rack
374
588
  body.force_encoding(encoding)
375
589
  end
376
590
 
591
+ # Return the related Encoding object. However, because
592
+ # enc is submitted by the user, it may be invalid, so
593
+ # use a binary encoding in that case.
594
+ def find_encoding(enc)
595
+ Encoding.find enc
596
+ rescue ArgumentError
597
+ Encoding::BINARY
598
+ end
599
+
600
+ REENCODE_DUMMY_ENCODINGS = {
601
+ # ISO-2022-JP is a legacy but still widely used encoding in Japan
602
+ # Here we convert ISO-2022-JP to UTF-8 so that it can be handled.
603
+ Encoding::ISO_2022_JP => true
604
+
605
+ # Other dummy encodings are rarely used and have not been supported yet.
606
+ # Adding support for them will require careful considerations.
607
+ }
608
+
609
+ def handle_dummy_encoding(name, body)
610
+ # A string object with a 'dummy' encoding does not have full functionality and can cause errors.
611
+ # So here we covert it to UTF-8 so that it can be handled properly.
612
+ if name.encoding.dummy? && REENCODE_DUMMY_ENCODINGS[name.encoding]
613
+ name = name.encode(Encoding::UTF_8)
614
+ body = body.encode(Encoding::UTF_8)
615
+ end
616
+ return name, body
617
+ end
618
+
377
619
  def handle_empty_content!(content)
378
620
  if content.nil? || content.empty?
379
- raise EOFError
621
+ raise EmptyContentError
380
622
  end
381
623
  end
382
624
  end
@@ -1,14 +1,51 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require 'tempfile'
4
+ require 'fileutils'
5
+
3
6
  module Rack
4
7
  module Multipart
8
+ # Despite the misleading name, UploadedFile is designed for use for
9
+ # preparing multipart file upload bodies, generally for use in tests.
10
+ # It is not designed for and should not be used for handling uploaded
11
+ # files (there is no need for that, since Rack's multipart parser
12
+ # already creates Tempfiles for that). Using this with non-trusted
13
+ # filenames can create a security vulnerability.
14
+ #
15
+ # You should only use this class if you plan on passing the instances
16
+ # to Rack::MockRequest for use in creating multipart request bodies.
17
+ #
18
+ # UploadedFile delegates most methods to the tempfile it contains.
5
19
  class UploadedFile
6
- # The filename, *not* including the path, of the "uploaded" file
20
+ # The provided name of the file. This generally is the basename of
21
+ # path provided during initialization, but it can contain slashes if they
22
+ # were present in the filename argument when the instance was created.
7
23
  attr_reader :original_filename
8
24
 
9
- # The content type of the "uploaded" file
25
+ # The content type of the instance.
10
26
  attr_accessor :content_type
11
27
 
28
+ # Create a new UploadedFile. For backwards compatibility, this accepts
29
+ # both positional and keyword versions of the same arguments:
30
+ #
31
+ # filepath/path :: The path to the file
32
+ # ct/content_type :: The content_type of the file
33
+ # bin/binary :: Whether to set binmode on the file before copying data into it.
34
+ #
35
+ # If both positional and keyword arguments are present, the keyword arguments
36
+ # take precedence.
37
+ #
38
+ # The following keyword-only arguments are also accepted:
39
+ #
40
+ # filename :: Override the filename to use for the file. This is so the
41
+ # filename for the upload does not need to match the basename of
42
+ # the file path. This should not contain slashes, unless you are
43
+ # trying to test how an application handles invalid filenames in
44
+ # multipart upload bodies.
45
+ # io :: Use the given IO-like instance as the tempfile, instead of creating
46
+ # a Tempfile instance. This is useful for building multipart file
47
+ # upload bodies without a file being present on the filesystem. If you are
48
+ # providing this, you should also provide the filename argument.
12
49
  def initialize(filepath = nil, ct = "text/plain", bin = false,
13
50
  path: filepath, content_type: ct, binary: bin, filename: nil, io: nil)
14
51
  if io
@@ -24,18 +61,23 @@ module Rack
24
61
  @content_type = content_type
25
62
  end
26
63
 
64
+ # The path of the tempfile for the instance, if the tempfile has a path.
65
+ # nil if the tempfile does not have a path.
27
66
  def path
28
67
  @tempfile.path if @tempfile.respond_to?(:path)
29
68
  end
30
69
  alias_method :local_path, :path
31
70
 
32
- def respond_to?(*args)
33
- super or @tempfile.respond_to?(*args)
71
+ # Return true if the tempfile responds to the method.
72
+ def respond_to_missing?(*args)
73
+ @tempfile.respond_to?(*args)
34
74
  end
35
75
 
76
+ # Delegate method missing calls to the tempfile.
36
77
  def method_missing(method_name, *args, &block) #:nodoc:
37
78
  @tempfile.__send__(method_name, *args, &block)
38
79
  end
80
+ ruby2_keywords(:method_missing) if respond_to?(:ruby2_keywords, true)
39
81
  end
40
82
  end
41
83
  end