summary refs log tree commit
diff options
context:
space:
mode:
-rw-r--r--.travis.yml27
-rw-r--r--HISTORY.md17
-rw-r--r--lib/rack.rb2
-rw-r--r--lib/rack/auth/digest/params.rb5
-rw-r--r--lib/rack/common_logger.rb2
-rw-r--r--lib/rack/directory.rb22
-rw-r--r--lib/rack/etag.rb4
-rw-r--r--lib/rack/file.rb11
-rw-r--r--lib/rack/handler.rb2
-rw-r--r--lib/rack/mock.rb1
-rw-r--r--lib/rack/multipart/generator.rb2
-rw-r--r--lib/rack/multipart/parser.rb2
-rw-r--r--lib/rack/query_parser.rb11
-rw-r--r--lib/rack/request.rb42
-rw-r--r--lib/rack/urlmap.rb6
-rw-r--r--lib/rack/utils.rb21
-rw-r--r--test/helper.rb5
-rw-r--r--test/spec_directory.rb15
-rw-r--r--test/spec_etag.rb4
-rw-r--r--test/spec_file.rb11
-rw-r--r--test/spec_response.rb60
-rw-r--r--test/spec_utils.rb15
-rw-r--r--test/spec_webrick.rb14
23 files changed, 244 insertions, 57 deletions
diff --git a/.travis.yml b/.travis.yml
index 5008c954..cf999939 100644
--- a/.travis.yml
+++ b/.travis.yml
@@ -1,13 +1,27 @@
+language: ruby
+sudo: false
+cache:
+  - bundler
+  - apt
+
+services:
+  - memcached
+
+addons:
+  apt:
+    packages:
+      - lighttpd
+      - libfcgi-dev
+
 before_install:
-  - sudo apt-get update > /dev/null
-  - sudo apt-get -y install lighttpd libfcgi-dev libmemcache-dev memcached
-install:
   - gem env version | grep '^\(2\|1.\(8\|9\|[0-9][0-9]\)\)' || gem update --system
-  - bundle install --jobs=3 --retry=3
+  - gem list -i bundler || gem install bundler
+
 script: bundle exec rake ci
+
 rvm:
-  - 2.2.4
-  - 2.3.0
+  - 2.2.5
+  - 2.3.1
   - ruby-head
   - rbx-2
   - jruby-9.0.4.0
@@ -20,3 +34,4 @@ notifications:
 matrix:
   allow_failures:
     - rvm: rbx-2
+    - rvm: jruby-head
diff --git a/HISTORY.md b/HISTORY.md
index 270a7ba1..406d1758 100644
--- a/HISTORY.md
+++ b/HISTORY.md
@@ -1,9 +1,18 @@
 Sun Dec 4 18:48:03 2015  Jeremy Daer <jeremydaer@gmail.com>
 
-        * "First-Party" cookies. Browsers omit First-Party cookies from
-        third-party requests, closing the door on many common CSRF attacks.
-        Pass `first_party: true` to enable:
-            response.set_cookie 'foo', value: 'bar', first_party: true
+        * First-party "SameSite" cookies. Browsers omit SameSite cookies
+        from third-party requests, closing the door on many CSRF attacks.
+
+        Pass `same_site: true` (or `:strict`) to enable:
+            response.set_cookie 'foo', value: 'bar', same_site: true
+        or `same_site: :lax` to use Lax enforcement:
+            response.set_cookie 'foo', value: 'bar', same_site: :lax
+
+        Based on version 7 of the Same-site Cookies internet draft:
+        https://tools.ietf.org/html/draft-west-first-party-cookies-07
+
+        Thanks to Ben Toews (@mastahyeti) and Bob Long (@bobjflong) for
+        updating to drafts 5 and 7.
 
 Tue Nov  3 16:17:26 2015  Aaron Patterson <tenderlove@ruby-lang.org>
 
diff --git a/lib/rack.rb b/lib/rack.rb
index 4a02eed1..00621178 100644
--- a/lib/rack.rb
+++ b/lib/rack.rb
@@ -18,7 +18,7 @@ module Rack
     VERSION.join(".")
   end
 
-  RELEASE = "2.0.0.alpha"
+  RELEASE = "2.0.0.rc1"
 
   # Return the Rack release as a dotted string.
   def self.release
diff --git a/lib/rack/auth/digest/params.rb b/lib/rack/auth/digest/params.rb
index f35a7bab..2b226e62 100644
--- a/lib/rack/auth/digest/params.rb
+++ b/lib/rack/auth/digest/params.rb
@@ -17,7 +17,7 @@ module Rack
         end
 
         def self.split_header_value(str)
-          str.scan( /(\w+\=(?:"[^\"]+"|[^,]+))/n ).collect{ |v| v[0] }
+          str.scan(/\w+\=(?:"[^\"]+"|[^,]+)/n)
         end
 
         def initialize
@@ -38,7 +38,7 @@ module Rack
 
         def to_s
           map do |k, v|
-            "#{k}=" + (UNQUOTED.include?(k) ? v.to_s : quote(v))
+            "#{k}=" << (UNQUOTED.include?(k) ? v.to_s : quote(v))
           end.join(', ')
         end
 
@@ -50,4 +50,3 @@ module Rack
     end
   end
 end
-
diff --git a/lib/rack/common_logger.rb b/lib/rack/common_logger.rb
index 1ec8266d..ae410430 100644
--- a/lib/rack/common_logger.rb
+++ b/lib/rack/common_logger.rb
@@ -48,7 +48,7 @@ module Rack
         now.strftime("%d/%b/%Y:%H:%M:%S %z"),
         env[REQUEST_METHOD],
         env[PATH_INFO],
-        env[QUERY_STRING].empty? ? "" : "?"+env[QUERY_STRING],
+        env[QUERY_STRING].empty? ? "" : "?#{env[QUERY_STRING]}",
         env[HTTP_VERSION],
         status.to_s[0..3],
         length,
diff --git a/lib/rack/directory.rb b/lib/rack/directory.rb
index 554f9c33..89cfe807 100644
--- a/lib/rack/directory.rb
+++ b/lib/rack/directory.rb
@@ -59,13 +59,21 @@ table { width:100%%; }
     def initialize(root, app=nil)
       @root = ::File.expand_path(root)
       @app = app || Rack::File.new(@root)
+      @head = Rack::Head.new(lambda { |env| get env })
     end
 
     def call(env)
+      # strip body if this is a HEAD call
+      @head.call env
+    end
+
+    def get(env)
       script_name = env[SCRIPT_NAME]
       path_info = Utils.unescape_path(env[PATH_INFO])
 
-      if forbidden = check_forbidden(path_info)
+      if bad_request = check_bad_request(path_info)
+        bad_request
+      elsif forbidden = check_forbidden(path_info)
         forbidden
       else
         path = ::File.join(@root, path_info)
@@ -73,6 +81,16 @@ table { width:100%%; }
       end
     end
 
+    def check_bad_request(path_info)
+      return if Utils.valid_path?(path_info)
+
+      body = "Bad Request\n"
+      size = body.bytesize
+      return [400, {CONTENT_TYPE => "text/plain",
+        CONTENT_LENGTH => size.to_s,
+        "X-Cascade" => "pass"}, [body]]
+    end
+
     def check_forbidden(path_info)
       return unless path_info.include? ".."
 
@@ -155,7 +173,7 @@ table { width:100%%; }
         return format % (int.to_f / size) if int >= size
       end
 
-      int.to_s + 'B'
+      "#{int}B"
     end
   end
 end
diff --git a/lib/rack/etag.rb b/lib/rack/etag.rb
index 0ecc55ab..a0041062 100644
--- a/lib/rack/etag.rb
+++ b/lib/rack/etag.rb
@@ -65,10 +65,10 @@ module Rack
 
         body.each do |part|
           parts << part
-          (digest ||= Digest::MD5.new) << part unless part.empty?
+          (digest ||= Digest::SHA256.new) << part unless part.empty?
         end
 
-        [digest && digest.hexdigest, parts]
+        [digest && digest.hexdigest.byteslice(0, 32), parts]
       end
   end
 end
diff --git a/lib/rack/file.rb b/lib/rack/file.rb
index 5b755f56..0a257b3d 100644
--- a/lib/rack/file.rb
+++ b/lib/rack/file.rb
@@ -2,6 +2,7 @@ require 'time'
 require 'rack/utils'
 require 'rack/mime'
 require 'rack/request'
+require 'rack/head'
 
 module Rack
   # Rack::File serves files below the +root+ directory given, according to the
@@ -22,17 +23,24 @@ module Rack
       @root = root
       @headers = headers
       @default_mime = default_mime
+      @head = Rack::Head.new(lambda { |env| get env })
     end
 
     def call(env)
+      # HEAD requests drop the response body, including 4xx error messages.
+      @head.call env
+    end
+
+    def get(env)
       request = Rack::Request.new env
       unless ALLOWED_VERBS.include? request.request_method
         return fail(405, "Method Not Allowed", {'Allow' => ALLOW_HEADER})
       end
 
       path_info = Utils.unescape_path request.path_info
-      clean_path_info = Utils.clean_path_info(path_info)
+      return fail(400, "Bad Request") unless Utils.valid_path?(path_info)
 
+      clean_path_info = Utils.clean_path_info(path_info)
       path = ::File.join(@root, clean_path_info)
 
       available = begin
@@ -131,6 +139,7 @@ module Rack
 
     def fail(status, body, headers = {})
       body += "\n"
+
       [
         status,
         {
diff --git a/lib/rack/handler.rb b/lib/rack/handler.rb
index adaac5d6..70a77fa9 100644
--- a/lib/rack/handler.rb
+++ b/lib/rack/handler.rb
@@ -52,7 +52,7 @@ module Rack
       elsif ENV.include?("RACK_HANDLER")
         self.get(ENV["RACK_HANDLER"])
       else
-        pick ['thin', 'puma', 'webrick']
+        pick ['puma', 'thin', 'webrick']
       end
     end
 
diff --git a/lib/rack/mock.rb b/lib/rack/mock.rb
index 04d27a61..4ebc4df1 100644
--- a/lib/rack/mock.rb
+++ b/lib/rack/mock.rb
@@ -163,7 +163,6 @@ module Rack
     def initialize(status, headers, body, errors=StringIO.new(""))
       @original_headers = headers
       @errors           = errors.string if errors.respond_to?(:string)
-      @body_string      = nil
 
       super(body, status, headers)
     end
diff --git a/lib/rack/multipart/generator.rb b/lib/rack/multipart/generator.rb
index 6367135f..f0b70a8d 100644
--- a/lib/rack/multipart/generator.rb
+++ b/lib/rack/multipart/generator.rb
@@ -22,7 +22,7 @@ module Rack
           else
             content_for_other(file, name)
           end
-        end.join + "--#{MULTIPART_BOUNDARY}--\r"
+        end.join << "--#{MULTIPART_BOUNDARY}--\r"
       end
 
       private
diff --git a/lib/rack/multipart/parser.rb b/lib/rack/multipart/parser.rb
index 567c868d..74a7ee67 100644
--- a/lib/rack/multipart/parser.rb
+++ b/lib/rack/multipart/parser.rb
@@ -26,7 +26,7 @@ module Rack
           str = if left < size
                   @io.read left
                 else
-                 @io.read size
+                  @io.read size
                 end
 
           if str
diff --git a/lib/rack/query_parser.rb b/lib/rack/query_parser.rb
index 72a521f3..17b77128 100644
--- a/lib/rack/query_parser.rb
+++ b/lib/rack/query_parser.rb
@@ -82,7 +82,13 @@ module Rack
       k = $1 || ''.freeze
       after = $' || ''.freeze
 
-      return if k.empty?
+      if k.empty?
+        if !v.nil? && name == "[]".freeze
+          return Array(v)
+        else
+          return
+        end
+      end
 
       if after == ''.freeze
         params[k] = v
@@ -96,7 +102,8 @@ module Rack
         child_key = $1
         params[k] ||= []
         raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
-        if params_hash_type?(params[k].last) && !params[k].last.key?(child_key)
+        first_key = child_key.gsub(/[\[\]]/, ' ').split.first
+        if params_hash_type?(params[k].last) && !params[k].last.key?(first_key)
           normalize_params(params[k].last, child_key, v, depth - 1)
         else
           params[k] << normalize_params(make_params, child_key, v, depth - 1)
diff --git a/lib/rack/request.rb b/lib/rack/request.rb
index a76f15cd..5bf3eb17 100644
--- a/lib/rack/request.rb
+++ b/lib/rack/request.rb
@@ -16,23 +16,6 @@ module Rack
       super(env)
     end
 
-    # shortcut for <tt>request.params[key]</tt>
-    def [](key)
-      params[key.to_s]
-    end
-
-    # shortcut for <tt>request.params[key] = value</tt>
-    #
-    # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params.
-    def []=(key, value)
-      params[key.to_s] = value
-    end
-
-    # like Hash#values_at
-    def values_at(*keys)
-      keys.map{|key| params[key] }
-    end
-
     def params
       @params ||= super
     end
@@ -437,6 +420,31 @@ module Rack
         ip =~ /\A127\.0\.0\.1\Z|\A(10|172\.(1[6-9]|2[0-9]|30|31)|192\.168)\.|\A::1\Z|\Afd[0-9a-f]{2}:.+|\Alocalhost\Z|\Aunix\Z|\Aunix:/i
       end
 
+      # shortcut for <tt>request.params[key]</tt>
+      def [](key)
+        if $verbose
+          warn("Request#[] is deprecated and will be removed in a future version of Rack. Please use request.params[] instead")
+        end
+
+        params[key.to_s]
+      end
+
+      # shortcut for <tt>request.params[key] = value</tt>
+      #
+      # Note that modifications will not be persisted in the env. Use update_param or delete_param if you want to destructively modify params.
+      def []=(key, value)
+        if $verbose
+          warn("Request#[]= is deprecated and will be removed in a future version of Rack. Please use request.params[]= instead")
+        end
+
+        params[key.to_s] = value
+      end
+
+      # like Hash#values_at
+      def values_at(*keys)
+        keys.map { |key| params[key] }
+      end
+
       private
 
       def default_session; {}; end
diff --git a/lib/rack/urlmap.rb b/lib/rack/urlmap.rb
index 572b2151..510b4b50 100644
--- a/lib/rack/urlmap.rb
+++ b/lib/rack/urlmap.rb
@@ -47,11 +47,13 @@ module Rack
       server_name = env[SERVER_NAME]
       server_port = env[SERVER_PORT]
 
+      is_same_server = casecmp?(http_host, server_name) ||
+                       casecmp?(http_host, "#{server_name}:#{server_port}")
+
       @mapping.each do |host, location, match, app|
         unless casecmp?(http_host, host) \
             || casecmp?(server_name, host) \
-            || (!host && (casecmp?(http_host, server_name) ||
-                          casecmp?(http_host, "#{server_name}:#{server_port}")))
+            || (!host && is_same_server)
           next
         end
 
diff --git a/lib/rack/utils.rb b/lib/rack/utils.rb
index d541608a..7b842125 100644
--- a/lib/rack/utils.rb
+++ b/lib/rack/utils.rb
@@ -248,13 +248,23 @@ module Rack
           rfc2822(value[:expires].clone.gmtime) if value[:expires]
         secure = "; secure"  if value[:secure]
         httponly = "; HttpOnly" if (value.key?(:httponly) ? value[:httponly] : value[:http_only])
-        first_party = "; First-Party" if value[:first_party]
+        same_site =
+          case value[:same_site]
+          when false, nil
+            nil
+          when :lax, 'Lax', :Lax
+            '; SameSite=Lax'.freeze
+          when true, :strict, 'Strict', :Strict
+            '; SameSite=Strict'.freeze
+          else
+            raise ArgumentError, "Invalid SameSite value: #{value[:same_site].inspect}"
+          end
         value = value[:value]
       end
       value = [value] unless Array === value
 
       cookie = "#{escape(key)}=#{value.map { |v| escape v }.join('&')}#{domain}" \
-        "#{path}#{max_age}#{expires}#{secure}#{httponly}#{first_party}"
+        "#{path}#{max_age}#{expires}#{secure}#{httponly}#{same_site}"
 
       case header
       when nil, ''
@@ -599,5 +609,12 @@ module Rack
     end
     module_function :clean_path_info
 
+    NULL_BYTE = "\0".freeze
+
+    def valid_path?(path)
+      path.valid_encoding? && !path.include?(NULL_BYTE)
+    end
+    module_function :valid_path?
+
   end
 end
diff --git a/test/helper.rb b/test/helper.rb
index 64f6d30f..aa9c0e0a 100644
--- a/test/helper.rb
+++ b/test/helper.rb
@@ -2,7 +2,10 @@ require 'minitest/autorun'
 
 module Rack
   class TestCase < Minitest::Test
-    if `which lighttpd` && !$?.success?
+    # Check for Lighttpd and launch it for tests if available.
+    `which lighttpd`
+
+    if $?.success?
       begin
         # Keep this first.
         LIGHTTPD_PID = fork {
diff --git a/test/spec_directory.rb b/test/spec_directory.rb
index 1a97e9e5..42bdea9f 100644
--- a/test/spec_directory.rb
+++ b/test/spec_directory.rb
@@ -63,6 +63,13 @@ describe Rack::Directory do
     assert_match(res, /passed!/)
   end
 
+  it "serve uri with URL encoded null byte (%00) in filenames" do
+    res = Rack::MockRequest.new(Rack::Lint.new(app))
+      .get("/cgi/test%00")
+
+    res.must_be :bad_request?
+  end
+
   it "not allow directory traversal" do
     res = Rack::MockRequest.new(Rack::Lint.new(app)).
       get("/cgi/../test")
@@ -130,4 +137,12 @@ describe Rack::Directory do
     res = mr.get("/script-path/cgi/test+directory/test+file")
     res.must_be :ok?
   end
+
+  it "return error when file not found for head request" do
+    res = Rack::MockRequest.new(Rack::Lint.new(app)).
+      head("/cgi/missing")
+
+    res.must_be :not_found?
+    res.body.must_be :empty?
+  end
 end
diff --git a/test/spec_etag.rb b/test/spec_etag.rb
index 03680602..10ee2bd0 100644
--- a/test/spec_etag.rb
+++ b/test/spec_etag.rb
@@ -22,13 +22,13 @@ describe Rack::ETag do
   it "set ETag if none is set if status is 200" do
     app = lambda { |env| [200, {'Content-Type' => 'text/plain'}, ["Hello, World!"]] }
     response = etag(app).call(request)
-    response[1]['ETag'].must_equal "W/\"65a8e27d8879283831b664bd8b7f0ad4\""
+    response[1]['ETag'].must_equal "W/\"dffd6021bb2bd5b0af676290809ec3a5\""
   end
 
   it "set ETag if none is set if status is 201" do
     app = lambda { |env| [201, {'Content-Type' => 'text/plain'}, ["Hello, World!"]] }
     response = etag(app).call(request)
-    response[1]['ETag'].must_equal "W/\"65a8e27d8879283831b664bd8b7f0ad4\""
+    response[1]['ETag'].must_equal "W/\"dffd6021bb2bd5b0af676290809ec3a5\""
   end
 
   it "set Cache-Control to 'max-age=0, private, must-revalidate' (default) if none is set" do
diff --git a/test/spec_file.rb b/test/spec_file.rb
index 2d0919a9..3106e629 100644
--- a/test/spec_file.rb
+++ b/test/spec_file.rb
@@ -68,6 +68,11 @@ describe Rack::File do
     assert_match(res, /ruby/)
   end
 
+  it "serve uri with URL encoded null byte (%00) in filenames" do
+    res = Rack::MockRequest.new(file(DOCROOT)).get("/cgi/test%00")
+    res.must_be :bad_request?
+  end
+
   it "allow safe directory traversal" do
     req = Rack::MockRequest.new(file(DOCROOT))
 
@@ -237,4 +242,10 @@ describe Rack::File do
     res['Content-Type'].must_equal nil
   end
 
+  it "return error when file not found for head request" do
+    res = Rack::MockRequest.new(file(DOCROOT)).head("/cgi/missing")
+    res.must_be :not_found?
+    res.body.must_be :empty?
+  end
+
 end
diff --git a/test/spec_response.rb b/test/spec_response.rb
index f1028826..02e51435 100644
--- a/test/spec_response.rb
+++ b/test/spec_response.rb
@@ -115,16 +115,66 @@ describe Rack::Response do
     response["Set-Cookie"].must_equal "foo=bar"
   end
 
-  it "can set First-Party cookies" do
+  it "can set SameSite cookies with symbol value :lax" do
     response = Rack::Response.new
-    response.set_cookie "foo", {:value => "bar", :first_party => true}
-    response["Set-Cookie"].must_equal "foo=bar; First-Party"
+    response.set_cookie "foo", {:value => "bar", :same_site => :lax}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Lax"
+  end
+
+  it "can set SameSite cookies with symbol value :Lax" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => :lax}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Lax"
+  end
+
+  it "can set SameSite cookies with string value 'Lax'" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => "Lax"}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Lax"
+  end
+
+  it "can set SameSite cookies with boolean value true" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => true}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Strict"
+  end
+
+  it "can set SameSite cookies with symbol value :strict" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => :strict}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Strict"
+  end
+
+  it "can set SameSite cookies with symbol value :Strict" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => :Strict}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Strict"
+  end
+
+  it "can set SameSite cookies with string value 'Strict'" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => "Strict"}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Strict"
+  end
+
+  it "validates the SameSite option value" do
+    response = Rack::Response.new
+    lambda {
+      response.set_cookie "foo", {:value => "bar", :same_site => "Foo"}
+    }.must_raise(ArgumentError).
+      message.must_match(/Invalid SameSite value: "Foo"/)
+  end
+
+  it "can set SameSite cookies with symbol value" do
+    response = Rack::Response.new
+    response.set_cookie "foo", {:value => "bar", :same_site => :Strict}
+    response["Set-Cookie"].must_equal "foo=bar; SameSite=Strict"
   end
 
   [ nil, false ].each do |non_truthy|
-    it "omits First-Party attribute given a #{non_truthy.inspect} value" do
+    it "omits SameSite attribute given a #{non_truthy.inspect} value" do
       response = Rack::Response.new
-      response.set_cookie "foo", {:value => "bar", :first_party => non_truthy}
+      response.set_cookie "foo", {:value => "bar", :same_site => non_truthy}
       response["Set-Cookie"].must_equal "foo=bar"
     end
   end
diff --git a/test/spec_utils.rb b/test/spec_utils.rb
index 17a12115..b24762c9 100644
--- a/test/spec_utils.rb
+++ b/test/spec_utils.rb
@@ -206,6 +206,14 @@ describe Rack::Utils do
     Rack::Utils.parse_nested_query("x[y][][z]=1&x[y][][w]=a&x[y][][z]=2&x[y][][w]=3").
       must_equal "x" => {"y" => [{"z" => "1", "w" => "a"}, {"z" => "2", "w" => "3"}]}
 
+    Rack::Utils.parse_nested_query("x[][y]=1&x[][z][w]=a&x[][y]=2&x[][z][w]=b").
+      must_equal "x" => [{"y" => "1", "z" => {"w" => "a"}}, {"y" => "2", "z" => {"w" => "b"}}]
+    Rack::Utils.parse_nested_query("x[][z][w]=a&x[][y]=1&x[][z][w]=b&x[][y]=2").
+      must_equal "x" => [{"y" => "1", "z" => {"w" => "a"}}, {"y" => "2", "z" => {"w" => "b"}}]
+
+    Rack::Utils.parse_nested_query("data[books][][data][page]=1&data[books][][data][page]=2").
+      must_equal "data" => { "books" => [{ "data" => { "page" => "1"}}, { "data" => { "page" => "2"}}] }
+
     lambda { Rack::Utils.parse_nested_query("x[y]=1&x[y]z=2") }.
       must_raise(Rack::Utils::ParameterTypeError).
       message.must_equal "expected Hash (got String) for param `y'"
@@ -300,13 +308,15 @@ describe Rack::Utils do
       must_equal 'x[y][][z]=1&x[y][][z]=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'
+    Rack::Utils.build_nested_query({"foo" => ["1", ["2"]]}).
+      must_equal 'foo[]=1&foo[][]=2'
 
     lambda { Rack::Utils.build_nested_query("foo=bar") }.
       must_raise(ArgumentError).
       message.must_equal "value must be a Hash"
   end
 
-  should 'perform the inverse function of #parse_nested_query' do
+  it 'performs the inverse function of #parse_nested_query' do
     [{"foo" => nil, "bar" => ""},
       {"foo" => "bar", "baz" => ""},
       {"foo" => ["1", "2"]},
@@ -323,7 +333,8 @@ describe Rack::Utils do
       {"x" => {"y" => [{"v" => {"w" => "1"}}]}},
       {"x" => {"y" => [{"z" => "1", "v" => {"w" => "2"}}]}},
       {"x" => {"y" => [{"z" => "1"}, {"z" => "2"}]}},
-      {"x" => {"y" => [{"z" => "1", "w" => "a"}, {"z" => "2", "w" => "3"}]}}
+      {"x" => {"y" => [{"z" => "1", "w" => "a"}, {"z" => "2", "w" => "3"}]}},
+      {"foo" => ["1", ["2"]]},
     ].each { |params|
       qs = Rack::Utils.build_nested_query(params)
       Rack::Utils.parse_nested_query(qs).must_equal params
diff --git a/test/spec_webrick.rb b/test/spec_webrick.rb
index 8e0360d2..9ae6103d 100644
--- a/test/spec_webrick.rb
+++ b/test/spec_webrick.rb
@@ -17,6 +17,19 @@ describe Rack::Handler::WEBrick do
     Rack::Lint.new(TestRequest.new)
   @thread = Thread.new { @server.start }
   trap(:INT) { @server.shutdown }
+  @status_thread = Thread.new do
+    seconds = 10
+    wait_time = 0.1
+    until is_running? || seconds <= 0
+      seconds -= wait_time
+      sleep wait_time
+    end
+    raise "Server never reached status 'Running'" unless is_running?
+  end
+  end
+
+  def is_running?
+    @server.status == :Running
   end
 
   it "respond" do
@@ -188,6 +201,7 @@ describe Rack::Handler::WEBrick do
   end
 
   after do
+  @status_thread.join
   @server.shutdown
   @thread.join
   end