Skip to content

Commit ce5f181

Browse files
committed
Do more request parameter parsing ourselves
We still rely on Rack to actually read the input stream, but by constructing the parameter hashes ourselves, we restore our ability to extend the meaning of parameter-name formatting in the future, while also concentrating all our preprocessing into a single walk. This is an important step in defining a more consistent boundary to our Rack dependency, allowing them to evolve their behaviour with less chance of creating some edge-case incompatibility.
1 parent de1e7ad commit ce5f181

File tree

8 files changed

+297
-31
lines changed

8 files changed

+297
-31
lines changed

actionpack/lib/action_dispatch.rb

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,13 @@ class MissingController < NameError
5353
eager_autoload do
5454
autoload_under "http" do
5555
autoload :ContentSecurityPolicy
56+
autoload :InvalidParameterError, "action_dispatch/http/param_error"
57+
autoload :ParamBuilder
58+
autoload :ParamError
59+
autoload :ParameterTypeError, "action_dispatch/http/param_error"
60+
autoload :ParamsTooDeepError, "action_dispatch/http/param_error"
5661
autoload :PermissionsPolicy
62+
autoload :QueryParser
5763
autoload :Request
5864
autoload :Response
5965
end
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
# frozen_string_literal: true
2+
3+
module ActionDispatch
4+
class ParamBuilder
5+
def self.make_default(param_depth_limit)
6+
new param_depth_limit
7+
end
8+
9+
attr_reader :param_depth_limit
10+
11+
def initialize(param_depth_limit)
12+
@param_depth_limit = param_depth_limit
13+
end
14+
15+
cattr_accessor :default
16+
self.default = make_default(100)
17+
18+
class << self
19+
delegate :from_query_string, :from_pairs, :from_hash, to: :default
20+
end
21+
22+
def from_query_string(qs, separator: nil, encoding_template: nil)
23+
from_pairs QueryParser.each_pair(qs, separator), encoding_template: encoding_template
24+
end
25+
26+
def from_pairs(pairs, encoding_template: nil)
27+
params = make_params
28+
29+
pairs.each do |k, v|
30+
if Hash === v
31+
v = ActionDispatch::Http::UploadedFile.new(v)
32+
end
33+
34+
store_nested_param(params, k, v, 0, encoding_template)
35+
end
36+
37+
params
38+
rescue ArgumentError => e
39+
raise InvalidParameterError, e.message, e.backtrace
40+
end
41+
42+
def from_hash(hash, encoding_template: nil)
43+
# Force encodings from encoding template
44+
hash = Request::Utils::CustomParamEncoder.encode_for_template(hash, encoding_template)
45+
46+
# Assert valid encoding
47+
Request::Utils.check_param_encoding(hash)
48+
49+
# Convert hashes to HWIA (or UploadedFile), and deep-munge nils
50+
# out of arrays
51+
hash = Request::Utils.normalize_encode_params(hash)
52+
53+
hash
54+
end
55+
56+
private
57+
def store_nested_param(params, name, v, depth, encoding_template = nil)
58+
raise ParamsTooDeepError if depth >= param_depth_limit
59+
60+
if !name
61+
# nil name, treat same as empty string (required by tests)
62+
k = after = ""
63+
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]
69+
else
70+
# Plain parameter with no nesting
71+
k = name
72+
after = ""
73+
end
74+
elsif name.start_with?("[]")
75+
# Array nesting
76+
k = "[]"
77+
after = name[2, name.length]
78+
elsif name.start_with?("[") && (start = name.index("]", 1))
79+
# Hash nesting, use the part inside brackets as the key
80+
k = name[1, start - 1]
81+
after = name[start + 1, name.length]
82+
else
83+
# Probably malformed input, nested but not starting with [
84+
# treat full name as key for backwards compatibility.
85+
k = name
86+
after = ""
87+
end
88+
89+
return if k.empty?
90+
91+
if depth == 0 && String === v
92+
# We have to wait until we've found the top part of the name,
93+
# because that's what the encoding template is configured with
94+
if encoding_template && (designated_encoding = encoding_template[k]) && !v.frozen?
95+
v.force_encoding(designated_encoding)
96+
end
97+
98+
# ... and we can't validate the encoding until after we've
99+
# applied any template override
100+
unless v.valid_encoding?
101+
raise InvalidParameterError, "Invalid encoding for parameter: #{v.scrub}"
102+
end
103+
end
104+
105+
if after == ""
106+
if k == "[]" && depth != 0
107+
return (v || !ActionDispatch::Request::Utils.perform_deep_munge) ? [v] : []
108+
else
109+
params[k] = v
110+
end
111+
elsif after == "["
112+
params[name] = v
113+
elsif after == "[]"
114+
params[k] ||= []
115+
raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
116+
params[k] << v if v || !ActionDispatch::Request::Utils.perform_deep_munge
117+
elsif after.start_with?("[]")
118+
# Recognize x[][y] (hash inside array) parameters
119+
unless after[2] == "[" && after.end_with?("]") && (child_key = after[3, after.length - 4]) && !child_key.empty? && !child_key.index("[") && !child_key.index("]")
120+
# Handle other nested array parameters
121+
child_key = after[2, after.length]
122+
end
123+
params[k] ||= []
124+
raise ParameterTypeError, "expected Array (got #{params[k].class.name}) for param `#{k}'" unless params[k].is_a?(Array)
125+
if params_hash_type?(params[k].last) && !params_hash_has_key?(params[k].last, child_key)
126+
store_nested_param(params[k].last, child_key, v, depth + 1)
127+
else
128+
params[k] << store_nested_param(make_params, child_key, v, depth + 1)
129+
end
130+
else
131+
params[k] ||= make_params
132+
raise ParameterTypeError, "expected Hash (got #{params[k].class.name}) for param `#{k}'" unless params_hash_type?(params[k])
133+
params[k] = store_nested_param(params[k], after, v, depth + 1)
134+
end
135+
136+
params
137+
end
138+
139+
def make_params
140+
ActiveSupport::HashWithIndifferentAccess.new
141+
end
142+
143+
def new_depth_limit(param_depth_limit)
144+
self.class.new @params_class, param_depth_limit
145+
end
146+
147+
def params_hash_type?(obj)
148+
Hash === obj
149+
end
150+
151+
def params_hash_has_key?(hash, key)
152+
return false if key.include?("[]")
153+
154+
key.split(/[\[\]]+/).inject(hash) do |h, part|
155+
next h if part == ""
156+
return false unless params_hash_type?(h) && h.key?(part)
157+
h[part]
158+
end
159+
160+
true
161+
end
162+
end
163+
end
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# frozen_string_literal: true
2+
3+
module ActionDispatch
4+
class ParamError < ActionDispatch::Http::Parameters::ParseError
5+
def initialize(message = nil)
6+
super
7+
end
8+
9+
def self.===(other)
10+
super || (
11+
defined?(Rack::Utils::ParameterTypeError) && Rack::Utils::ParameterTypeError === other ||
12+
defined?(Rack::Utils::InvalidParameterError) && Rack::Utils::InvalidParameterError === other ||
13+
defined?(Rack::QueryParser::ParamsTooDeepError) && Rack::QueryParser::ParamsTooDeepError === other
14+
)
15+
end
16+
end
17+
18+
class ParameterTypeError < ParamError
19+
end
20+
21+
class InvalidParameterError < ParamError
22+
end
23+
24+
class ParamsTooDeepError < ParamError
25+
end
26+
end
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
# frozen_string_literal: true
2+
3+
require "uri"
4+
5+
module ActionDispatch
6+
class QueryParser
7+
DEFAULT_SEP = /& */n
8+
COMMON_SEP = { ";" => /; */n, ";," => /[;,] */n, "&" => /& */n }
9+
10+
#--
11+
# Note this departs from WHATWG's specified parsing algorithm by
12+
# giving a nil value for keys that do not use '='. Callers that need
13+
# the standard's interpretation can use `v.to_s`.
14+
def self.each_pair(s, separator = nil)
15+
return enum_for(:each_pair, s, separator) unless block_given?
16+
17+
(s || "").split(separator ? (COMMON_SEP[separator] || /[#{separator}] */n) : DEFAULT_SEP).each do |part|
18+
next if part.empty?
19+
20+
k, v = part.split("=", 2)
21+
22+
k = URI.decode_www_form_component(k)
23+
v &&= URI.decode_www_form_component(v)
24+
25+
yield k, v
26+
end
27+
28+
nil
29+
end
30+
end
31+
end

actionpack/lib/action_dispatch/http/request.rb

Lines changed: 56 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -63,6 +63,9 @@ def self.empty
6363

6464
def initialize(env)
6565
super
66+
67+
@rack_request = Rack::Request.new(env)
68+
6669
@method = nil
6770
@request_method = nil
6871
@remote_ip = nil
@@ -71,6 +74,8 @@ def initialize(env)
7174
@ip = nil
7275
end
7376

77+
attr_reader :rack_request
78+
7479
def commit_cookie_jar! # :nodoc:
7580
end
7681

@@ -388,34 +393,67 @@ def session_options=(options)
388393
# Override Rack's GET method to support indifferent access.
389394
def GET
390395
fetch_header("action_dispatch.request.query_parameters") do |k|
391-
rack_query_params = super || {}
392-
controller = path_parameters[:controller]
393-
action = path_parameters[:action]
394-
rack_query_params = Request::Utils.set_binary_encoding(self, rack_query_params, controller, action)
395-
# Check for non UTF-8 parameter values, which would cause errors later
396-
Request::Utils.check_param_encoding(rack_query_params)
397-
set_header k, Request::Utils.normalize_encode_params(rack_query_params)
396+
encoding_template = Request::Utils::CustomParamEncoder.action_encoding_template(self, path_parameters[:controller], path_parameters[:action])
397+
rack_query_params = ActionDispatch::ParamBuilder.from_query_string(rack_request.query_string, encoding_template: encoding_template)
398+
399+
set_header k, rack_query_params
398400
end
399-
rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError, Rack::QueryParser::ParamsTooDeepError => e
401+
rescue ActionDispatch::ParamError => e
400402
raise ActionController::BadRequest.new("Invalid query parameters: #{e.message}")
401403
end
402404
alias :query_parameters :GET
403405

404406
# Override Rack's POST method to support indifferent access.
405407
def POST
406408
fetch_header("action_dispatch.request.request_parameters") do
407-
pr = parse_formatted_parameters(params_parsers) do |params|
408-
super || {}
409+
encoding_template = Request::Utils::CustomParamEncoder.action_encoding_template(self, path_parameters[:controller], path_parameters[:action])
410+
411+
param_list = nil
412+
pr = parse_formatted_parameters(params_parsers) do
413+
if param_list = request_parameters_list
414+
ActionDispatch::ParamBuilder.from_pairs(param_list, encoding_template: encoding_template)
415+
else
416+
# We're not using a version of Rack that provides raw form
417+
# pairs; we must use its hash (and thus post-process it below).
418+
fallback_request_parameters
419+
end
420+
end
421+
422+
# If the request body was parsed by a custom parser like JSON
423+
# (and thus the above block was not run), we need to
424+
# post-process the result hash.
425+
if param_list.nil?
426+
pr = ActionDispatch::ParamBuilder.from_hash(pr, encoding_template: encoding_template)
409427
end
410-
pr = Request::Utils.set_binary_encoding(self, pr, path_parameters[:controller], path_parameters[:action])
411-
Request::Utils.check_param_encoding(pr)
412-
self.request_parameters = Request::Utils.normalize_encode_params(pr)
428+
429+
self.request_parameters = pr
413430
end
414-
rescue Rack::Utils::ParameterTypeError, Rack::Utils::InvalidParameterError, Rack::QueryParser::ParamsTooDeepError, EOFError => e
431+
rescue ActionDispatch::ParamError, EOFError => e
415432
raise ActionController::BadRequest.new("Invalid request parameters: #{e.message}")
416433
end
417434
alias :request_parameters :POST
418435

436+
def request_parameters_list
437+
# We don't use Rack's parse result, but we must call it so Rack
438+
# can populate the rack.request.* keys we need.
439+
rack_post = rack_request.POST
440+
441+
if form_pairs = get_header("rack.request.form_pairs")
442+
# Multipart
443+
form_pairs
444+
elsif form_vars = get_header("rack.request.form_vars")
445+
# URL-encoded
446+
ActionDispatch::QueryParser.each_pair(form_vars)
447+
elsif rack_post && !rack_post.empty?
448+
# It was multipart, but Rack did not preserve a pair list
449+
# (probably too old). Flat parameter list is not available.
450+
nil
451+
else
452+
# No request body, or not a format Rack knows
453+
[]
454+
end
455+
end
456+
419457
# Returns the authorization header regardless of whether it was specified
420458
# directly or through one of the proxy alternatives.
421459
def authorization
@@ -492,6 +530,10 @@ def reset_stream(body_stream)
492530
yield
493531
end
494532
end
533+
534+
def fallback_request_parameters
535+
rack_request.POST
536+
end
495537
end
496538
end
497539

actionpack/lib/action_dispatch/request/utils.rb

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -83,8 +83,8 @@ def self.handle_array(params)
8383
end
8484

8585
class CustomParamEncoder # :nodoc:
86-
def self.encode(request, params, controller, action)
87-
return params unless encoding_template = action_encoding_template(request, controller, action)
86+
def self.encode_for_template(params, encoding_template)
87+
return params unless encoding_template
8888
params.except(:controller, :action).each do |key, value|
8989
ActionDispatch::Request::Utils.each_param_value(value) do |param|
9090
# If `param` is frozen, it comes from the router defaults
@@ -98,6 +98,11 @@ def self.encode(request, params, controller, action)
9898
params
9999
end
100100

101+
def self.encode(request, params, controller, action)
102+
encoding_template = action_encoding_template(request, controller, action)
103+
encode_for_template(params, encoding_template)
104+
end
105+
101106
def self.action_encoding_template(request, controller, action) # :nodoc:
102107
controller && controller.valid_encoding? &&
103108
request.controller_class_for(controller).action_encoding_template(action)

0 commit comments

Comments
 (0)