Skip to content

Commit e4ed440

Browse files
authored
Merge pull request #5042 from DataDog/fix-api-security-route-extractor-for-rails-8-1-1
Fix APISecurity::RouteExtractor for Rails 8.1.1+
2 parents e0ab5b7 + a693370 commit e4ed440

File tree

4 files changed

+110
-36
lines changed

4 files changed

+110
-36
lines changed

lib/datadog/appsec/api_security/route_extractor.rb

Lines changed: 20 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ module RouteExtractor
1010
SINATRA_ROUTE_KEY = 'sinatra.route'
1111
SINATRA_ROUTE_SEPARATOR = ' '
1212
GRAPE_ROUTE_KEY = 'grape.routing_args'
13-
RAILS_ROUTE_KEY = 'action_dispatch.route_uri_pattern'
13+
RAILS_ROUTE_URI_PATTERN_KEY = 'action_dispatch.route_uri_pattern'
14+
RAILS_ROUTE_KEY = 'action_dispatch.route' # Rails 8.1.1+
1415
RAILS_ROUTES_KEY = 'action_dispatch.routes'
1516
RAILS_PATH_PARAMS_KEY = 'action_dispatch.request.path_parameters'
1617
RAILS_FORMAT_SUFFIX = '(.:format)'
@@ -37,6 +38,9 @@ module RouteExtractor
3738
# Rails > 7.1 (fast path)
3839
# uses `action_dispatch.route_uri_pattern` with a string like
3940
# "/users/:id(.:format)"
41+
# Rails > 8.1.1 (fast path)
42+
# uses `action_dispatch.route` to store the ActionDispatch::Journey::Route
43+
# that matched when the request was routed
4044
#
4145
# WARNING: This method works only *after* the request has been routed.
4246
#
@@ -52,11 +56,18 @@ def self.route_pattern(request)
5256
pattern = request.env[SINATRA_ROUTE_KEY].split(SINATRA_ROUTE_SEPARATOR, 2)[1]
5357
"#{request.script_name}#{pattern}"
5458
elsif request.env.key?(RAILS_ROUTE_KEY)
55-
request.env[RAILS_ROUTE_KEY].delete_suffix(RAILS_FORMAT_SUFFIX)
59+
request.env[RAILS_ROUTE_KEY].path.spec.to_s.delete_suffix(RAILS_FORMAT_SUFFIX)
60+
elsif request.env.key?(RAILS_ROUTE_URI_PATTERN_KEY)
61+
request.env[RAILS_ROUTE_URI_PATTERN_KEY].delete_suffix(RAILS_FORMAT_SUFFIX)
5662
elsif request.env.key?(RAILS_ROUTES_KEY) && !request.env.fetch(RAILS_PATH_PARAMS_KEY, {}).empty?
57-
# NOTE: Rails mutate HEAD request in order to understand that route is supported.
58-
# It will assing GET request method and run the route recognition.
59-
request = request.env[RAILS_ROUTES_KEY].request_class.new(request.env) if request.head?
63+
# NOTE: In Rails < 7.1 this `request` argument will be a Rack::Request,
64+
# it does not have all the methods that ActionDispatch::Request has.
65+
# Before trying to use the router to recognize the route, we need to
66+
# create a new ActionDispatch::Request from the request env
67+
#
68+
# NOTE: Rails mutates HEAD request by changing the method to GET
69+
# and uses it for route recognition to check if the route is defined
70+
request = request.env[RAILS_ROUTES_KEY].request_class.new(request.env)
6071

6172
pattern = request.env[RAILS_ROUTES_KEY].router
6273
.recognize(request) { |route, _| break route.path.spec.to_s }
@@ -70,6 +81,10 @@ def self.route_pattern(request)
7081
else
7182
Tracing::Contrib::Rack::RouteInference.read_or_infer(request.env)
7283
end
84+
rescue => e
85+
AppSec.telemetry&.report(e, description: 'AppSec: Could not extract route pattern')
86+
87+
nil
7388
end
7489
end
7590
end

lib/datadog/appsec/api_security/sampler.rb

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,9 @@ def sample?(request, response)
4545
return true if @sample_delay_seconds.zero?
4646
return false if response.status == 404
4747

48-
key = Zlib.crc32("#{request.request_method}#{RouteExtractor.route_pattern(request)}#{response.status}")
48+
route_pattern = RouteExtractor.route_pattern(request).to_s
49+
50+
key = Zlib.crc32("#{request.request_method}#{route_pattern}#{response.status}")
4951
current_timestamp = Core::Utils::Time.now.to_i
5052
cached_timestamp = @cache[key] || 0
5153

sig/datadog/appsec/api_security/route_extractor.rbs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,8 @@ module Datadog
1010

1111
RAILS_ROUTE_KEY: String
1212

13+
RAILS_ROUTE_URI_PATTERN_KEY: String
14+
1315
RAILS_ROUTES_KEY: String
1416

1517
RAILS_PATH_PARAMS_KEY: String

spec/datadog/appsec/api_security/route_extractor_spec.rb

Lines changed: 85 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -68,31 +68,61 @@
6868
end
6969

7070
context 'when Rails routing is present' do
71-
context 'when route has format suffix' do
72-
before do
73-
allow(request).to receive(:env).and_return({'action_dispatch.route_uri_pattern' => '/users/:id(.:format)'})
71+
context 'when action_dispatch.route_uri_pattern is set' do
72+
context 'when route has format suffix' do
73+
before do
74+
allow(request).to receive(:env).and_return({'action_dispatch.route_uri_pattern' => '/users/:id(.:format)'})
75+
end
76+
77+
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
7478
end
7579

76-
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
77-
end
80+
context 'when route has no format suffix' do
81+
before { allow(request).to receive(:env).and_return({'action_dispatch.route_uri_pattern' => '/users/:id'}) }
7882

79-
context 'when route has no format suffix' do
80-
before { allow(request).to receive(:env).and_return({'action_dispatch.route_uri_pattern' => '/users/:id'}) }
83+
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
84+
end
8185

82-
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
86+
context 'when route has nested path' do
87+
before do
88+
allow(request).to receive(:env).and_return(
89+
{'action_dispatch.route_uri_pattern' => '/api/v1/users/:id/posts/:post_id(.:format)'}
90+
)
91+
end
92+
93+
it { expect(described_class.route_pattern(request)).to eq('/api/v1/users/:id/posts/:post_id') }
94+
end
8395
end
8496

85-
context 'when route has nested path' do
86-
before do
87-
allow(request).to receive(:env).and_return(
88-
{'action_dispatch.route_uri_pattern' => '/api/v1/users/:id/posts/:post_id(.:format)'}
89-
)
97+
context 'when action_dispatch.route is set (Rails 8.1.1 and up)' do
98+
context 'when route has format suffix' do
99+
let(:route_double) do
100+
spec_double = instance_double('ActionDispatch::Journey::Nodes::Cat', to_s: '/users/:id(.:format)')
101+
path_double = instance_double('ActionDispatch::Journey::Path::Pattern', spec: spec_double)
102+
instance_double('ActionDispatch::Journey::Route', path: path_double)
103+
end
104+
105+
before do
106+
allow(request).to receive(:env).and_return({'action_dispatch.route' => route_double})
107+
end
108+
109+
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
90110
end
91111

92-
it { expect(described_class.route_pattern(request)).to eq('/api/v1/users/:id/posts/:post_id') }
112+
context 'when route has no format suffix' do
113+
let(:route_double) do
114+
spec_double = instance_double('ActionDispatch::Journey::Nodes::Cat', to_s: '/users/:id')
115+
path_double = instance_double('ActionDispatch::Journey::Path::Pattern', spec: spec_double)
116+
instance_double('ActionDispatch::Journey::Route', path: path_double)
117+
end
118+
119+
before { allow(request).to receive(:env).and_return({'action_dispatch.route' => route_double}) }
120+
121+
it { expect(described_class.route_pattern(request)).to eq('/users/:id') }
122+
end
93123
end
94124

95-
context 'when route_uri_pattern is not set and request path_parameters is empty' do
125+
context 'when neither route or route_uri_pattern is set and request path_parameters are empty' do
96126
before do
97127
allow(request).to receive(:env).and_return({
98128
'action_dispatch.routes' => route_set,
@@ -114,7 +144,7 @@
114144
end
115145
end
116146

117-
context 'when route_uri_pattern is not set and request path_parameters is present' do
147+
context 'when neither route route_uri_pattern is set and request path_parameters are present' do
118148
let(:env) do
119149
{
120150
'action_dispatch.routes' => route_set,
@@ -123,18 +153,18 @@
123153
}
124154
}
125155
end
156+
let(:router) { double('ActionDispatch::Routing::RouteSet::Router') }
157+
let(:route_set) { double('ActionDispatch::Routing::RouteSet', router: router, request_class: action_dispatch_request_class) }
158+
let(:action_dispatch_request_class) { double('class ActionDispatch::Request', new: action_dispatch_request) }
159+
let(:action_dispatch_request) { double('ActionDispatch::Request', env: {}, script_name: '', path: '/users/1') }
126160

127-
context 'when request is HEAD' do
128-
before do
129-
allow(request).to receive(:env).and_return(env)
130-
allow(action_dispatch_request).to receive(:env).and_return(env)
131-
end
161+
before do
162+
allow(request).to receive(:env).and_return(env)
163+
allow(action_dispatch_request).to receive(:env).and_return(env)
164+
end
132165

133-
let(:router) { double('ActionDispatch::Routing::RouteSet::Router') }
134-
let(:route_set) { double('ActionDispatch::Routing::RouteSet', router: router, request_class: action_dispatch_request_class) }
166+
context 'when request is HEAD' do
135167
let(:request) { double('Rack::Request', env: {}, script_name: '', path: '/users/1', head?: true) }
136-
let(:action_dispatch_request_class) { double('class ActionDispatch::Request', new: action_dispatch_request) }
137-
let(:action_dispatch_request) { double('ActionDispatch::Request', env: {}, script_name: '', path: '/users/1') }
138168

139169
it 'uses action dispatch request for route recognition' do
140170
expect(router).to receive(:recognize).with(action_dispatch_request).and_return('/users/:id(.:format)')
@@ -143,14 +173,10 @@
143173
end
144174

145175
context 'when request is not HEAD' do
146-
before { allow(request).to receive(:env).and_return(env) }
147-
148-
let(:router) { double('ActionDispatch::Routing::RouteSet::Router') }
149-
let(:route_set) { double('ActionDispatch::Routing::RouteSet', router: router) }
150176
let(:request) { double('Rack::Request', env: {}, script_name: '', path: '/users/1', head?: false) }
151177

152178
it 'uses action dispatch request for route recognition' do
153-
expect(router).to receive(:recognize).with(request).and_return('/users/:id(.:format)')
179+
expect(router).to receive(:recognize).with(action_dispatch_request).and_return('/users/:id(.:format)')
154180
expect(described_class.route_pattern(request)).to eq('/users/:id')
155181
end
156182
end
@@ -170,6 +196,35 @@
170196

171197
it { expect(described_class.route_pattern(request)).to eq('/unmatched/route') }
172198
end
199+
200+
context 'when an error is raised during route recognition' do
201+
before do
202+
allow(Datadog::AppSec).to receive(:telemetry).and_return(telemetry)
203+
204+
allow(request).to receive(:env).and_return({
205+
'action_dispatch.routes' => route_set,
206+
'action_dispatch.request.path_parameters' => {
207+
'controller' => 'users',
208+
'action' => 'show',
209+
'id' => '1'
210+
}
211+
})
212+
213+
expect(route_set).to receive(:request_class).and_raise(StandardError)
214+
end
215+
216+
let(:route_set) { double('ActionDispatch::Routing::RouteSet') }
217+
let(:telemetry) { spy(Datadog::Core::Telemetry::Component) }
218+
219+
it { expect(described_class.route_pattern(request)).to be_nil }
220+
221+
it 'reports the error via telemetry' do
222+
expect(telemetry).to receive(:report)
223+
.with(an_instance_of(StandardError), description: 'AppSec: Could not extract route pattern')
224+
225+
described_class.route_pattern(request)
226+
end
227+
end
173228
end
174229

175230
context 'when Rack routing is present' do

0 commit comments

Comments
 (0)