Skip to content

Commit

Permalink
add SwWebhookAuthentication middleware
Browse files Browse the repository at this point in the history
  • Loading branch information
jpsantosbh committed Oct 24, 2023
1 parent c5dfb1b commit c820dd0
Show file tree
Hide file tree
Showing 4 changed files with 252 additions and 0 deletions.
53 changes: 53 additions & 0 deletions lib/rack/signalwire_webhook_authentication.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# frozen_string_literal: true

require 'rack/media_type'

module Rack

class SwWebhookAuthentication

FORM_URLENCODED_MEDIA_TYPE = Rack::MediaType.type('application/x-www-form-urlencoded')

def initialize(app, private_key, *paths, &private_key_lookup)
@app = app
@private_key = private_key
define_singleton_method(:get_private_key, private_key_lookup) if block_given?
@path_regex = Regexp.union(paths)
end

def call(env)
return @app.call(env) unless env['PATH_INFO'].match(@path_regex)
request = Rack::Request.new(env)
original_url = request.url
params = extract_params!(request)
private_key = @private_key || get_private_key(params['AccountSid'])
validator = Signalwire::Webhook::ValidateRequest.new(private_key)
signature = env['HTTP_X_SIGNALWIRE_SIGNATURE'] || env['HTTP_X_TWILIO_SIGNATURE'] || ''
if validator.validate(original_url, params, signature)
@app.call(env)
else
[
403,
{ 'Content-Type' => 'text/plain' },
['Signalwire Request Validation Failed.']
]
end
end

def extract_params!(request)
return {} unless request.post?

if request.media_type == FORM_URLENCODED_MEDIA_TYPE
request.POST
else
request.body.rewind
body = request.body.read
request.body.rewind
body
end
end

private :extract_params!

end
end
1 change: 1 addition & 0 deletions signalwire.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'bundler', '~> 2.1'
spec.add_development_dependency 'bundler-audit', '~> 0.6'
spec.add_development_dependency 'guard-rspec', '~> 4.7'
spec.add_development_dependency 'rack', '~> 2.0'
spec.add_development_dependency 'rake', '~> 13.0'
spec.add_development_dependency 'rdoc', '~> 6.1'
spec.add_development_dependency 'rspec', '~> 3.0'
Expand Down
197 changes: 197 additions & 0 deletions spec/rack/signalwire_webhook_authentication_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
require 'spec_helper'
require 'rack/mock'
require 'rack/signalwire_webhook_authentication'

describe Rack::SwWebhookAuthentication do
before do
@app = ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['Hello']] }
end

describe 'new' do
it 'should initialize with an app, auth token and a path' do
expect do
Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/)
end.not_to raise_error
end

it 'should initialize with an app, auth token and paths' do
expect do
Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/, /\/sms/)
end.not_to raise_error
end

it 'should initialize with an app, dynamic token and paths' do
expect do
Rack::SwWebhookAuthentication.new(@app, nil, /\/voice/, /\/sms/)
end.not_to raise_error
end
end

describe 'calling against one path with dynamic auth token' do
it 'should allow a request through if it validates' do
auth_token = 'qwerty'
account_sid = 12_345
expect_any_instance_of(Rack::Request).to receive(:post?).and_return(true)
expect_any_instance_of(Rack::Request).to receive(:media_type).and_return(Rack::MediaType.type('application/x-www-form-urlencoded'))
expect_any_instance_of(Rack::Request).to receive(:POST).and_return({ 'AccountSid' => account_sid })
@middleware = Rack::SwWebhookAuthentication.new(@app, nil, /\/voice/) { |asid| auth_token }
request_validator = double('RequestValidator')
expect(Twilio::Security::RequestValidator).to receive(:new).with(auth_token).and_return(request_validator)
expect(request_validator).to receive(:validate).and_return(true)
request = Rack::MockRequest.env_for('/voice')
status, headers, body = @middleware.call(request)
expect(status).to be(200)
end
end

describe 'calling against one path' do
before do
@middleware = Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/)
end

it 'should not intercept when the path doesn\'t match' do
expect_any_instance_of(Twilio::Security::RequestValidator).to_not receive(:validate)
request = Rack::MockRequest.env_for('/sms')
status, headers, body = @middleware.call(request)
expect(status).to be(200)
end

it 'should allow a request through if it validates' do
expect_any_instance_of(Twilio::Security::RequestValidator).to(
receive(:validate).and_return(true)
)
request = Rack::MockRequest.env_for('/voice')
status, headers, body = @middleware.call(request)
expect(status).to be(200)
end

it 'should short circuit a request to 403 if it does not validate' do
expect_any_instance_of(Twilio::Security::RequestValidator).to(
receive(:validate).and_return(false)
)
request = Rack::MockRequest.env_for('/voice')
status, headers, body = @middleware.call(request)
expect(status).to be(403)
end
end

describe 'calling against many paths' do
before do
@middleware = Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/, /\/sms/)
end

it 'should not intercept when the path doesn\'t match' do
expect_any_instance_of(Twilio::Security::RequestValidator).to_not receive(:validate)
request = Rack::MockRequest.env_for('icesms')
status, headers, body = @middleware.call(request)
expect(status).to be(200)
end

it 'shold allow a request through if it validates' do
expect_any_instance_of(Twilio::Security::RequestValidator).to(
receive(:validate).and_return(true)
)
request = Rack::MockRequest.env_for('/sms')
status, headers, body = @middleware.call(request)
expect(status).to be(200)
end

it 'should short circuit a request to 403 if it does not validate' do
expect_any_instance_of(Twilio::Security::RequestValidator).to(
receive(:validate).and_return(false)
)
request = Rack::MockRequest.env_for('/sms')
status, headers, body = @middleware.call(request)
expect(status).to be(403)
end
end

describe 'validating non-form-data POST payloads' do
it 'should fail if the body does not validate' do
middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/)
input = StringIO.new('{"message": "a post body that does not match the bodySHA256"}')

request = Rack::MockRequest.env_for(
'https://example.com/test?bodySHA256=79bfb0acaf0045fd30f13d48d4fe296b393d85a3bfbee881a0172b2bd574b11e',
method: 'POST',
input: input
)
request['HTTP_X_TWILIO_SIGNATURE'] = '+LYlbGr/VmN84YPJQCuWs+9UA7E='
request['CONTENT_TYPE'] = 'application/json'

status, headers, body = middleware.call(request)

expect(status).not_to be(200)
end

it 'should validate if the body signature is correct' do
middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/)
input = StringIO.new('{"message": "a post body"}')

request = Rack::MockRequest.env_for(
'https://example.com/test?bodySHA256=8d90d640c6ba47d595ac56203d7f5c6b511be80fdf44a2055acca75a119b9fd2',
method: 'POST',
input: input
)
request['HTTP_X_SIGNALWIRE_SIGNATURE'] = 'zR5Oq4f6cijN5oz5bisiVuxYnTU='
request['CONTENT_TYPE'] = 'application/json'

status, headers, body = middleware.call(request)

expect(status).to be(200)
end

it 'should validate even if a previous middleware read the body first' do
middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/)
input = StringIO.new('{"message": "a post body"}')

request = Rack::MockRequest.env_for(
'https://example.com/test?bodySHA256=8d90d640c6ba47d595ac56203d7f5c6b511be80fdf44a2055acca75a119b9fd2',
method: 'POST',
input: input
)
request['HTTP_X_SIGNALWIRE_SIGNATURE'] = 'zR5Oq4f6cijN5oz5bisiVuxYnTU='
request['CONTENT_TYPE'] = 'application/json'
request['rack.input'].read

status, headers, body = middleware.call(request)

expect(status).to be(200)
end
end

describe 'validating application/x-www-form-urlencoded POST payloads' do
it 'should fail if the body does not validate' do
middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/)

request = Rack::MockRequest.env_for(
'https://example.com/test',
method: 'POST',
params: { 'foo' => 'bar' }
)
request['HTTP_X_SIGNALWIRE_SIGNATURE'] = 'foobarbaz'
expect(request['CONTENT_TYPE']).to eq('application/x-www-form-urlencoded')

status, headers, body = middleware.call(request)

expect(status).not_to be(200)
end

it 'should validate if the body signature is correct' do
middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/)

request = Rack::MockRequest.env_for(
'https://example.com/test',
method: 'POST',
params: { 'foo' => 'bar' }
)
request['HTTP_X_SIGNALWIRE_SIGNATURE'] = 'TR9Skm9jiF4WVRJznU5glK5I83k='
expect(request['CONTENT_TYPE']).to eq('application/x-www-form-urlencoded')

status, headers, body = middleware.call(request)

expect(status).to be(200)
end

end
end
1 change: 1 addition & 0 deletions spec/spec_helper.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

%w[
bundler/setup
rack
signalwire
webmock/rspec
vcr
Expand Down

0 comments on commit c820dd0

Please sign in to comment.