Skip to content

Commit a235f20

Browse files
committed
Ensure we only ever call Rack query parsing when we must
It's fine for applications / 3rd party middleware to do so: this is just enforcing framework-internal consistency.
1 parent 395f44d commit a235f20

File tree

3 files changed

+94
-1
lines changed

3 files changed

+94
-1
lines changed

actionpack/test/abstract_unit.rb

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626
require "active_model"
2727
require "zeitwerk"
2828

29+
require_relative "support/rack_parsing_override"
30+
2931
ActiveSupport::Cache.format_version = 7.1
3032

3133
module Rails

actionpack/test/dispatch/request/query_string_parsing_test.rb

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,9 +18,13 @@ def initialize(app)
1818
@app = app
1919
end
2020

21+
def populate_rack_cache(env)
22+
Rack::Request.new(env).params
23+
end
24+
2125
def call(env)
2226
# Trigger a Rack parse so that env caches the query params
23-
Rack::Request.new(env).params
27+
populate_rack_cache(env)
2428
@app.call(env)
2529
end
2630
end
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
# frozen_string_literal: true
2+
3+
# Because we implement our own query string parsing, and it is extremely
4+
# similar to Rack's in most simple cases, it would be very easy to have
5+
# locations sneak in where we unknowingly depend on Rack's
6+
# interpretation rather than our own, potentially creating edge-case
7+
# incompatibilities.
8+
#
9+
# To counter that, we monkey-patch Rack in our tests to allow-list the
10+
# call sites that are known to be safe & appropriate.
11+
#
12+
# This file may need to change in response to future Rack changes. If
13+
# you're here because you're adding new code/tests to Rails, though, you
14+
# probably need to work out how to ensure you're using the Rails query
15+
# parser instead.
16+
module RackParsingOverride
17+
UnexpectedCall = Class.new(Exception)
18+
19+
# The only expected calls to Rack::QueryParser#parse_nested_query are
20+
# from Rack::Request#GET/POST, which are separately protected below.
21+
module ParserPatch
22+
def parse_nested_query(*)
23+
unless caller_locations.any? { |loc| loc.path == __FILE__ && (loc.lineno == RackParsingOverride::GET_LINE || loc.lineno == RackParsingOverride::POST_LINE) }
24+
raise UnexpectedCall, "Unexpected call to Rack::QueryParser#parse_nested_query"
25+
end
26+
super
27+
end
28+
end
29+
30+
# This is where we do the real checking, because we need to catch
31+
# every caller that might _use_ the cached result of Rack's parsing,
32+
# not just the first call site where parsing gets triggered.
33+
module RequestPatch
34+
# Single list of permitted callers -- we don't care about GET vs POST
35+
def self.permitted_caller?
36+
caller_locations.any? do |loc|
37+
# Our parser calls Rack's to prepopulate caches
38+
loc.path.end_with?("lib/action_dispatch/http/request.rb") && loc.label == "request_parameters_list" ||
39+
# and as a fallback for older Rack versions
40+
loc.path.end_with?("lib/action_dispatch/http/request.rb") && loc.label == "fallback_request_parameters" ||
41+
# This specifically tests that a "pure" Rack middleware
42+
# doesn't interfere with our parsing
43+
(loc.path.end_with?("test/dispatch/request/query_string_parsing_test.rb") && loc.label == "populate_rack_cache") ||
44+
# Rack::MethodOverride obviously uses Rack's parsing, and
45+
# that's fine: it's looking for a simple top-level key.
46+
# Checking for a specific internal method is fragile, but we
47+
# don't want to ignore any app that happens to have
48+
# MethodOverride on its call stack!
49+
(loc.path.end_with?("lib/rack/method_override.rb") && loc.label == "method_override_param")
50+
end
51+
end
52+
53+
def params
54+
unless RequestPatch.permitted_caller?
55+
raise UnexpectedCall, "Unexpected call to Rack::Request#params"
56+
end
57+
super
58+
end
59+
::RackParsingOverride::PARAMS_LINE = __LINE__ - 2
60+
61+
def GET
62+
unless RequestPatch.permitted_caller?
63+
raise UnexpectedCall, "Unexpected call to Rack::Request#GET"
64+
end
65+
super
66+
end
67+
::RackParsingOverride::GET_LINE = __LINE__ - 2
68+
69+
def POST
70+
unless RequestPatch.permitted_caller?
71+
raise UnexpectedCall, "Unexpected call to Rack::Request#POST"
72+
end
73+
super
74+
end
75+
::RackParsingOverride::POST_LINE = __LINE__ - 2
76+
end
77+
78+
Rack::QueryParser.class_eval do
79+
# Being careful here, as this is more internal
80+
unless method_defined?(:parse_nested_query)
81+
raise "Rack changed? Can't patch absent Rack::QueryParser#parse_nested_query"
82+
end
83+
prepend ParserPatch
84+
end
85+
86+
Rack::Request.prepend RequestPatch
87+
end

0 commit comments

Comments
 (0)