From c820dd01d9f5952a1b01c243da61ea4b7df1f3f9 Mon Sep 17 00:00:00 2001 From: Joao Santos Date: Tue, 24 Oct 2023 15:34:11 -0300 Subject: [PATCH] add SwWebhookAuthentication middleware --- lib/rack/signalwire_webhook_authentication.rb | 53 +++++ signalwire.gemspec | 1 + .../signalwire_webhook_authentication_spec.rb | 197 ++++++++++++++++++ spec/spec_helper.rb | 1 + 4 files changed, 252 insertions(+) create mode 100644 lib/rack/signalwire_webhook_authentication.rb create mode 100644 spec/rack/signalwire_webhook_authentication_spec.rb diff --git a/lib/rack/signalwire_webhook_authentication.rb b/lib/rack/signalwire_webhook_authentication.rb new file mode 100644 index 0000000..e594447 --- /dev/null +++ b/lib/rack/signalwire_webhook_authentication.rb @@ -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 diff --git a/signalwire.gemspec b/signalwire.gemspec index 5113569..30bedd1 100644 --- a/signalwire.gemspec +++ b/signalwire.gemspec @@ -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' diff --git a/spec/rack/signalwire_webhook_authentication_spec.rb b/spec/rack/signalwire_webhook_authentication_spec.rb new file mode 100644 index 0000000..b90d88b --- /dev/null +++ b/spec/rack/signalwire_webhook_authentication_spec.rb @@ -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 \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 799620d..c7be093 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -5,6 +5,7 @@ %w[ bundler/setup + rack signalwire webmock/rspec vcr