Skip to content

Commit 9d69b6e

Browse files
gabrielgeshanholtz
andauthored
fix: Validate signatures in Rack middleware for non-form-data payloads (#590)
Co-authored-by: Elise Shanholtz <[email protected]>
1 parent 6c50e0f commit 9d69b6e

File tree

2 files changed

+114
-1
lines changed

2 files changed

+114
-1
lines changed

lib/rack/twilio_webhook_authentication.rb

+25-1
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# frozen_string_literal: true
22

3+
require 'rack/media_type'
4+
35
module Rack
46
# Middleware that authenticates webhooks from Twilio using the request
57
# validator.
@@ -19,6 +21,10 @@ module Rack
1921
# doesn't validate then the middleware responds immediately with a 403 status.
2022

2123
class TwilioWebhookAuthentication
24+
# Rack's FORM_DATA_MEDIA_TYPES can be modified to taste, so we're slightly
25+
# more conservative in what we consider form data.
26+
FORM_URLENCODED_MEDIA_TYPE = Rack::MediaType.type('application/x-www-form-urlencoded')
27+
2228
def initialize(app, auth_token, *paths, &auth_token_lookup)
2329
@app = app
2430
@auth_token = auth_token
@@ -30,7 +36,7 @@ def call(env)
3036
return @app.call(env) unless env['PATH_INFO'].match(@path_regex)
3137
request = Rack::Request.new(env)
3238
original_url = request.url
33-
params = request.post? ? request.POST : {}
39+
params = extract_params!(request)
3440
auth_token = @auth_token || get_auth_token(params['AccountSid'])
3541
validator = Twilio::Security::RequestValidator.new(auth_token)
3642
signature = env['HTTP_X_TWILIO_SIGNATURE'] || ''
@@ -44,5 +50,23 @@ def call(env)
4450
]
4551
end
4652
end
53+
54+
# Extract the params from the the request that we can use to determine the
55+
# signature. This _may_ modify the passed in request since it may read/rewind
56+
# the body.
57+
def extract_params!(request)
58+
return {} unless request.post?
59+
60+
if request.media_type == FORM_URLENCODED_MEDIA_TYPE
61+
request.POST
62+
else
63+
request.body.rewind
64+
body = request.body.read
65+
request.body.rewind
66+
body
67+
end
68+
end
69+
70+
private :extract_params!
4771
end
4872
end

spec/rack/twilio_webhook_authentication_spec.rb

+89
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
auth_token = 'qwerty'
3232
account_sid = 12_345
3333
expect_any_instance_of(Rack::Request).to receive(:post?).and_return(true)
34+
expect_any_instance_of(Rack::Request).to receive(:media_type).and_return(Rack::MediaType.type('application/x-www-form-urlencoded'))
3435
expect_any_instance_of(Rack::Request).to receive(:POST).and_return({ 'AccountSid' => account_sid })
3536
@middleware = Rack::TwilioWebhookAuthentication.new(@app, nil, /\/voice/) { |asid| auth_token }
3637
request_validator = double('RequestValidator')
@@ -103,4 +104,92 @@
103104
expect(status).to be(403)
104105
end
105106
end
107+
108+
describe 'validating non-form-data POST payloads' do
109+
it 'should fail if the body does not validate' do
110+
middleware = Rack::TwilioWebhookAuthentication.new(@app, 'qwerty', /\/test/)
111+
input = StringIO.new('{"message": "a post body that does not match the bodySHA256"}')
112+
113+
request = Rack::MockRequest.env_for(
114+
'https://example.com/test?bodySHA256=79bfb0acaf0045fd30f13d48d4fe296b393d85a3bfbee881a0172b2bd574b11e',
115+
method: 'POST',
116+
input: input
117+
)
118+
request['HTTP_X_TWILIO_SIGNATURE'] = '+LYlbGr/VmN84YPJQCuWs+9UA7E='
119+
request['CONTENT_TYPE'] = 'application/json'
120+
121+
status, headers, body = middleware.call(request)
122+
123+
expect(status).not_to be(200)
124+
end
125+
126+
it 'should validate if the body signature is correct' do
127+
middleware = Rack::TwilioWebhookAuthentication.new(@app, 'qwerty', /\/test/)
128+
input = StringIO.new('{"message": "a post body"}')
129+
130+
request = Rack::MockRequest.env_for(
131+
'https://example.com/test?bodySHA256=8d90d640c6ba47d595ac56203d7f5c6b511be80fdf44a2055acca75a119b9fd2',
132+
method: 'POST',
133+
input: input
134+
)
135+
request['HTTP_X_TWILIO_SIGNATURE'] = 'zR5Oq4f6cijN5oz5bisiVuxYnTU='
136+
request['CONTENT_TYPE'] = 'application/json'
137+
138+
status, headers, body = middleware.call(request)
139+
140+
expect(status).to be(200)
141+
end
142+
143+
it 'should validate even if a previous middleware read the body first' do
144+
middleware = Rack::TwilioWebhookAuthentication.new(@app, 'qwerty', /\/test/)
145+
input = StringIO.new('{"message": "a post body"}')
146+
147+
request = Rack::MockRequest.env_for(
148+
'https://example.com/test?bodySHA256=8d90d640c6ba47d595ac56203d7f5c6b511be80fdf44a2055acca75a119b9fd2',
149+
method: 'POST',
150+
input: input
151+
)
152+
request['HTTP_X_TWILIO_SIGNATURE'] = 'zR5Oq4f6cijN5oz5bisiVuxYnTU='
153+
request['CONTENT_TYPE'] = 'application/json'
154+
request['rack.input'].read
155+
156+
status, headers, body = middleware.call(request)
157+
158+
expect(status).to be(200)
159+
end
160+
end
161+
162+
describe 'validating application/x-www-form-urlencoded POST payloads' do
163+
it 'should fail if the body does not validate' do
164+
middleware = Rack::TwilioWebhookAuthentication.new(@app, 'qwerty', /\/test/)
165+
166+
request = Rack::MockRequest.env_for(
167+
'https://example.com/test',
168+
method: 'POST',
169+
params: { 'foo' => 'bar' }
170+
)
171+
request['HTTP_X_TWILIO_SIGNATURE'] = 'foobarbaz'
172+
expect(request['CONTENT_TYPE']).to eq('application/x-www-form-urlencoded')
173+
174+
status, headers, body = middleware.call(request)
175+
176+
expect(status).not_to be(200)
177+
end
178+
179+
it 'should validate if the body signature is correct' do
180+
middleware = Rack::TwilioWebhookAuthentication.new(@app, 'qwerty', /\/test/)
181+
182+
request = Rack::MockRequest.env_for(
183+
'https://example.com/test',
184+
method: 'POST',
185+
params: { 'foo' => 'bar' }
186+
)
187+
request['HTTP_X_TWILIO_SIGNATURE'] = 'TR9Skm9jiF4WVRJznU5glK5I83k='
188+
expect(request['CONTENT_TYPE']).to eq('application/x-www-form-urlencoded')
189+
190+
status, headers, body = middleware.call(request)
191+
192+
expect(status).to be(200)
193+
end
194+
end
106195
end

0 commit comments

Comments
 (0)