diff --git a/.github/workflows/test-external.yaml b/.github/workflows/test-external.yaml index 59c25ef4f..bcf7a6c72 100644 --- a/.github/workflows/test-external.yaml +++ b/.github/workflows/test-external.yaml @@ -11,24 +11,18 @@ jobs: fail-fast: false matrix: os: [ubuntu-latest] - ruby: ['2.7', '3.0', '3.1'] + ruby: ['3.0', '3.1', '3.2', '3.3'] runs-on: ${{matrix.os}} steps: - uses: actions/checkout@v3 - - uses: ruby/setup-ruby@v1 + - uses: ruby/setup-ruby-pkgs@v1 with: ruby-version: ${{matrix.ruby}} bundler-cache: true - - - name: Installing packages (ubuntu) - if: matrix.os == 'ubuntu-latest' - run: sudo apt-get install libfcgi-dev libmemcached-dev - - - name: Installing packages (macos) - if: matrix.os == 'macos-latest' - run: brew install fcgi libmemcached + apt-get: _update_ libfcgi-dev libmemcached-dev + brew: fcgi libmemcached - run: bundle exec bake test:external diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index a224f6771..697b3cd27 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -10,15 +10,16 @@ jobs: strategy: fail-fast: false matrix: - os: + os: - ubuntu-latest - ruby: + ruby: - '2.4' - '2.5' - '2.6' - '2.7' - '3.0' - '3.1' + - '3.2' - jruby - truffleruby-head include: diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c9a133fd..d3d7fa370 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,72 @@ 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/). +## Unreleased + +## [3.0.10] - 2024-03-21 + +- Backport #2104 to 3-0-stable: Return empty when parsing a multi-part POST with only one end delimiter. ([#2164](https://github.com/rack/rack/pull/2164), [@JoeDupuis](https://github.com/JoeDupuis)) + +## [3.0.9] - 2024-01-31 + +- Fix incorrect content-length header that was emitted when `Rack::Response#write` was used in some situations. ([#2150](https://github.com/rack/rack/pull/2150), [@mattbrictson](https://github.com/mattbrictson)) + +## [3.0.8] - 2023-06-14 + +- Fix some unused variable verbose warnings. ([#2084](https://github.com/rack/rack/pull/2084), [@jeremyevans], [@skipkayhil](https://github.com/skipkayhil)) + +## [3.0.7] - 2023-03-16 + +- Make query parameters without `=` have `nil` values. ([#2059](https://github.com/rack/rack/pull/2059), [@jeremyevans]) + +## [3.0.6.1] - 2023-03-13 + +- [CVE-2023-27539] Avoid ReDoS in header parsing + +## [3.0.6] - 2023-03-13 + +- Add `QueryParser#missing_value` for handling missing values + tests. ([#2052](https://github.com/rack/rack/pull/2052), [@ioquatix]) + +## [3.0.5] - 2023-03-13 + +- Split form/query parsing into two steps. ([#2038](https://github.com/rack/rack/pull/2038), [@matthewd](https://github.com/matthewd)) + +## [3.0.4.1] - 2023-03-02 + +- [CVE-2023-27530] Introduce multipart_total_part_limit to limit total parts + +## [3.0.4.1] - 2023-01-17 + +- [CVE-2022-44571] Fix ReDoS vulnerability in multipart parser +- [CVE-2022-44570] Fix ReDoS in Rack::Utils.get_byte_ranges +- [CVE-2022-44572] Forbid control characters in attributes (also ReDoS) + +## [3.0.4] - 2023-01-17 + +- `Rack::Request#POST` should consistently raise errors. Cache errors that occur when invoking `Rack::Request#POST` so they can be raised again later. ([#2010](https://github.com/rack/rack/pull/2010), [@ioquatix]) +- Fix `Rack::Lint` error message for `HTTP_CONTENT_TYPE` and `HTTP_CONTENT_LENGTH`. ([#2007](https://github.com/rack/rack/pull/2007), [@byroot](https://github.com/byroot)) +- Extend `Rack::MethodOverride` to handle `QueryParser::ParamsTooDeepError` error. ([#2006](https://github.com/rack/rack/pull/2006), [@byroot](https://github.com/byroot)) + +## [3.0.3] - 2022-12-27 + +### Fixed + +- `Rack::URLMap` uses non-deprecated form of `Regexp.new`. ([#1998](https://github.com/rack/rack/pull/1998), [@weizheheng](https://github.com/weizheheng)) + +## [3.0.2] - 2022-12-05 + +### Fixed + +- `Utils.build_nested_query` URL-encodes nested field names including the square brackets. +- Allow `Rack::Response` to pass through streaming bodies. ([#1993](https://github.com/rack/rack/pull/1993), [@ioquatix]) + +## [3.0.1] - 2022-11-18 + +### Fixed + +- `MethodOverride` does not look for an override if a request does not include form/parseable data. +- `Rack::Lint::Wrapper` correctly handles `respond_to?` with `to_ary`, `each`, `call` and `to_path`, forwarding to the body. ([#1981](https://github.com/rack/rack/pull/1981), [@ioquatix]) + ## [3.0.0] - 2022-09-06 - No changes @@ -34,7 +100,7 @@ All notable changes to this project will be documented in this file. For info on - `SERVER_PROTOCOL` is now a required environment key, matching the HTTP protocol used in the request. - `rack.hijack?` (partial hijack) and `rack.hijack` (full hijack) are now independently optional. - `rack.hijack_io` has been removed completely. -- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsucessfully). +- `rack.response_finished` is an optional environment key which contains an array of callable objects that must accept `#call(env, status, headers, error)` and are invoked after the response is finished (either successfully or unsuccessfully). - It is okay to call `#close` on `rack.input` to indicate that you no longer need or care about the input. - The stream argument supplied to the streaming body and hijack must support `#<<` for writing output. diff --git a/README.md b/README.md index 3f060ca30..2d26ab44f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # ![Rack](contrib/logo.webp) -> **_NOTE:_** Rack v3.0.0.beta1 was recently released. Please check the [Upgrade +> **_NOTE:_** Rack v3.0.0 was recently released. Please check the [Upgrade > Guide](UPGRADE-GUIDE.md) for more details about migrating your existing > servers, middlewares and applications. For detailed information on specific > changes, check the [Change Log](CHANGELOG.md). @@ -23,7 +23,7 @@ by a [supported web framework](#supported-web-frameworks): $ gem install rack --pre # or, add it to your current application gemfile: -$ bundle add rack --version 3.0.0.beta1 +$ bundle add rack --version 3.0.0 ``` If you need features from `Rack::Session` or `bin/rackup` please add those gems separately. @@ -186,19 +186,35 @@ but this query string would not be allowed: Limiting the depth prevents a possible stack overflow when parsing parameters. -### `multipart_part_limit` +### `multipart_file_limit` ```ruby -Rack::Utils.multipart_part_limit = 128 # default +Rack::Utils.multipart_file_limit = 128 # default ``` -The maximum number of parts a request can contain. Accepting too many parts can -lead to the server running out of file handles. +The maximum number of parts with a filename a request can contain. Accepting +too many parts can lead to the server running out of file handles. The default is 128, which means that a single request can't upload more than 128 files at once. Set to 0 for no limit. -Can also be set via the `RACK_MULTIPART_PART_LIMIT` environment variable. +Can also be set via the `RACK_MULTIPART_FILE_LIMIT` environment variable. + +(This is also aliased as `multipart_part_limit` and `RACK_MULTIPART_PART_LIMIT` for compatibility) + + +### `multipart_total_part_limit` + +The maximum total number of parts a request can contain of any type, including +both file and non-file form fields. + +The default is 4096, which means that a single request can't contain more than +4096 parts. + +Set to 0 for no limit. + +Can also be set via the `RACK_MULTIPART_TOTAL_PART_LIMIT` environment variable. + ## Changelog diff --git a/UPGRADE-GUIDE.md b/UPGRADE-GUIDE.md index f845e5de3..290fac24d 100644 --- a/UPGRADE-GUIDE.md +++ b/UPGRADE-GUIDE.md @@ -150,7 +150,7 @@ dropped entirely. ### `rack.input` is no longer required to be rewindable -Previosuly, `rack.input` was required to be rewindable, i.e. `io.seek(0)` but +Previously, `rack.input` was required to be rewindable, i.e. `io.seek(0)` but this was only generally possible with a file based backing, which prevented efficient streaming of request bodies. Now, `rack.input` is not required to be rewindable. @@ -308,11 +308,11 @@ def call(env) headers[ETAG_STRING] = %(W/"#{digest}") if digest end - return [status, headers, body] + return [status, headers, body] end ``` -### Middleware should not directly modify the response body +### Middleware should not directly modify the response body Be aware that the response body might not respond to `#each` and you must now check if the body responds to `#each` or not to determine if it is an enumerable diff --git a/lib/rack.rb b/lib/rack.rb index 5b87ea1bc..b37c00cde 100644 --- a/lib/rack.rb +++ b/lib/rack.rb @@ -41,6 +41,7 @@ module Rack autoload :MethodOverride, "rack/method_override" autoload :Mime, "rack/mime" autoload :NullLogger, "rack/null_logger" + autoload :QueryParser, "rack/query_parser" autoload :Recursive, "rack/recursive" autoload :Reloader, "rack/reloader" autoload :RewindableInput, "rack/rewindable_input" diff --git a/lib/rack/auth/basic.rb b/lib/rack/auth/basic.rb index d5b4ea16d..019efde75 100644 --- a/lib/rack/auth/basic.rb +++ b/lib/rack/auth/basic.rb @@ -10,8 +10,6 @@ module Auth # # Initialize with the Rack application that you want protecting, # and a block that checks if a username and password pair are valid. - # - # See also: example/protectedlobster.rb class Basic < AbstractHandler diff --git a/lib/rack/builder.rb b/lib/rack/builder.rb index 6d40b534a..0b9c3d24a 100644 --- a/lib/rack/builder.rb +++ b/lib/rack/builder.rb @@ -10,26 +10,23 @@ module Rack # # Example: # - # require 'rack/lobster' - # app = Rack::Builder.new do - # use Rack::CommonLogger - # use Rack::ShowExceptions - # map "/lobster" do - # use Rack::Lint - # run Rack::Lobster.new - # end - # end + # app = Rack::Builder.new do + # use Rack::CommonLogger + # map "/ok" do + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end + # end # - # run app + # run app # # Or # - # app = Rack::Builder.app do - # use Rack::CommonLogger - # run lambda { |env| [200, {'Content-Type' => 'text/plain'}, ['OK']] } - # end + # app = Rack::Builder.app do + # use Rack::CommonLogger + # run lambda { |env| [200, {'content-type' => 'text/plain'}, ['OK']] } + # end # - # run app + # run app # # +use+ adds middleware to the stack, +run+ dispatches to an application. # You can use +map+ to construct a Rack::URLMap in a convenient way. @@ -180,15 +177,6 @@ def use(middleware, *args, &block) # # run Heartbeat.new # - # It could also be a module: - # - # module HelloWorld - # def call(env) - # [200, { "content-type" => "text/plain" }, ["Hello World"]] - # end - # end - # - # run HelloWorld def run(app = nil, &block) raise ArgumentError, "Both app and block given!" if app && block_given? @@ -213,21 +201,35 @@ def warmup(prc = nil, &block) # the Rack application specified by run inside the block. Other requests will be sent to the # default application specified by run outside the block. # - # Rack::Builder.app do + # class App + # def call(env) + # [200, {'content-type' => 'text/plain'}, ["Hello World"]] + # end + # end + # + # class Heartbeat + # def call(env) + # [200, { "content-type" => "text/plain" }, ["OK"]] + # end + # end + # + # app = Rack::Builder.app do # map '/heartbeat' do - # run Heartbeat + # run Heartbeat.new # end - # run App + # run App.new # end # + # run app + # # The +use+ method can also be used inside the block to specify middleware to run under a specific path: # - # Rack::Builder.app do + # app = Rack::Builder.app do # map '/heartbeat' do # use Middleware - # run Heartbeat + # run Heartbeat.new # end - # run App + # run App.new # end # # This example includes a piece of middleware which will run before +/heartbeat+ requests hit +Heartbeat+. diff --git a/lib/rack/constants.rb b/lib/rack/constants.rb index d99b63673..13365935b 100644 --- a/lib/rack/constants.rb +++ b/lib/rack/constants.rb @@ -14,7 +14,7 @@ module Rack SERVER_NAME = 'SERVER_NAME' SERVER_PORT = 'SERVER_PORT' HTTP_COOKIE = 'HTTP_COOKIE' - + # Response Header Keys CACHE_CONTROL = 'cache-control' CONTENT_LENGTH = 'content-length' @@ -55,6 +55,7 @@ module Rack RACK_REQUEST_FORM_INPUT = 'rack.request.form_input' RACK_REQUEST_FORM_HASH = 'rack.request.form_hash' RACK_REQUEST_FORM_VARS = 'rack.request.form_vars' + RACK_REQUEST_FORM_ERROR = 'rack.request.form_error' RACK_REQUEST_COOKIE_HASH = 'rack.request.cookie_hash' RACK_REQUEST_COOKIE_STRING = 'rack.request.cookie_string' RACK_REQUEST_QUERY_HASH = 'rack.request.query_hash' diff --git a/lib/rack/headers.rb b/lib/rack/headers.rb index 153c1b223..ae1a89d12 100644 --- a/lib/rack/headers.rb +++ b/lib/rack/headers.rb @@ -31,7 +31,7 @@ def []=(key, value) super(key.downcase.freeze, value) end alias store []= - + def assoc(key) super(downcase_key(key)) end @@ -43,7 +43,7 @@ def compare_by_identity def delete(key) super(downcase_key(key)) end - + def dig(key, *a) super(downcase_key(key), *a) end @@ -52,7 +52,7 @@ def fetch(key, *default, &block) key = downcase_key(key) super end - + def fetch_values(*a) super(*a.map!{|key| downcase_key(key)}) end @@ -63,34 +63,34 @@ def has_key?(key) alias include? has_key? alias key? has_key? alias member? has_key? - + def invert hash = self.class.new each{|key, value| hash[value] = key} hash end - + def merge(hash, &block) dup.merge!(hash, &block) end - + def reject(&block) hash = dup hash.reject!(&block) hash end - + def replace(hash) clear update(hash) end - + def select(&block) hash = dup hash.select!(&block) hash end - + def to_proc lambda{|x| self[x]} end @@ -100,10 +100,10 @@ def transform_values(&block) end def update(hash, &block) - hash.each do |key, value| + hash.each do |key, value| self[key] = if block_given? && include?(key) block.call(key, self[key], value) - else + else value end end @@ -114,7 +114,7 @@ def update(hash, &block) def values_at(*keys) keys.map{|key| self[key]} end - + # :nocov: if RUBY_VERSION >= '2.5' # :nocov: diff --git a/lib/rack/lint.rb b/lib/rack/lint.rb index 42878879a..ee3ec7161 100755 --- a/lib/rack/lint.rb +++ b/lib/rack/lint.rb @@ -303,7 +303,7 @@ def check_environment(env) ## (use the versions without HTTP_). %w[HTTP_CONTENT_TYPE HTTP_CONTENT_LENGTH].each { |header| if env.include? header - raise LintError, "env contains #{header}, must use #{header[5, -1]}" + raise LintError, "env contains #{header}, must use #{header[5..-1]}" end } @@ -629,7 +629,7 @@ def check_headers(headers) unless headers.kind_of?(Hash) raise LintError, "headers object should be a hash, but isn't (got #{headers.class} as headers)" end - + if headers.frozen? raise LintError, "headers object should not be frozen, but is" end @@ -817,8 +817,14 @@ def each verify_to_path end + BODY_METHODS = {to_ary: true, each: true, call: true, to_path: true} + + def to_path + @body.to_path + end + def respond_to?(name, *) - if name == :to_ary + if BODY_METHODS.key?(name) @body.respond_to?(name) else super @@ -883,7 +889,7 @@ class StreamWrapper def initialize(stream) @stream = stream - + REQUIRED_METHODS.each do |method_name| raise LintError, "Stream must respond to #{method_name}" unless stream.respond_to?(method_name) end diff --git a/lib/rack/media_type.rb b/lib/rack/media_type.rb index ff3145deb..7fc1e39db 100644 --- a/lib/rack/media_type.rb +++ b/lib/rack/media_type.rb @@ -4,7 +4,7 @@ module Rack # Rack::MediaType parse media type and parameters out of content_type string class MediaType - SPLIT_PATTERN = %r{\s*[;,]\s*} + SPLIT_PATTERN = /[;,]/ class << self # The media type (type/subtype) portion of the CONTENT_TYPE header @@ -15,7 +15,11 @@ class << self # http://www.w3.org/Protocols/rfc2616/rfc2616-sec3.html#sec3.7 def type(content_type) return nil unless content_type - content_type.split(SPLIT_PATTERN, 2).first.tap(&:downcase!) + if type = content_type.split(SPLIT_PATTERN, 2).first + type.rstrip! + type.downcase! + type + end end # The media type parameters provided in CONTENT_TYPE as a Hash, or @@ -27,9 +31,10 @@ def params(content_type) return {} if content_type.nil? content_type.split(SPLIT_PATTERN)[1..-1].each_with_object({}) do |s, hsh| + s.strip! k, v = s.split('=', 2) - - hsh[k.tap(&:downcase!)] = strip_doublequotes(v) + k.downcase! + hsh[k] = strip_doublequotes(v) end end diff --git a/lib/rack/method_override.rb b/lib/rack/method_override.rb index cf7fcf75d..6125b1916 100644 --- a/lib/rack/method_override.rb +++ b/lib/rack/method_override.rb @@ -46,8 +46,8 @@ def allowed_methods end def method_override_param(req) - req.POST[METHOD_OVERRIDE_PARAM_KEY] - rescue Utils::InvalidParameterError, Utils::ParameterTypeError + req.POST[METHOD_OVERRIDE_PARAM_KEY] if req.form_data? || req.parseable_data? + rescue Utils::InvalidParameterError, Utils::ParameterTypeError, QueryParser::ParamsTooDeepError req.get_header(RACK_ERRORS).puts "Invalid or incomplete POST params" rescue EOFError req.get_header(RACK_ERRORS).puts "Bad request content body" diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb index 480badaf2..345cc258b 100644 --- a/lib/rack/multipart/parser.rb +++ b/lib/rack/multipart/parser.rb @@ -8,6 +8,8 @@ module Rack module Multipart class MultipartPartLimitError < Errno::EMFILE; end + class MultipartTotalPartLimitError < StandardError; end + # Use specific error class when parsing multipart request # that ends early. class EmptyContentError < ::EOFError; end @@ -23,10 +25,10 @@ class Error < StandardError; end VALUE = /"(?:\\"|[^"])*"|#{TOKEN}/ BROKEN = /^#{CONDISP}.*;\s*filename=(#{VALUE})/i MULTIPART_CONTENT_TYPE = /Content-Type: (.*)#{EOL}/ni - MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:.*;\s*name=(#{VALUE})/ni + MULTIPART_CONTENT_DISPOSITION = /Content-Disposition:[^:]*;\s*name=(#{VALUE})/ni MULTIPART_CONTENT_ID = /Content-ID:\s*([^#{EOL}]*)/ni # Updated definitions from RFC 2231 - ATTRIBUTE_CHAR = %r{[^ \t\v\n\r)(><@,;:\\"/\[\]?='*%]} + ATTRIBUTE_CHAR = %r{[^ \x00-\x1f\x7f)(><@,;:\\"/\[\]?='*%]} ATTRIBUTE = /#{ATTRIBUTE_CHAR}+/ SECTION = /\*[0-9]+/ REGULAR_PARAMETER_NAME = /#{ATTRIBUTE}#{SECTION}?/ @@ -166,7 +168,7 @@ def on_mime_head(mime_index, head, filename, content_type, name) @mime_parts[mime_index] = klass.new(body, head, filename, content_type, name) - check_open_files + check_part_limits end def on_mime_body(mime_index, content) @@ -178,13 +180,23 @@ def on_mime_finish(mime_index) private - def check_open_files - if Utils.multipart_part_limit > 0 - if @open_files >= Utils.multipart_part_limit + def check_part_limits + file_limit = Utils.multipart_file_limit + part_limit = Utils.multipart_total_part_limit + + if file_limit && file_limit > 0 + if @open_files >= file_limit @mime_parts.each(&:close) raise MultipartPartLimitError, 'Maximum file multiparts in content reached' end end + + if part_limit && part_limit > 0 + if @mime_parts.size >= part_limit + @mime_parts.each(&:close) + raise MultipartTotalPartLimitError, 'Maximum total multiparts in content reached' + end + end end end @@ -201,6 +213,7 @@ def initialize(boundary, tempfile, bufsize, query_parser) @sbuf = StringScanner.new("".dup) @body_regex = /(?:#{EOL}|\A)--#{Regexp.quote(boundary)}(?:#{EOL}|--)/m + @end_boundary_size = boundary.bytesize + 4 # (-- at start, -- at finish) @rx_max_size = boundary.bytesize + 6 # (\r\n-- at start, either \r\n or -- at finish) @head_regex = /(.*?#{EOL})#{EOL}/m end @@ -267,7 +280,14 @@ def handle_fast_forward @state = :MIME_HEAD return when :END_BOUNDARY - # invalid multipart upload, but retry for opening boundary + # invalid multipart upload + if @sbuf.pos == @end_boundary_size && @sbuf.rest == EOL + # stop parsing a buffer if a buffer is only an end boundary. + @state = :DONE + return + end + + # retry for opening boundary else # no boundary found, keep reading data return :want_read diff --git a/lib/rack/query_parser.rb b/lib/rack/query_parser.rb index 3077fb1fd..9e8434cf3 100644 --- a/lib/rack/query_parser.rb +++ b/lib/rack/query_parser.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require 'uri' + module Rack class QueryParser DEFAULT_SEP = /[&] */n @@ -128,8 +130,6 @@ def normalize_params(params, name, v, _depth=nil) return if k.empty? - v ||= String.new - if after == '' if k == '[]' && depth != 0 return [v] @@ -190,8 +190,8 @@ def params_hash_has_key?(hash, key) true end - def unescape(s) - Utils.unescape(s) + def unescape(string, encoding = Encoding::UTF_8) + URI.decode_www_form_component(string, encoding) end class Params diff --git a/lib/rack/request.rb b/lib/rack/request.rb index 452140149..99a33aa1e 100644 --- a/lib/rack/request.rb +++ b/lib/rack/request.rb @@ -44,7 +44,7 @@ class << self @x_forwarded_proto_priority = [:proto, :scheme] valid_ipv4_octet = /\.(25[0-5]|2[0-4][0-9]|[01]?[0-9]?[0-9])/ - + trusted_proxies = Regexp.union( /\A127#{valid_ipv4_octet}{3}\z/, # localhost IPv4 range 127.x.x.x, per RFC-3330 /\A::1\z/, # localhost IPv6 ::1 @@ -54,7 +54,7 @@ class << self /\A192\.168#{valid_ipv4_octet}{2}\z/, # private IPv4 range 192.168.x.x /\Alocalhost\z|\Aunix(\z|:)/i, # localhost hostname, and unix domain sockets ) - + self.ip_filter = lambda { |ip| trusted_proxies.match?(ip) } ALLOWED_SCHEMES = %w(https http wss ws).freeze @@ -496,26 +496,46 @@ def GET # This method support both application/x-www-form-urlencoded and # multipart/form-data. def POST - if get_header(RACK_INPUT).nil? - raise "Missing rack.input" - elsif get_header(RACK_REQUEST_FORM_INPUT) == get_header(RACK_INPUT) - get_header(RACK_REQUEST_FORM_HASH) - elsif form_data? || parseable_data? - unless set_header(RACK_REQUEST_FORM_HASH, parse_multipart) - form_vars = get_header(RACK_INPUT).read - - # Fix for Safari Ajax postings that always append \0 - # form_vars.sub!(/\0\z/, '') # performance replacement: - form_vars.slice!(-1) if form_vars.end_with?("\0") - - set_header RACK_REQUEST_FORM_VARS, form_vars - set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') + if error = get_header(RACK_REQUEST_FORM_ERROR) + raise error.class, error.message, cause: error.cause + end + + begin + rack_input = get_header(RACK_INPUT) + + # If the form hash was already memoized: + if form_hash = get_header(RACK_REQUEST_FORM_HASH) + # And it was memoized from the same input: + if get_header(RACK_REQUEST_FORM_INPUT).equal?(rack_input) + return form_hash + end end - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - get_header RACK_REQUEST_FORM_HASH - else - set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) - set_header(RACK_REQUEST_FORM_HASH, {}) + + # Otherwise, figure out how to parse the input: + if rack_input.nil? + set_header RACK_REQUEST_FORM_INPUT, nil + set_header(RACK_REQUEST_FORM_HASH, {}) + elsif form_data? || parseable_data? + unless set_header(RACK_REQUEST_FORM_HASH, parse_multipart) + form_vars = get_header(RACK_INPUT).read + + # Fix for Safari Ajax postings that always append \0 + # form_vars.sub!(/\0\z/, '') # performance replacement: + form_vars.slice!(-1) if form_vars.end_with?("\0") + + set_header RACK_REQUEST_FORM_VARS, form_vars + set_header RACK_REQUEST_FORM_HASH, parse_query(form_vars, '&') + end + + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + get_header RACK_REQUEST_FORM_HASH + else + set_header RACK_REQUEST_FORM_INPUT, get_header(RACK_INPUT) + set_header(RACK_REQUEST_FORM_HASH, {}) + end + rescue => error + set_header(RACK_REQUEST_FORM_ERROR, error) + raise end end @@ -625,8 +645,8 @@ def wrap_ipv6(host) end def parse_http_accept_header(header) - header.to_s.split(/\s*,\s*/).map do |part| - attribute, parameters = part.split(/\s*;\s*/, 2) + header.to_s.split(",").each(&:strip!).map do |part| + attribute, parameters = part.split(";", 2).each(&:strip!) quality = 1.0 if parameters and /\Aq=([\d.]+)/ =~ parameters quality = $1.to_f diff --git a/lib/rack/response.rb b/lib/rack/response.rb index 2b7057a2f..b9b02c272 100644 --- a/lib/rack/response.rb +++ b/lib/rack/response.rb @@ -43,7 +43,7 @@ def header # # If the +body+ is +nil+, construct an empty response object with internal # buffering. - # + # # If the +body+ responds to +to_str+, assume it's a string-like object and # construct a buffered response object containing using that string as the # initial contents of the buffer. @@ -102,11 +102,16 @@ def chunked? CHUNKED == get_header(TRANSFER_ENCODING) end + def no_entity_body? + # The response body is an enumerable body and it is not allowed to have an entity body. + @body.respond_to?(:each) && STATUS_WITH_NO_ENTITY_BODY[@status] + end + # Generate a response array consistent with the requirements of the SPEC. # @return [Array] a 3-tuple suitable of `[status, headers, body]` # which is suitable to be returned from the middleware `#call(env)` method. def finish(&block) - if STATUS_WITH_NO_ENTITY_BODY[@status] + if no_entity_body? delete_header CONTENT_TYPE delete_header CONTENT_LENGTH close @@ -323,6 +328,8 @@ def buffered_body! @body.each do |part| @length += part.to_s.bytesize end + + @buffered = true elsif @body.respond_to?(:each) # Turn the user supplied body into a buffered array: body = @body @@ -333,7 +340,7 @@ def buffered_body! end body.close if body.respond_to?(:close) - + @buffered = true else @buffered = false diff --git a/lib/rack/sendfile.rb b/lib/rack/sendfile.rb index 45f0a8c36..9c6e0c42f 100644 --- a/lib/rack/sendfile.rb +++ b/lib/rack/sendfile.rb @@ -111,7 +111,7 @@ def initialize(app, variation = nil, mappings = []) end def call(env) - status, headers, body = response = @app.call(env) + _, headers, body = response = @app.call(env) if body.respond_to?(:to_path) case type = variation(env) diff --git a/lib/rack/urlmap.rb b/lib/rack/urlmap.rb index afb97eea4..99c4d8236 100644 --- a/lib/rack/urlmap.rb +++ b/lib/rack/urlmap.rb @@ -37,7 +37,7 @@ def remap(map) end location = location.chomp('/') - match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", nil, 'n') + match = Regexp.new("^#{Regexp.quote(location).gsub('/', '/+')}(.*)", Regexp::NOENCODING) [host, location, match, app] }.sort_by do |(host, location, _, _)| diff --git a/lib/rack/utils.rb b/lib/rack/utils.rb index 648b70590..f91838b37 100644 --- a/lib/rack/utils.rb +++ b/lib/rack/utils.rb @@ -58,13 +58,24 @@ def unescape(s, encoding = Encoding::UTF_8) end class << self - attr_accessor :multipart_part_limit + attr_accessor :multipart_total_part_limit + + attr_accessor :multipart_file_limit + + # multipart_part_limit is the original name of multipart_file_limit, but + # the limit only counts parts with filenames. + alias multipart_part_limit multipart_file_limit + alias multipart_part_limit= multipart_file_limit= end - # The maximum number of parts a request can contain. Accepting too many part - # can lead to the server running out of file handles. + # The maximum number of file parts a request can contain. Accepting too + # many parts can lead to the server running out of file handles. # Set to `0` for no limit. - self.multipart_part_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || 128).to_i + self.multipart_file_limit = (ENV['RACK_MULTIPART_PART_LIMIT'] || ENV['RACK_MULTIPART_FILE_LIMIT'] || 128).to_i + + # The maximum total number of parts a request can contain. Accepting too + # many can lead to excessive memory use and parsing time. + self.multipart_total_part_limit = (ENV['RACK_MULTIPART_TOTAL_PART_LIMIT'] || 4096).to_i def self.param_depth_limit default_query_parser.param_depth_limit @@ -121,19 +132,19 @@ def build_nested_query(value, prefix = nil) }.join("&") when Hash value.map { |k, v| - build_nested_query(v, prefix ? "#{prefix}[#{escape(k)}]" : escape(k)) + build_nested_query(v, prefix ? "#{prefix}[#{k}]" : k) }.delete_if(&:empty?).join('&') when nil - prefix + escape(prefix) else raise ArgumentError, "value must be a Hash" if prefix.nil? - "#{prefix}=#{escape(value)}" + "#{escape(prefix)}=#{escape(value)}" end end def q_values(q_value_header) - q_value_header.to_s.split(/\s*,\s*/).map do |part| - value, parameters = part.split(/\s*;\s*/, 2) + q_value_header.to_s.split(',').map do |part| + value, parameters = part.split(';', 2).map(&:strip) quality = 1.0 if parameters && (md = /\Aq=([\d.]+)/.match(parameters)) quality = md[1].to_f @@ -146,9 +157,10 @@ def forwarded_values(forwarded_header) return nil unless forwarded_header forwarded_header = forwarded_header.to_s.gsub("\n", ";") - forwarded_header.split(/\s*;\s*/).each_with_object({}) do |field, values| - field.split(/\s*,\s*/).each do |pair| - return nil unless pair =~ /\A\s*(by|for|host|proto)\s*=\s*"?([^"]+)"?\s*\Z/i + forwarded_header.split(';').each_with_object({}) do |field, values| + field.split(',').each do |pair| + pair = pair.split('=').map(&:strip).join('=') + return nil unless pair =~ /\A(by|for|host|proto)="?([^"]+)"?\Z/i (values[$1.downcase.to_sym] ||= []) << $2 end end @@ -278,7 +290,7 @@ def parse_cookies(env) # If the cookie +value+ is an instance of +Hash+, it considers the following # cookie attribute keys: +domain+, +max_age+, +expires+ (must be instance # of +Time+), +secure+, +http_only+, +same_site+ and +value+. For more - # details about the interpretation of these fields, consult + # details about the interpretation of these fields, consult # [RFC6265 Section 5.2](https://datatracker.ietf.org/doc/html/rfc6265#section-5.2). # # An extra cookie attribute +escape_key+ can be provided to control whether @@ -426,17 +438,18 @@ def get_byte_ranges(http_range, size) return nil unless http_range && http_range =~ /bytes=([^;]+)/ ranges = [] $1.split(/,\s*/).each do |range_spec| - return nil unless range_spec =~ /(\d*)-(\d*)/ - r0, r1 = $1, $2 - if r0.empty? - return nil if r1.empty? + return nil unless range_spec.include?('-') + range = range_spec.split('-') + r0, r1 = range[0], range[1] + if r0.nil? || r0.empty? + return nil if r1.nil? # suffix-byte-range-spec, represents trailing suffix of file r0 = size - r1.to_i r0 = 0 if r0 < 0 r1 = size - 1 else r0 = r0.to_i - if r1.empty? + if r1.nil? r1 = size - 1 else r1 = r1.to_i @@ -446,6 +459,9 @@ def get_byte_ranges(http_range, size) end ranges << (r0..r1) if r0 <= r1 end + + return [] if ranges.map(&:size).sum > size + ranges end diff --git a/lib/rack/version.rb b/lib/rack/version.rb index 782eb9811..884d21e2e 100644 --- a/lib/rack/version.rb +++ b/lib/rack/version.rb @@ -25,7 +25,7 @@ def self.version VERSION end - RELEASE = "3.0.0" + RELEASE = "3.0.10" # Return the Rack release as a dotted string. def self.release diff --git a/test/cgi/rackup_stub.rb b/test/cgi/rackup_stub.rb old mode 100755 new mode 100644 diff --git a/test/cgi/sample_rackup.ru b/test/cgi/sample_rackup.ru old mode 100755 new mode 100644 diff --git a/test/cgi/test b/test/cgi/test old mode 100755 new mode 100644 diff --git a/test/cgi/test.ru b/test/cgi/test.ru old mode 100755 new mode 100644 diff --git a/test/spec_builder.rb b/test/spec_builder.rb index 6ca284776..2cc7732c7 100644 --- a/test/spec_builder.rb +++ b/test/spec_builder.rb @@ -277,7 +277,7 @@ def config_file(name) it "strips leading unicode byte order mark when present" do enc = Encoding.default_external begin - verbose, $VERBOSE = $VERBOSE, nil + verbose, $VERBOSE = $VERBOSE, nil Encoding.default_external = 'UTF-8' app, _ = Rack::Builder.parse_file config_file('bom.ru') Rack::MockRequest.new(app).get("/").body.to_s.must_equal 'OK' diff --git a/test/spec_deflater.rb b/test/spec_deflater.rb index 87c3c4f0b..9d6e81f50 100644 --- a/test/spec_deflater.rb +++ b/test/spec_deflater.rb @@ -275,7 +275,7 @@ class << app_body; def each; yield('foo'); yield('bar'); end; end 'PATH_INFO' => '/foo/bar' } } - + app_body3 = [app_body] closed = false app_body3.define_singleton_method(:close){closed = true} diff --git a/test/spec_headers.rb b/test/spec_headers.rb old mode 100755 new mode 100644 index dcd6296bf..f22680b77 --- a/test/spec_headers.rb +++ b/test/spec_headers.rb @@ -18,7 +18,7 @@ def test_public_interface assert_empty(headers_methods - hash_methods) assert_empty(hash_methods - headers_methods) end - + def test_class_aref assert_equal Hash[], Rack::Headers[] assert_equal Hash['a'=>'2'], Rack::Headers['A'=>'2'] @@ -27,7 +27,7 @@ def test_class_aref assert_raises(ArgumentError){Rack::Headers['A']} assert_raises(ArgumentError){Rack::Headers['A',2,'B']} end - + def test_default_values h, ch = Hash.new, Rack::Headers.new assert_equal h, ch @@ -48,29 +48,29 @@ def test_default_values assert_equal '3', Rack::Headers.new('3').default assert_nil Rack::Headers.new('3').default_proc assert_equal '3', Rack::Headers.new('3')['1'] - + @fh.default = '4' assert_equal '4', @fh.default assert_nil @fh.default_proc assert_equal '4', @fh['55'] - + h = Rack::Headers.new('5') assert_equal '5', h.default assert_nil h.default_proc assert_equal '5', h['55'] - + h = Rack::Headers.new{|hash, key| '1234'} assert_nil h.default refute_equal nil, h.default_proc assert_equal '1234', h['55'] - + h = Rack::Headers.new{|hash, key| hash[key] = '1234'; nil} assert_nil h.default refute_equal nil, h.default_proc assert_nil h['Ac'] assert_equal '1234', h['aC'] end - + def test_store_and_retrieve assert_nil @h['a'] @h['A'] = '2' @@ -88,14 +88,14 @@ def test_store_and_retrieve assert_equal '8', @h['c'] assert_equal '8', @h['C'] end - + def test_clear assert_equal 3, @fh.length @fh.clear assert_equal Hash[], @fh assert_equal 0, @fh.length end - + def test_delete assert_equal 3, @fh.length assert_equal '1', @fh.delete('aB') @@ -103,7 +103,7 @@ def test_delete assert_nil @fh.delete('Ab') assert_equal 2, @fh.length end - + def test_delete_if_and_reject assert_equal 3, @fh.length hash = @fh.reject{|key, value| key == 'ab' || key == 'cd'} @@ -135,49 +135,49 @@ def @h.foo; 1; end assert_equal '2', h2['a'] assert_equal '3', h3['b'] end - + def test_each i = 0 @h.each{i+=1} assert_equal 0, i items = [['ab','1'], ['cd','2'], ['3','4']] - @fh.each do |k,v| + @fh.each do |k,v| assert items.include?([k,v]) items -= [[k,v]] end assert_equal [], items end - + def test_each_key i = 0 @h.each{i+=1} assert_equal 0, i keys = ['ab', 'cd', '3'] - @fh.each_key do |k| + @fh.each_key do |k| assert keys.include?(k) assert k.frozen? keys -= [k] end assert_equal [], keys end - + def test_each_value i = 0 @h.each{i+=1} assert_equal 0, i values = ['1', '2', '4'] - @fh.each_value do |v| + @fh.each_value do |v| assert values.include?(v) values -= [v] end assert_equal [], values end - + def test_empty assert @h.empty? assert !@fh.empty? end - + def test_fetch assert_raises(ArgumentError){@h.fetch(1,2,3)} assert_raises(ArgumentError){@h.fetch(1,2,3){4}} @@ -194,7 +194,7 @@ def test_fetch assert_equal '4', @fh.fetch("3"){|k| k*3} assert_raises(IndexError){Rack::Headers.new{34}.fetch(1)} end - + def test_has_key %i'include? has_key? key? member?'.each do |meth| assert !@h.send(meth,1) @@ -207,7 +207,7 @@ def test_has_key assert !@fh.send(meth,1) end end - + def test_has_value %i'value? has_value?'.each do |meth| assert !@h.send(meth,'1') @@ -217,14 +217,14 @@ def test_has_value assert !@fh.send(meth,'3') end end - + def test_inspect %i'inspect to_s'.each do |meth| assert_equal '{}', @h.send(meth) assert_equal '{"ab"=>"1", "cd"=>"2", "3"=>"4"}', @fh.send(meth) end end - + def test_invert assert_kind_of(Rack::Headers, @h.invert) assert_equal({}, @h.invert) @@ -232,19 +232,19 @@ def test_invert assert_equal({'cd'=>'ab'}, Rack::Headers['AB'=>'CD'].invert) assert_equal({'cd'=>'xy'}, Rack::Headers['AB'=>'Cd', 'xY'=>'cD'].invert) end - + def test_keys assert_equal [], @h.keys assert_equal %w'ab cd 3', @fh.keys end - + def test_length %i'length size'.each do |meth| assert_equal 0, @h.send(meth) assert_equal 3, @fh.send(meth) end end - + def test_merge_and_update assert_equal @h, @h.merge({}) assert_equal @fh, @fh.merge({}) @@ -263,7 +263,7 @@ def test_merge_and_update assert_equal Rack::Headers['ab'=>'abssabss55', 'cd'=>'2', '3'=>'4'], @fh.merge!({'ab'=>'ss'}){|k,ov,nv| [k,nv,ov].join} assert_equal Rack::Headers['ab'=>'abssabss55', 'cd'=>'2', '3'=>'4'], @fh end - + def test_replace h = @h.dup fh = @fh.dup @@ -278,7 +278,7 @@ def test_replace assert_equal @h, fh.replace({}) assert_equal @fh, h.replace('AB'=>'1', 'cd'=>'2', '3'=>'4') end - + def test_select assert_equal({}, @h.select{true}) assert_equal({}, @h.select{false}) @@ -287,7 +287,7 @@ def test_select assert_equal({'cd' => '2'}, @fh.select{|k,v| k.start_with?('c')}) assert_equal({'3' => '4'}, @fh.select{|k,v| v == '4'}) end - + def test_shift assert_nil @h.shift array = @fh.to_a @@ -307,29 +307,29 @@ def test_shift assert_equal [], array assert_equal 0, i end - + def test_sort assert_equal [], @h.sort assert_equal [], @h.sort{|a,b| a.to_s<=>b.to_s} assert_equal [['ab', '1'], ['cd', '4'], ['ef', '2']], Rack::Headers['CD','4','AB','1','EF','2'].sort assert_equal [['3', '4'], ['ab', '1'], ['cd', '2']], @fh.sort{|(ak,av),(bk,bv)| ak.to_s<=>bk.to_s} end - + def test_to_a assert_equal [], @h.to_a assert_equal [['ab', '1'], ['cd', '2'], ['3', '4']], @fh.to_a end - + def test_to_hash assert_equal Hash[], @h.to_hash assert_equal Hash['3','4','ab','1','cd','2'], @fh.to_hash end - + def test_values assert_equal [], @h.values assert_equal ['f', 'c'], Rack::Headers['aB','f','1','c'].values end - + def test_values_at assert_equal [], @h.values_at() assert_equal [nil], @h.values_at(1) diff --git a/test/spec_lint.rb b/test/spec_lint.rb index 054f9fded..398c7719c 100755 --- a/test/spec_lint.rb +++ b/test/spec_lint.rb @@ -33,7 +33,6 @@ def env(*args) lambda { Rack::Lint.new(nil).call({}.freeze) }.must_raise(Rack::Lint::LintError). message.must_match(/env should not be frozen, but is/) - lambda { e = env e.delete("REQUEST_METHOD") @@ -473,6 +472,17 @@ def obj.each; end message.must_match(/content-length header was 1, but should be 0/) end + it "responds to to_path" do + body = Object.new + def body.each; end + def body.to_path; __FILE__ end + app = lambda { |env| [200, {}, body] } + + body = Rack::Lint.new(app).call(env({}))[2] + body.must_respond_to(:to_path) + body.to_path.must_equal __FILE__ + end + it "notice body errors" do lambda { body = Rack::Lint.new(lambda { |env| diff --git a/test/spec_method_override.rb b/test/spec_method_override.rb index d0538a70d..f3b8ad729 100644 --- a/test/spec_method_override.rb +++ b/test/spec_method_override.rb @@ -106,10 +106,28 @@ def app env[Rack::RACK_ERRORS].read.must_include 'Bad request content body' end + it "not modify REQUEST_METHOD for POST requests when the params are unparseable because too deep" do + env = Rack::MockRequest.env_for("/", method: "POST", input: ("[a]" * 36) + "=1") + app.call env + + env["REQUEST_METHOD"].must_equal "POST" + end + it "not modify REQUEST_METHOD for POST requests when the params are unparseable" do env = Rack::MockRequest.env_for("/", method: "POST", input: "(%bad-params%)") app.call env env["REQUEST_METHOD"].must_equal "POST" end + + it "not set form input when the content type is JSON" do + env = Rack::MockRequest.env_for("/", + "CONTENT_TYPE" => "application/json", + method: "POST", + input: '{"_method":"options"}') + app.call env + + env["REQUEST_METHOD"].must_equal "POST" + env["rack.request.form_input"].must_be_nil + end end diff --git a/test/spec_mock_request.rb b/test/spec_mock_request.rb index 889c8a9f1..b2a16fded 100644 --- a/test/spec_mock_request.rb +++ b/test/spec_mock_request.rb @@ -26,8 +26,8 @@ req.GET["status"] || 200, "content-type" => "text/yaml" ) - response.set_cookie("session_test", { value: "session_test", domain: ".test.com", path: "/" }) - response.set_cookie("secure_test", { value: "secure_test", domain: ".test.com", path: "/", secure: true }) + response.set_cookie("session_test", { value: "session_test", domain: "test.com", path: "/" }) + response.set_cookie("secure_test", { value: "secure_test", domain: "test.com", path: "/", secure: true }) response.set_cookie("persistent_test", { value: "persistent_test", max_age: 15552000, path: "/" }) response.set_cookie("persistent_with_expires_test", { value: "persistent_with_expires_test", expires: Time.httpdate("Thu, 31 Oct 2021 07:28:00 GMT"), path: "/" }) response.set_cookie("expires_and_max-age_test", { value: "expires_and_max-age_test", expires: Time.now + 15552000 * 2, max_age: 15552000, path: "/" }) @@ -192,17 +192,17 @@ env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["QUERY_STRING"].must_include "baz=2" - env["QUERY_STRING"].must_include "foo[bar]=1" + env["QUERY_STRING"].must_include "foo%5Bbar%5D=1" env["PATH_INFO"].must_equal "/foo" env["mock.postdata"].must_equal "" end it "accept raw input in params for GET requests" do - res = Rack::MockRequest.new(app).get("/foo?baz=2", params: "foo[bar]=1") + res = Rack::MockRequest.new(app).get("/foo?baz=2", params: "foo%5Bbar%5D=1") env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "GET" env["QUERY_STRING"].must_include "baz=2" - env["QUERY_STRING"].must_include "foo[bar]=1" + env["QUERY_STRING"].must_include "foo%5Bbar%5D=1" env["PATH_INFO"].must_equal "/foo" env["mock.postdata"].must_equal "" end @@ -214,17 +214,17 @@ env["QUERY_STRING"].must_equal "" env["PATH_INFO"].must_equal "/foo" env["CONTENT_TYPE"].must_equal "application/x-www-form-urlencoded" - env["mock.postdata"].must_equal "foo[bar]=1" + env["mock.postdata"].must_equal "foo%5Bbar%5D=1" end it "accept raw input in params for POST requests" do - res = Rack::MockRequest.new(app).post("/foo", params: "foo[bar]=1") + res = Rack::MockRequest.new(app).post("/foo", params: "foo%5Bbar%5D=1") env = YAML.unsafe_load(res.body) env["REQUEST_METHOD"].must_equal "POST" env["QUERY_STRING"].must_equal "" env["PATH_INFO"].must_equal "/foo" env["CONTENT_TYPE"].must_equal "application/x-www-form-urlencoded" - env["mock.postdata"].must_equal "foo[bar]=1" + env["mock.postdata"].must_equal "foo%5Bbar%5D=1" end it "accept params and build multipart encoded params for POST requests" do diff --git a/test/spec_mock_response.rb b/test/spec_mock_response.rb index 2813ecd6f..83fba9287 100644 --- a/test/spec_mock_response.rb +++ b/test/spec_mock_response.rb @@ -26,8 +26,8 @@ req.GET["status"] || 200, "content-type" => "text/yaml" ) - response.set_cookie("session_test", { value: "session_test", domain: ".test.com", path: "/" }) - response.set_cookie("secure_test", { value: "secure_test", domain: ".test.com", path: "/", secure: true }) + response.set_cookie("session_test", { value: "session_test", domain: "test.com", path: "/" }) + response.set_cookie("secure_test", { value: "secure_test", domain: "test.com", path: "/", secure: true }) response.set_cookie("persistent_test", { value: "persistent_test", max_age: 15552000, path: "/" }) response.set_cookie("persistent_with_expires_test", { value: "persistent_with_expires_test", expires: Time.httpdate("Thu, 31 Oct 2021 07:28:00 GMT"), path: "/" }) response.set_cookie("expires_and_max-age_test", { value: "expires_and_max-age_test", expires: Time.now + 15552000 * 2, max_age: 15552000, path: "/" }) @@ -82,7 +82,7 @@ res = Rack::MockRequest.new(app).get("") session_cookie = res.cookie("session_test") session_cookie.value[0].must_equal "session_test" - session_cookie.domain.must_equal ".test.com" + session_cookie.domain.must_equal "test.com" session_cookie.path.must_equal "/" session_cookie.secure.must_equal false session_cookie.expires.must_be_nil @@ -122,7 +122,7 @@ res = Rack::MockRequest.new(app).get("") secure_cookie = res.cookie("secure_test") secure_cookie.value[0].must_equal "secure_test" - secure_cookie.domain.must_equal ".test.com" + secure_cookie.domain.must_equal "test.com" secure_cookie.path.must_equal "/" secure_cookie.secure.must_equal true secure_cookie.expires.must_be_nil diff --git a/test/spec_multipart.rb b/test/spec_multipart.rb index 43bb90b2d..c415056b6 100644 --- a/test/spec_multipart.rb +++ b/test/spec_multipart.rb @@ -691,7 +691,7 @@ def initialize(*) end end - it "reach a multipart limit" do + it "reach a multipart file limit" do begin previous_limit = Rack::Utils.multipart_part_limit Rack::Utils.multipart_part_limit = 3 @@ -703,6 +703,18 @@ def initialize(*) end end + it "reach a multipart total limit" do + begin + previous_limit = Rack::Utils.multipart_total_part_limit + Rack::Utils.multipart_total_part_limit = 5 + + env = Rack::MockRequest.env_for '/', multipart_fixture(:three_files_three_fields) + lambda { Rack::Multipart.parse_multipart(env) }.must_raise Rack::Multipart::MultipartTotalPartLimitError + ensure + Rack::Utils.multipart_total_part_limit = previous_limit + end + end + it "return nil if no UploadedFiles were used" do data = Rack::Multipart.build_multipart("people" => [{ "submit-name" => "Larry", "files" => "contents" }]) data.must_be_nil diff --git a/test/spec_query_parser.rb b/test/spec_query_parser.rb new file mode 100644 index 000000000..dbb8b14ed --- /dev/null +++ b/test/spec_query_parser.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +require_relative 'helper' + +separate_testing do + require_relative '../lib/rack/query_parser' +end + +describe Rack::QueryParser do + query_parser ||= Rack::QueryParser.make_default(8) + + it "can normalize values with missing values" do + query_parser.parse_nested_query("a=a").must_equal({"a" => "a"}) + query_parser.parse_nested_query("a=").must_equal({"a" => ""}) + query_parser.parse_nested_query("a").must_equal({"a" => nil}) + end +end diff --git a/test/spec_request.rb b/test/spec_request.rb index 750639deb..c22619063 100644 --- a/test/spec_request.rb +++ b/test/spec_request.rb @@ -572,11 +572,11 @@ def self.req(headers) end it "parse the query string" do - req = make_request(Rack::MockRequest.env_for("/?foo=bar&quux=bla")) - req.query_string.must_equal "foo=bar&quux=bla" - req.GET.must_equal "foo" => "bar", "quux" => "bla" - req.POST.must_be :empty? - req.params.must_equal "foo" => "bar", "quux" => "bla" + request = make_request(Rack::MockRequest.env_for("/?foo=bar&quux=bla¬hing&empty=")) + request.query_string.must_equal "foo=bar&quux=bla¬hing&empty=" + request.GET.must_equal "foo" => "bar", "quux" => "bla", "nothing" => nil, "empty" => "" + request.POST.must_be :empty? + request.params.must_equal "foo" => "bar", "quux" => "bla", "nothing" => nil, "empty" => "" end it "not truncate query strings containing semi-colons #543 only in POST" do @@ -696,11 +696,6 @@ def initialize(*) message.must_equal "invalid %-encoding (a%)" end - it "raise if rack.input is missing" do - req = make_request({}) - lambda { req.POST }.must_raise RuntimeError - end - it "parse POST data when method is POST and no content-type given" do req = make_request \ Rack::MockRequest.env_for("/?foo=quux", @@ -1218,6 +1213,22 @@ def initialize(*) req.media_type_params['weird'].must_equal 'lol"' end + it "returns the same error for invalid post inputs" do + env = { + 'REQUEST_METHOD' => 'POST', + 'PATH_INFO' => '/foo', + 'rack.input' => StringIO.new('invalid=bar&invalid[foo]=bar'), + 'HTTP_CONTENT_TYPE' => "application/x-www-form-urlencoded", + } + + 2.times do + # The actual exception type here is unimportant - just that it fails. + assert_raises(Rack::Utils::ParameterTypeError) do + Rack::Request.new(env).POST + end + end + end + it "parse with junk before boundary" do # Adapted from RFC 1867. input = < "multipart/form-data, boundary=AaB03x", + "CONTENT_LENGTH" => input.size, + :input => input + ) + + req = make_request mr + req.query_string.must_equal "" + req.GET.must_be :empty? + req.POST.must_be :empty? + req.params.must_equal({}) + end + + it "MultipartPartLimitError when request has too many multipart file parts if limit set" do begin data = 10000.times.map { "--AaB03x\r\ncontent-type: text/plain\r\ncontent-disposition: attachment; name=#{SecureRandom.hex(10)}; filename=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") data += "--AaB03x--\r" @@ -1338,6 +1367,22 @@ def initialize(*) end end + it "MultipartPartLimitError when request has too many multipart total parts if limit set" do + begin + data = 10000.times.map { "--AaB03x\r\ncontent-type: text/plain\r\ncontent-disposition: attachment; name=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") + data += "--AaB03x--\r" + + options = { + "CONTENT_TYPE" => "multipart/form-data; boundary=AaB03x", + "CONTENT_LENGTH" => data.length.to_s, + :input => StringIO.new(data) + } + + request = make_request Rack::MockRequest.env_for("/", options) + lambda { request.POST }.must_raise Rack::Multipart::MultipartTotalPartLimitError + end + end + it 'closes tempfiles it created in the case of too many created' do begin data = 10000.times.map { "--AaB03x\r\ncontent-type: text/plain\r\ncontent-disposition: attachment; name=#{SecureRandom.hex(10)}; filename=#{SecureRandom.hex(10)}\r\n\r\ncontents\r\n" }.join("\r\n") @@ -1507,12 +1552,16 @@ def initialize(*) rack_input.write(input) rack_input.rewind - req = make_request Rack::MockRequest.env_for("/", - "rack.request.form_hash" => { 'foo' => 'bar' }, - "rack.request.form_input" => rack_input, - :input => rack_input) + form_hash = {} + + req = make_request Rack::MockRequest.env_for( + "/", + "rack.request.form_hash" => form_hash, + "rack.request.form_input" => rack_input, + :input => rack_input + ) - req.POST.must_equal req.env['rack.request.form_hash'] + req.POST.must_be_same_as form_hash end it "conform to the Rack spec" do diff --git a/test/spec_response.rb b/test/spec_response.rb index 459aaa8fb..11e64d61f 100644 --- a/test/spec_response.rb +++ b/test/spec_response.rb @@ -295,14 +295,14 @@ "foo=bar; domain=example.com.example.com", "foo=bar; domain=example.com" ] - + response.delete_cookie "foo", { domain: "example.com" } response["set-cookie"].must_equal [ "foo=bar; domain=example.com.example.com", "foo=bar; domain=example.com", "foo=; domain=example.com; max-age=0; expires=Thu, 01 Jan 1970 00:00:00 GMT" ] - + response.delete_cookie "foo", { domain: "example.com.example.com" } response["set-cookie"].must_equal [ "foo=bar; domain=example.com.example.com", @@ -412,7 +412,7 @@ def object_with_each.each status.must_equal 404 end - it "correctly updates content-type when writing when not initialized with body" do + it "correctly updates content-length when writing when initialized without body" do r = Rack::Response.new r.write('foo') r.write('bar') @@ -423,20 +423,39 @@ def object_with_each.each header['content-length'].must_equal '9' end - it "correctly updates content-type when writing when initialized with body" do + it "correctly updates content-length when writing when initialized with Array body" do + r = Rack::Response.new(["foo"]) + r.write('bar') + r.write('baz') + _, header, body = r.finish + str = "".dup; body.each { |part| str << part } + str.must_equal "foobarbaz" + header['content-length'].must_equal '9' + end + + it "correctly updates content-length when writing when initialized with String body" do + r = Rack::Response.new("foo") + r.write('bar') + r.write('baz') + _, header, body = r.finish + str = "".dup; body.each { |part| str << part } + str.must_equal "foobarbaz" + header['content-length'].must_equal '9' + end + + it "correctly updates content-length when writing when initialized with object body that responds to #each" do obj = Object.new def obj.each yield 'foo' yield 'bar' end - ["foobar", ["foo", "bar"], obj].each do - r = Rack::Response.new(["foo", "bar"]) - r.write('baz') - _, header, body = r.finish - str = "".dup; body.each { |part| str << part } - str.must_equal "foobarbaz" - header['content-length'].must_equal '9' - end + r = Rack::Response.new(obj) + r.write('baz') + r.write('baz') + _, header, body = r.finish + str = "".dup; body.each { |part| str << part } + str.must_equal "foobarbazbaz" + header['content-length'].must_equal '12' end it "doesn't return invalid responses" do @@ -642,6 +661,16 @@ def obj.each res.body.wont_be :closed? end + it "doesn't clear #body when 101 and streaming" do + res = Rack::Response.new + + streaming_body = proc{|stream| stream.close} + res.body = streaming_body + res.status = 101 + res.finish + res.body.must_equal streaming_body + end + it "flatten doesn't cause infinite loop" do # https://github.com/rack/rack/issues/419 res = Rack::Response.new("Hello World") diff --git a/test/spec_utils.rb b/test/spec_utils.rb index 52d295024..2165c5a6a 100644 --- a/test/spec_utils.rb +++ b/test/spec_utils.rb @@ -159,7 +159,7 @@ def assert_nested_query(exp, act) it "parse nested query strings correctly" do Rack::Utils.parse_nested_query("foo"). - must_equal "foo" => "" + must_equal "foo" => nil Rack::Utils.parse_nested_query("foo="). must_equal "foo" => "" Rack::Utils.parse_nested_query("foo=bar"). @@ -176,7 +176,7 @@ def assert_nested_query(exp, act) Rack::Utils.parse_nested_query("&foo=1&&bar=2"). must_equal "foo" => "1", "bar" => "2" Rack::Utils.parse_nested_query("foo&bar="). - must_equal "foo" => "", "bar" => "" + must_equal "foo" => nil, "bar" => "" Rack::Utils.parse_nested_query("foo=bar&baz="). must_equal "foo" => "bar", "baz" => "" Rack::Utils.parse_nested_query("my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F"). @@ -186,19 +186,19 @@ def assert_nested_query(exp, act) must_equal "pid=1234" => "1023", "a" => "b" Rack::Utils.parse_nested_query("foo[]"). - must_equal "foo" => [""] + must_equal "foo" => [nil] Rack::Utils.parse_nested_query("foo[]="). must_equal "foo" => [""] Rack::Utils.parse_nested_query("foo[]=bar"). must_equal "foo" => ["bar"] Rack::Utils.parse_nested_query("foo[]=bar&foo"). - must_equal "foo" => "" + must_equal "foo" => nil Rack::Utils.parse_nested_query("foo[]=bar&foo["). - must_equal "foo" => ["bar"], "foo[" => "" + must_equal "foo" => ["bar"], "foo[" => nil Rack::Utils.parse_nested_query("foo[]=bar&foo[=baz"). must_equal "foo" => ["bar"], "foo[" => "baz" Rack::Utils.parse_nested_query("foo[]=bar&foo[]"). - must_equal "foo" => ["bar", ""] + must_equal "foo" => ["bar", nil] Rack::Utils.parse_nested_query("foo[]=bar&foo[]="). must_equal "foo" => ["bar", ""] @@ -210,7 +210,7 @@ def assert_nested_query(exp, act) must_equal "foo" => ["bar"], "baz" => ["1", "2", "3"] Rack::Utils.parse_nested_query("x[y][z]"). - must_equal "x" => { "y" => { "z" => "" } } + must_equal "x" => { "y" => { "z" => nil } } Rack::Utils.parse_nested_query("x[y][z]=1"). must_equal "x" => { "y" => { "z" => "1" } } Rack::Utils.parse_nested_query("x[y][z][]=1"). @@ -328,9 +328,9 @@ def initialize(*) assert_nested_query("my+weird+field=q1%212%22%27w%245%267%2Fz8%29%3F", "my weird field" => "q1!2\"'w$5&7/z8)?") - Rack::Utils.build_nested_query("foo" => [nil]).must_equal "foo[]" - Rack::Utils.build_nested_query("foo" => [""]).must_equal "foo[]=" - Rack::Utils.build_nested_query("foo" => ["bar"]).must_equal "foo[]=bar" + Rack::Utils.build_nested_query("foo" => [nil]).must_equal "foo%5B%5D" + Rack::Utils.build_nested_query("foo" => [""]).must_equal "foo%5B%5D=" + Rack::Utils.build_nested_query("foo" => ["bar"]).must_equal "foo%5B%5D=bar" Rack::Utils.build_nested_query('foo' => []).must_equal '' Rack::Utils.build_nested_query('foo' => {}).must_equal '' Rack::Utils.build_nested_query('foo' => 'bar', 'baz' => []).must_equal 'foo=bar' @@ -341,35 +341,39 @@ def initialize(*) Rack::Utils.build_nested_query('foo' => 'bar', 'baz' => ''). must_equal 'foo=bar&baz=' Rack::Utils.build_nested_query('foo' => ['1', '2']). - must_equal 'foo[]=1&foo[]=2' + must_equal 'foo%5B%5D=1&foo%5B%5D=2' Rack::Utils.build_nested_query('foo' => 'bar', 'baz' => ['1', '2', '3']). - must_equal 'foo=bar&baz[]=1&baz[]=2&baz[]=3' + must_equal 'foo=bar&baz%5B%5D=1&baz%5B%5D=2&baz%5B%5D=3' Rack::Utils.build_nested_query('foo' => ['bar'], 'baz' => ['1', '2', '3']). - must_equal 'foo[]=bar&baz[]=1&baz[]=2&baz[]=3' + must_equal 'foo%5B%5D=bar&baz%5B%5D=1&baz%5B%5D=2&baz%5B%5D=3' Rack::Utils.build_nested_query('foo' => ['bar'], 'baz' => ['1', '2', '3']). - must_equal 'foo[]=bar&baz[]=1&baz[]=2&baz[]=3' + must_equal 'foo%5B%5D=bar&baz%5B%5D=1&baz%5B%5D=2&baz%5B%5D=3' Rack::Utils.build_nested_query('x' => { 'y' => { 'z' => '1' } }). - must_equal 'x[y][z]=1' + must_equal 'x%5By%5D%5Bz%5D=1' Rack::Utils.build_nested_query('x' => { 'y' => { 'z' => ['1'] } }). - must_equal 'x[y][z][]=1' + must_equal 'x%5By%5D%5Bz%5D%5B%5D=1' Rack::Utils.build_nested_query('x' => { 'y' => { 'z' => ['1', '2'] } }). - must_equal 'x[y][z][]=1&x[y][z][]=2' + must_equal 'x%5By%5D%5Bz%5D%5B%5D=1&x%5By%5D%5Bz%5D%5B%5D=2' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => '1' }] }). - must_equal 'x[y][][z]=1' + must_equal 'x%5By%5D%5B%5D%5Bz%5D=1' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => ['1'] }] }). - must_equal 'x[y][][z][]=1' + must_equal 'x%5By%5D%5B%5D%5Bz%5D%5B%5D=1' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => '1', 'w' => '2' }] }). - must_equal 'x[y][][z]=1&x[y][][w]=2' + must_equal 'x%5By%5D%5B%5D%5Bz%5D=1&x%5By%5D%5B%5D%5Bw%5D=2' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'v' => { 'w' => '1' } }] }). - must_equal 'x[y][][v][w]=1' + must_equal 'x%5By%5D%5B%5D%5Bv%5D%5Bw%5D=1' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => '1', 'v' => { 'w' => '2' } }] }). - must_equal 'x[y][][z]=1&x[y][][v][w]=2' + must_equal 'x%5By%5D%5B%5D%5Bz%5D=1&x%5By%5D%5B%5D%5Bv%5D%5Bw%5D=2' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => '1' }, { 'z' => '2' }] }). - must_equal 'x[y][][z]=1&x[y][][z]=2' + must_equal 'x%5By%5D%5B%5D%5Bz%5D=1&x%5By%5D%5B%5D%5Bz%5D=2' Rack::Utils.build_nested_query('x' => { 'y' => [{ 'z' => '1', 'w' => 'a' }, { 'z' => '2', 'w' => '3' }] }). - must_equal 'x[y][][z]=1&x[y][][w]=a&x[y][][z]=2&x[y][][w]=3' + must_equal 'x%5By%5D%5B%5D%5Bz%5D=1&x%5By%5D%5B%5D%5Bw%5D=a&x%5By%5D%5B%5D%5Bz%5D=2&x%5By%5D%5B%5D%5Bw%5D=3' Rack::Utils.build_nested_query({ "foo" => ["1", ["2"]] }). - must_equal 'foo[]=1&foo[][]=2' + must_equal 'foo%5B%5D=1&foo%5B%5D%5B%5D=2' + + # A nested hash is the same as string keys with brackets. + Rack::Utils.build_nested_query('foo' => { 'bar' => 'baz' }). + must_equal Rack::Utils.build_nested_query('foo[bar]' => 'baz') lambda { Rack::Utils.build_nested_query("foo=bar") }. must_raise(ArgumentError). @@ -712,6 +716,10 @@ def initialize(*) end describe Rack::Utils, "get_byte_ranges" do + it "returns an empty list if the sum of the ranges is too large" do + assert_equal [], Rack::Utils.byte_ranges({ "HTTP_RANGE" => "bytes=0-20,0-500" }, 500) + end + deprecated "pase simple byte ranges from env" do Rack::Utils.byte_ranges({ "HTTP_RANGE" => "bytes=123-456" }, 500).must_equal [(123..456)] end