Skip to content

Commit 382f9d3

Browse files
committed
Harden API read parameter parsing
1 parent 5e2a46a commit 382f9d3

9 files changed

Lines changed: 259 additions & 12 deletions

File tree

‎lib/flipper/api/action.rb‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,42 @@ def existing_feature_names
163163
@existing_feature_names ||= flipper.features.map(&:key).to_set
164164
end
165165

166+
# Private: Returns request parameters, or an empty hash when Rack cannot
167+
# parse client-controlled parameter syntax.
168+
def safe_params
169+
@safe_params ||= params
170+
rescue *parameter_parser_errors
171+
@params_parse_failed = true
172+
@safe_params = {}
173+
end
174+
175+
def params_parse_failed?
176+
safe_params
177+
@params_parse_failed == true
178+
end
179+
180+
# Private: Returns a valid String parameter, ignoring other shapes and
181+
# invalid encodings.
182+
def string_param(name)
183+
value = safe_params[name]
184+
value if valid_param_string?(value)
185+
end
186+
187+
def valid_param_string?(value)
188+
value.is_a?(String) && value.valid_encoding?
189+
end
190+
191+
def parameter_parser_errors
192+
parsers = [Rack::Utils]
193+
parsers << Rack.const_get(:QueryParser, false) if Rack.const_defined?(:QueryParser, false)
194+
error_names = [:InvalidParameterError, :ParameterTypeError, :ParamsTooDeepError, :QueryLimitError]
195+
parsers.each_with_object([]) do |parser, errors|
196+
error_names.each do |name|
197+
errors << parser.const_get(name, false) if parser.const_defined?(name, false)
198+
end
199+
end.uniq
200+
end
201+
166202
# Private: Returns the request method converted to an action method.
167203
# Converts head to get.
168204
def request_method_name

‎lib/flipper/api/v1/actions/actors.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ class Actors < Api::Action
99
route %r{\A/actors/(?<flipper_id>.*)/?\Z}
1010

1111
def get
12-
keys = params['keys']
12+
keys = string_param('keys')
1313
features = if keys
1414
names = keys.split(',')
1515
if names.empty?

‎lib/flipper/api/v1/actions/feature.rb‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class Feature < Api::Action
1212

1313
def get
1414
return json_error_response(:feature_not_found) unless feature_exists?(feature_name)
15-
exclude_gates = params['exclude_gates']&.downcase == "true"
15+
exclude_gates = string_param('exclude_gates')&.downcase == "true"
1616
feature = Decorators::Feature.new(flipper[feature_name])
1717
json_response(feature.as_json(exclude_gates: exclude_gates))
1818
end

‎lib/flipper/api/v1/actions/features.rb‎

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,8 @@ class Features < Api::Action
1010

1111
def get
1212
names = requested_feature_names
13-
exclude_gates = params['exclude_gates']&.downcase == "true"
14-
exclude_gate_names = params['exclude_gate_names']&.downcase == "true"
13+
exclude_gates = query_string_param('exclude_gates')&.downcase == "true"
14+
exclude_gate_names = query_string_param('exclude_gate_names')&.downcase == "true"
1515

1616
features = if names
1717
if names.empty?
@@ -51,10 +51,13 @@ def post
5151
private
5252

5353
def requested_feature_names
54-
keys = params['keys']
55-
return keys.map(&:to_s) if keys.is_a?(Array)
54+
keys = safe_params['keys']
55+
return nil unless keys
56+
return nil unless valid_feature_keys?(keys)
57+
return keys if keys.is_a?(Array)
5658

5759
raw_keys = raw_query_values("keys")
60+
return nil unless raw_keys
5861
return decoded_feature_names if raw_keys.empty?
5962

6063
raw_keys.flat_map do |keys|
@@ -68,19 +71,45 @@ def requested_feature_names
6871
end
6972

7073
def decoded_feature_names
71-
keys = params['keys']
72-
return nil unless keys
73-
74-
Array(keys).flat_map { |key| key.to_s.split(',') }
74+
safe_params['keys'].split(',')
7575
end
7676

7777
def raw_query_values(name)
78-
request.query_string.to_s.split(/[&;]/).each_with_object([]) do |part, values|
78+
values = []
79+
request.query_string.to_s.split(/[&;]/).each do |part|
7980
next if part.empty?
8081

8182
key, value = part.split('=', 2)
82-
values << value.to_s if Rack::Utils.unescape(key) == name
83+
decoded_key = decoded_query_component(key)
84+
return nil unless decoded_key
85+
next unless decoded_key == name
86+
return nil unless decoded_query_component(value.to_s)
87+
88+
values << value.to_s
8389
end
90+
values
91+
end
92+
93+
def query_string_param(name)
94+
value = string_param(name)
95+
return value if value || safe_params.key?(name)
96+
return nil if params_parse_failed?
97+
98+
raw_values = raw_query_values(name)
99+
Rack::Utils.unescape(raw_values.last) if raw_values && !raw_values.empty?
100+
end
101+
102+
def valid_feature_keys?(keys)
103+
return valid_param_string?(keys) unless keys.is_a?(Array)
104+
105+
keys.all? { |key| valid_param_string?(key) }
106+
end
107+
108+
def decoded_query_component(value)
109+
decoded = Rack::Utils.unescape(value)
110+
decoded if decoded.valid_encoding?
111+
rescue ArgumentError
112+
nil
84113
end
85114
end
86115
end

‎spec/flipper/api/action_spec.rb‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,4 +107,14 @@ def delete
107107
end
108108
end
109109
end
110+
111+
describe 'safe parameters' do
112+
it 'does not classify unrelated application errors as parameter errors' do
113+
request = double('Request', request_method: 'GET')
114+
allow(request).to receive(:params).and_raise(ArgumentError, 'application failure')
115+
action = action_subclass.new(flipper, request)
116+
117+
expect { action.send(:safe_params) }.to raise_error(ArgumentError, 'application failure')
118+
end
119+
end
110120
end

‎spec/flipper/api/v1/actions/actors_spec.rb‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,27 @@
99
flipper[:my_feature_3].enable_actor(actor)
1010
end
1111

12+
malformed_queries = {
13+
'array-shaped keys' => 'keys[]=my_feature_1',
14+
'hash-shaped keys' => 'keys[a]=my_feature_1',
15+
'a bare percent escape' => 'keys=%',
16+
'an invalid percent escape' => 'keys=%GG',
17+
'invalid UTF-8' => 'keys=%FF',
18+
'conflicting parameter shapes' => 'a=1&a[]=2',
19+
'excessive nesting' => "a#{'[a]' * 150}=1",
20+
}
21+
22+
malformed_queries.each do |description, query|
23+
it "ignores #{description}" do
24+
expected_response = raw_api_get("/actors/#{actor.flipper_id}", '')
25+
26+
response = raw_api_get("/actors/#{actor.flipper_id}", query)
27+
28+
expect(response.first).to eq(200)
29+
expect(response).to eq(expected_response)
30+
end
31+
end
32+
1233
context 'when no feature is specified' do
1334
before do
1435
get "/actors/#{actor.flipper_id}"

‎spec/flipper/api/v1/actions/feature_spec.rb‎

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,40 @@
44
let(:gate) { feature.gate(:boolean) }
55

66
describe 'get' do
7+
malformed_queries = {
8+
'array-shaped exclude_gates' => 'exclude_gates[]=true',
9+
'hash-shaped exclude_gates' => 'exclude_gates[a]=true',
10+
'a bare percent escape' => 'exclude_gates=%',
11+
'an invalid percent escape' => 'exclude_gates=%GG',
12+
'invalid UTF-8' => 'exclude_gates=%FF',
13+
'conflicting parameter shapes' => 'a=1&a[]=2',
14+
'excessive nesting' => "a#{'[a]' * 150}=1",
15+
}
16+
17+
malformed_queries.each do |description, query|
18+
it "ignores #{description}" do
19+
flipper[:my_feature].enable
20+
expected_response = raw_api_get('/features/my_feature', '')
21+
22+
response = raw_api_get('/features/my_feature', query)
23+
24+
expect(response.first).to eq(200)
25+
expect(response).to eq(expected_response)
26+
end
27+
end
28+
29+
it 'responds without gates when instructed by param' do
30+
flipper[:my_feature].enable
31+
32+
status, _, body = raw_api_get('/features/my_feature', 'exclude_gates=true')
33+
34+
expect(status).to eq(200)
35+
expect(body).to eq(
36+
'key' => 'my_feature',
37+
'state' => 'on'
38+
)
39+
end
40+
741
context 'enabled feature' do
842
before do
943
flipper[:my_feature].enable

‎spec/flipper/api/v1/actions/features_spec.rb‎

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,100 @@
44
let(:admin) { double 'Fake Fliper Thing', flipper_id: 10 }
55

66
describe 'get' do
7+
malformed_queries = {
8+
'array-shaped exclude_gate_names' => 'exclude_gate_names[]=true',
9+
'hash-shaped exclude_gates' => 'exclude_gates[a]=true',
10+
'hash-shaped keys' => 'keys[a]=my_feature',
11+
'nested array-shaped keys' => 'keys[][]=my_feature',
12+
'a bare percent escape' => 'keys=%',
13+
'a truncated percent escape' => 'keys=%2',
14+
'an invalid percent escape in a value' => 'keys=%GG',
15+
'an invalid percent escape in a key' => '%GG=value',
16+
'invalid UTF-8 in a key' => '%FF=value&keys=my_feature',
17+
'invalid UTF-8' => 'keys=%FF',
18+
'conflicting parameter shapes' => 'a=1&a[]=2',
19+
'excessive nesting' => "a#{'[a]' * 150}=1",
20+
'excessive parameter count' => (1..5000).map { |index| "ignored#{index}=1" }.join('&'),
21+
}
22+
23+
malformed_queries.each do |description, query|
24+
it "fails open for #{description}" do
25+
flipper[:my_feature].enable
26+
expected_response = raw_api_get('/features', '')
27+
28+
response = raw_api_get('/features', query)
29+
30+
expect(response.first).to eq(200)
31+
expect(response).to eq(expected_response)
32+
end
33+
end
34+
35+
it 'fully fails open when malformed syntax is combined with a valid option' do
36+
flipper[:my_feature].enable
37+
expected_response = raw_api_get('/features', '')
38+
39+
response = raw_api_get('/features', 'a=1&a[]=2&exclude_gate_names=true')
40+
41+
expect(response.first).to eq(200)
42+
expect(response).to eq(expected_response)
43+
end
44+
45+
separator_queries = [
46+
'&exclude_gate_names=true',
47+
'&&exclude_gate_names=true&&',
48+
';exclude_gate_names=true',
49+
';;exclude_gate_names=true;;',
50+
'exclude_gate_names=true&&;;',
51+
]
52+
53+
separator_queries.each do |query|
54+
it "preserves optional parameter semantics with empty query segments in #{query.inspect}" do
55+
flipper[:my_feature].enable
56+
expected_response = raw_api_get('/features', 'exclude_gate_names=true')
57+
58+
response = raw_api_get('/features', query)
59+
60+
expect(response.first).to eq(200)
61+
expect(response).to eq(expected_response)
62+
end
63+
end
64+
65+
it 'fails open when decoding a raw query component raises ArgumentError' do
66+
request = instance_double(Rack::Request, query_string: 'keys=%')
67+
action = described_class.new(flipper, request)
68+
allow(action).to receive(:safe_params).and_return('keys' => 'my_feature')
69+
allow(Rack::Utils).to receive(:unescape).with('keys').and_return('keys')
70+
allow(Rack::Utils).to receive(:unescape).with('%').and_raise(ArgumentError)
71+
72+
expect(action.send(:requested_feature_names)).to be_nil
73+
end
74+
75+
it 'uses parsed keys when no raw keys parameter is present' do
76+
request = instance_double(Rack::Request, query_string: 'other=value')
77+
action = described_class.new(flipper, request)
78+
allow(action).to receive(:safe_params).and_return('keys' => 'my_feature,other_feature')
79+
80+
expect(action.send(:requested_feature_names)).to eq(%w[my_feature other_feature])
81+
end
82+
83+
{
84+
'keys=my_feature&keys=other_feature' => %w[my_feature other_feature],
85+
'keys=&keys=my_feature' => %w[my_feature],
86+
'=&keys=my_feature&=value' => %w[my_feature],
87+
'keys=' => [],
88+
}.each do |query, expected_keys|
89+
it "preserves duplicate and blank parameter semantics for #{query.inspect}" do
90+
flipper[:my_feature].enable
91+
flipper[:other_feature].disable
92+
93+
status, _, body = raw_api_get('/features', query)
94+
keys = body.fetch('features').map { |feature| feature.fetch('key') }.sort
95+
96+
expect(status).to eq(200)
97+
expect(keys).to eq(expected_keys.sort)
98+
end
99+
end
100+
7101
context 'with flipper features' do
8102
before do
9103
flipper[:my_feature].enable
@@ -80,6 +174,20 @@
80174
expect(json_response).to eq(expected_response)
81175
end
82176

177+
it 'responds without gates when instructed by param' do
178+
get '/features', 'exclude_gates' => 'true'
179+
180+
expect(last_response.status).to eq(200)
181+
expect(json_response).to eq(
182+
'features' => [
183+
{
184+
'key' => 'my_feature',
185+
'state' => 'on',
186+
},
187+
]
188+
)
189+
end
190+
83191
it 'ignores a leading empty query segment' do
84192
get '/features?exclude_gate_names=true'
85193
expected_response = json_response

‎spec/support/spec_helpers.rb‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,15 @@ def json_response
3737
JSON.parse(body)
3838
end
3939

40+
def raw_api_get(path, query_string)
41+
env = Rack::MockRequest.env_for(path, 'QUERY_STRING' => query_string)
42+
status, headers, body = app.call(env)
43+
response_body = body.each_with_object(+'') { |part, buffer| buffer << part }
44+
[status, headers, JSON.parse(response_body)]
45+
ensure
46+
body.close if body.respond_to?(:close)
47+
end
48+
4049
def api_error_code_reference_url
4150
'https://flippercloud.io/docs/api#error-code-reference'
4251
end

0 commit comments

Comments
 (0)