Skip to content

Commit ae60fce

Browse files
committed
Maintain Rack 2 parameter parsing behaviour
When running under Rack 3, we'll proceed with the new behaviour. That means we won't affect applications that already use Rack 3, but does also mean we can't help an application that is simultaneously bumping Rails and Rack together. However, with this commit, we at least ensure we maintain compatibility for applications that are currently still on Rack 2 -- and we can help with the transition using deprecation warnings. Fixes rails#53394
1 parent e642926 commit ae60fce

File tree

5 files changed

+190
-10
lines changed

5 files changed

+190
-10
lines changed

actionpack/lib/action_dispatch/http/param_builder.rb

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,10 @@
22

33
module ActionDispatch
44
class ParamBuilder
5+
# --
6+
# This implementation is based on Rack::QueryParser,
7+
# Copyright (C) 2007-2021 Leah Neukirchen <http://leahneukirchen.org/infopage.html>
8+
59
def self.make_default(param_depth_limit)
610
new param_depth_limit
711
end
@@ -12,6 +16,10 @@ def initialize(param_depth_limit)
1216
@param_depth_limit = param_depth_limit
1317
end
1418

19+
cattr_accessor :ignore_leading_brackets
20+
21+
LEADING_BRACKETS_COMPAT = defined?(::Rack::RELEASE) && ::Rack::RELEASE.to_s.start_with?("2.")
22+
1523
cattr_accessor :default
1624
self.default = make_default(100)
1725

@@ -61,15 +69,30 @@ def store_nested_param(params, name, v, depth, encoding_template = nil)
6169
# nil name, treat same as empty string (required by tests)
6270
k = after = ""
6371
elsif depth == 0
64-
# Start of parsing, don't treat [] or [ at start of string specially
65-
if start = name.index("[", 1)
66-
# Start of parameter nesting, use part before brackets as key
67-
k = name[0, start]
68-
after = name[start, name.length]
72+
if ignore_leading_brackets || (ignore_leading_brackets.nil? && LEADING_BRACKETS_COMPAT)
73+
# Rack 2 compatible behavior, ignore leading brackets
74+
if name =~ /\A[\[\]]*([^\[\]]+)\]*/
75+
k = $1
76+
after = $' || ""
77+
78+
if !ignore_leading_brackets && (k != $& || !after.empty? && !after.start_with?("["))
79+
ActionDispatch.deprecator.warn("Skipping over leading brackets in parameter name #{name.inspect} is deprecated and will parse differently in Rails 8.1 or Rack 3.0.")
80+
end
81+
else
82+
k = name
83+
after = ""
84+
end
6985
else
70-
# Plain parameter with no nesting
71-
k = name
72-
after = ""
86+
# Start of parsing, don't treat [] or [ at start of string specially
87+
if start = name.index("[", 1)
88+
# Start of parameter nesting, use part before brackets as key
89+
k = name[0, start]
90+
after = name[start, name.length]
91+
else
92+
# Plain parameter with no nesting
93+
k = name
94+
after = ""
95+
end
7396
end
7497
elsif name.start_with?("[]")
7598
# Array nesting

actionpack/lib/action_dispatch/http/query_parser.rb

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,17 @@
11
# frozen_string_literal: true
22

33
require "uri"
4+
require "rack"
45

56
module ActionDispatch
67
class QueryParser
78
DEFAULT_SEP = /& */n
8-
COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n }
9+
COMPAT_SEP = /[&;] */n
10+
COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n, "&;" => /[&;] */n }
11+
12+
cattr_accessor :strict_query_string_separator
13+
14+
SEMICOLON_COMPAT = defined?(::Rack::QueryParser::DEFAULT_SEP) && ::Rack::QueryParser::DEFAULT_SEP.to_s.include?(";")
915

1016
#--
1117
# Note this departs from WHATWG's specified parsing algorithm by
@@ -14,7 +20,23 @@ class QueryParser
1420
def self.each_pair(s, separator = nil)
1521
return enum_for(:each_pair, s, separator) unless block_given?
1622

17-
(s || "").split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |part|
23+
s ||= ""
24+
25+
splitter =
26+
if separator
27+
COMMON_SEP[separator] || /[#{separator}] */n
28+
elsif strict_query_string_separator
29+
DEFAULT_SEP
30+
elsif SEMICOLON_COMPAT && s.include?(";")
31+
if strict_query_string_separator.nil?
32+
ActionDispatch.deprecator.warn("Using semicolon as a query string separator is deprecated and will not be supported in Rails 8.1 or Rack 3.0. Use `&` instead.")
33+
end
34+
COMPAT_SEP
35+
else
36+
DEFAULT_SEP
37+
end
38+
39+
s.split(splitter).each do |part|
1840
next if part.empty?
1941

2042
k, v = part.split("=", 2)

actionpack/lib/action_dispatch/railtie.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,9 @@ class Railtie < Rails::Railtie # :nodoc:
3131
config.action_dispatch.debug_exception_log_level = :fatal
3232
config.action_dispatch.strict_freshness = false
3333

34+
config.action_dispatch.ignore_leading_brackets = nil
35+
config.action_dispatch.strict_query_string_separator = nil
36+
3437
config.action_dispatch.default_headers = {
3538
"X-Frame-Options" => "SAMEORIGIN",
3639
"X-XSS-Protection" => "1; mode=block",
@@ -52,6 +55,9 @@ class Railtie < Rails::Railtie # :nodoc:
5255
ActionDispatch::Http::URL.secure_protocol = app.config.force_ssl
5356
ActionDispatch::Http::URL.tld_length = app.config.action_dispatch.tld_length
5457

58+
ActionDispatch::ParamBuilder.ignore_leading_brackets = app.config.action_dispatch.ignore_leading_brackets
59+
ActionDispatch::QueryParser.strict_query_string_separator = app.config.action_dispatch.strict_query_string_separator
60+
5561
ActiveSupport.on_load(:action_dispatch_request) do
5662
self.ignore_accept_header = app.config.action_dispatch.ignore_accept_header
5763
ActionDispatch::Request::Utils.perform_deep_munge = app.config.action_dispatch.perform_deep_munge
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
# frozen_string_literal: true
2+
3+
require "abstract_unit"
4+
5+
class ParamBuilderTest < ActiveSupport::TestCase
6+
# Much of the behavioral details are covered by long-standing
7+
# integration tests in test/request/query_string_parsing_test.rb
8+
#
9+
# This test doesn't need to duplicate all of that: it just
10+
# offers a simple baseline of unit tests.
11+
12+
test "simple query string" do
13+
result = ActionDispatch::ParamBuilder.from_query_string("foo=bar&baz=quux")
14+
assert_equal({ "foo" => "bar", "baz" => "quux" }, result)
15+
assert_instance_of ActiveSupport::HashWithIndifferentAccess, result
16+
end
17+
18+
test "nested parameters" do
19+
result = ActionDispatch::ParamBuilder.from_query_string("foo[bar]=baz")
20+
assert_equal({ "foo" => { "bar" => "baz" } }, result)
21+
assert_instance_of ActiveSupport::HashWithIndifferentAccess, result[:foo]
22+
end
23+
24+
if ::Rack::RELEASE.start_with?("2.")
25+
test "(rack 2) defaults to ignoring leading bracket" do
26+
assert_deprecated(ActionDispatch.deprecator) do
27+
result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar")
28+
assert_equal({ "foo" => "bar" }, result)
29+
end
30+
31+
assert_deprecated(ActionDispatch.deprecator) do
32+
result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz")
33+
assert_equal({ "foo" => { "bar" => "baz" } }, result)
34+
end
35+
end
36+
else
37+
test "(rack 3) defaults to retaining leading bracket" do
38+
result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar")
39+
assert_equal({ "[foo]" => "bar" }, result)
40+
41+
result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz")
42+
assert_equal({ "[foo]" => { "bar" => "baz" } }, result)
43+
end
44+
end
45+
46+
test "configured for strict brackets" do
47+
previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets
48+
ActionDispatch::ParamBuilder.ignore_leading_brackets = false
49+
50+
result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar")
51+
assert_equal({ "[foo]" => "bar" }, result)
52+
53+
result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz")
54+
assert_equal({ "[foo]" => { "bar" => "baz" } }, result)
55+
ensure
56+
ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets
57+
end
58+
59+
test "configured for ignoring leading brackets" do
60+
previous_brackets = ActionDispatch::ParamBuilder.ignore_leading_brackets
61+
ActionDispatch::ParamBuilder.ignore_leading_brackets = true
62+
63+
result = ActionDispatch::ParamBuilder.from_query_string("[foo]=bar")
64+
assert_equal({ "foo" => "bar" }, result)
65+
66+
result = ActionDispatch::ParamBuilder.from_query_string("[foo][bar]=baz")
67+
assert_equal({ "foo" => { "bar" => "baz" } }, result)
68+
ensure
69+
ActionDispatch::ParamBuilder.ignore_leading_brackets = previous_brackets
70+
end
71+
end
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
# frozen_string_literal: true
2+
3+
require "abstract_unit"
4+
5+
class QueryParserTest < ActiveSupport::TestCase
6+
test "simple query string" do
7+
assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar&baz=quux")
8+
end
9+
10+
test "query string with empty and missing values" do
11+
assert_equal [["foo", "bar"], ["empty", ""], ["missing", nil], ["baz", "quux"]], parsed_pairs("foo=bar&empty=&missing&baz=quux")
12+
end
13+
14+
test "custom separator" do
15+
assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar;baz=quux", ";")
16+
end
17+
18+
test "non-standard separator" do
19+
assert_equal [["foo", "bar"], ["baz", "quux"]], parsed_pairs("foo=bar/baz=quux", "/")
20+
end
21+
22+
test "mixed separators" do
23+
assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;")
24+
end
25+
26+
if ::Rack::RELEASE.start_with?("2.")
27+
test "(rack 2) defaults to mixed separators" do
28+
assert_deprecated(ActionDispatch.deprecator) do
29+
assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc")
30+
end
31+
end
32+
else
33+
test "(rack 3) defaults to ampersand separator only" do
34+
assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc")
35+
end
36+
end
37+
38+
test "configured for strict separator" do
39+
previous_separator = ActionDispatch::QueryParser.strict_query_string_separator
40+
ActionDispatch::QueryParser.strict_query_string_separator = true
41+
assert_equal [["a", "aa"], ["b", "bb;c=cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&")
42+
ensure
43+
ActionDispatch::QueryParser.strict_query_string_separator = previous_separator
44+
end
45+
46+
test "configured for mixed separator" do
47+
previous_separator = ActionDispatch::QueryParser.strict_query_string_separator
48+
ActionDispatch::QueryParser.strict_query_string_separator = false
49+
assert_equal [["a", "aa"], ["b", "bb"], ["c", "cc"]], parsed_pairs("a=aa&b=bb;c=cc", "&;")
50+
ensure
51+
ActionDispatch::QueryParser.strict_query_string_separator = previous_separator
52+
end
53+
54+
private
55+
def parsed_pairs(query, separator = nil)
56+
ActionDispatch::QueryParser.each_pair(query, separator).to_a
57+
end
58+
end

0 commit comments

Comments
 (0)