From c820dd01d9f5952a1b01c243da61ea4b7df1f3f9 Mon Sep 17 00:00:00 2001 From: Joao Santos <jpsantos@gmail.com> Date: Tue, 24 Oct 2023 15:34:11 -0300 Subject: [PATCH 1/7] 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 From 9927d0fae70340a5d7c2355b3d6c5c04e1ea5d04 Mon Sep 17 00:00:00 2001 From: Joao Santos <jpsantos@gmail.com> Date: Tue, 24 Oct 2023 21:48:29 -0300 Subject: [PATCH 2/7] rename filename to match --- ...e_webhook_authentication.rb => sw_webhook_authentication.rb} | 0 ...authentication_spec.rb => sw_webhook_authentication_spec.rb} | 2 +- 2 files changed, 1 insertion(+), 1 deletion(-) rename lib/rack/{signalwire_webhook_authentication.rb => sw_webhook_authentication.rb} (100%) rename spec/rack/{signalwire_webhook_authentication_spec.rb => sw_webhook_authentication_spec.rb} (99%) diff --git a/lib/rack/signalwire_webhook_authentication.rb b/lib/rack/sw_webhook_authentication.rb similarity index 100% rename from lib/rack/signalwire_webhook_authentication.rb rename to lib/rack/sw_webhook_authentication.rb diff --git a/spec/rack/signalwire_webhook_authentication_spec.rb b/spec/rack/sw_webhook_authentication_spec.rb similarity index 99% rename from spec/rack/signalwire_webhook_authentication_spec.rb rename to spec/rack/sw_webhook_authentication_spec.rb index b90d88b..2215fbe 100644 --- a/spec/rack/signalwire_webhook_authentication_spec.rb +++ b/spec/rack/sw_webhook_authentication_spec.rb @@ -1,6 +1,6 @@ require 'spec_helper' require 'rack/mock' -require 'rack/signalwire_webhook_authentication' +require 'rack/sw_webhook_authentication' describe Rack::SwWebhookAuthentication do before do From f00c9a9d6b868d3e7ec937c663c9ed720a2e6d7c Mon Sep 17 00:00:00 2001 From: Joao Santos <jpsantos@gmail.com> Date: Wed, 25 Oct 2023 22:03:50 -0300 Subject: [PATCH 3/7] revert to SignalwireWebhookAuthentication --- ...b => signalwire_webhook_authentication.rb} | 2 +- ...signalwire_webhook_authentication_spec.rb} | 26 +++++++++---------- 2 files changed, 14 insertions(+), 14 deletions(-) rename lib/rack/{sw_webhook_authentication.rb => signalwire_webhook_authentication.rb} (97%) rename spec/rack/{sw_webhook_authentication_spec.rb => signalwire_webhook_authentication_spec.rb} (85%) diff --git a/lib/rack/sw_webhook_authentication.rb b/lib/rack/signalwire_webhook_authentication.rb similarity index 97% rename from lib/rack/sw_webhook_authentication.rb rename to lib/rack/signalwire_webhook_authentication.rb index e594447..1c21263 100644 --- a/lib/rack/sw_webhook_authentication.rb +++ b/lib/rack/signalwire_webhook_authentication.rb @@ -4,7 +4,7 @@ module Rack - class SwWebhookAuthentication + class SignalwireWebhookAuthentication FORM_URLENCODED_MEDIA_TYPE = Rack::MediaType.type('application/x-www-form-urlencoded') diff --git a/spec/rack/sw_webhook_authentication_spec.rb b/spec/rack/signalwire_webhook_authentication_spec.rb similarity index 85% rename from spec/rack/sw_webhook_authentication_spec.rb rename to spec/rack/signalwire_webhook_authentication_spec.rb index 2215fbe..39cd853 100644 --- a/spec/rack/sw_webhook_authentication_spec.rb +++ b/spec/rack/signalwire_webhook_authentication_spec.rb @@ -1,8 +1,8 @@ require 'spec_helper' require 'rack/mock' -require 'rack/sw_webhook_authentication' +require 'rack/signalwire_webhook_authentication' -describe Rack::SwWebhookAuthentication do +describe Rack::SignalwireWebhookAuthentication do before do @app = ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['Hello']] } end @@ -10,19 +10,19 @@ describe 'new' do it 'should initialize with an app, auth token and a path' do expect do - Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/) + Rack::SignalwireWebhookAuthentication.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/) + Rack::SignalwireWebhookAuthentication.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/) + Rack::SignalwireWebhookAuthentication.new(@app, nil, /\/voice/, /\/sms/) end.not_to raise_error end end @@ -34,7 +34,7 @@ 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 } + @middleware = Rack::SignalwireWebhookAuthentication.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) @@ -46,7 +46,7 @@ describe 'calling against one path' do before do - @middleware = Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/) + @middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'ABC', /\/voice/) end it 'should not intercept when the path doesn\'t match' do @@ -77,7 +77,7 @@ describe 'calling against many paths' do before do - @middleware = Rack::SwWebhookAuthentication.new(@app, 'ABC', /\/voice/, /\/sms/) + @middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'ABC', /\/voice/, /\/sms/) end it 'should not intercept when the path doesn\'t match' do @@ -108,7 +108,7 @@ 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/) + middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'qwerty', /\/test/) input = StringIO.new('{"message": "a post body that does not match the bodySHA256"}') request = Rack::MockRequest.env_for( @@ -125,7 +125,7 @@ end it 'should validate if the body signature is correct' do - middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/) + middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'qwerty', /\/test/) input = StringIO.new('{"message": "a post body"}') request = Rack::MockRequest.env_for( @@ -142,7 +142,7 @@ end it 'should validate even if a previous middleware read the body first' do - middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/) + middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'qwerty', /\/test/) input = StringIO.new('{"message": "a post body"}') request = Rack::MockRequest.env_for( @@ -162,7 +162,7 @@ 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/) + middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'qwerty', /\/test/) request = Rack::MockRequest.env_for( 'https://example.com/test', @@ -178,7 +178,7 @@ end it 'should validate if the body signature is correct' do - middleware = Rack::SwWebhookAuthentication.new(@app, 'qwerty', /\/test/) + middleware = Rack::SignalwireWebhookAuthentication.new(@app, 'qwerty', /\/test/) request = Rack::MockRequest.env_for( 'https://example.com/test', From a2344fdf1dec2a82e39f2f104021a67e41e95f50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Barbosa=20Marques=20dos=20Santos?= <jpsantos@gmail.com> Date: Thu, 26 Oct 2023 08:19:05 -0300 Subject: [PATCH 4/7] Update lib/rack/signalwire_webhook_authentication.rb Co-authored-by: Ryan Williams <ryan.williams@signalwire.com> --- lib/rack/signalwire_webhook_authentication.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/rack/signalwire_webhook_authentication.rb b/lib/rack/signalwire_webhook_authentication.rb index 1c21263..58bbb1f 100644 --- a/lib/rack/signalwire_webhook_authentication.rb +++ b/lib/rack/signalwire_webhook_authentication.rb @@ -5,7 +5,6 @@ module Rack class SignalwireWebhookAuthentication - FORM_URLENCODED_MEDIA_TYPE = Rack::MediaType.type('application/x-www-form-urlencoded') def initialize(app, private_key, *paths, &private_key_lookup) From 60d429eea629070ccded598a633fd985d7e4318d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Barbosa=20Marques=20dos=20Santos?= <jpsantos@gmail.com> Date: Thu, 26 Oct 2023 08:19:15 -0300 Subject: [PATCH 5/7] Update spec/rack/signalwire_webhook_authentication_spec.rb Co-authored-by: Ryan Williams <ryan.williams@signalwire.com> --- spec/rack/signalwire_webhook_authentication_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/spec/rack/signalwire_webhook_authentication_spec.rb b/spec/rack/signalwire_webhook_authentication_spec.rb index 39cd853..1e3d9ef 100644 --- a/spec/rack/signalwire_webhook_authentication_spec.rb +++ b/spec/rack/signalwire_webhook_authentication_spec.rb @@ -194,4 +194,4 @@ end end -end \ No newline at end of file +end From af177b94e9c3fb3c31c9548b9ed7ed46a8a0f44c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jo=C3=A3o=20Paulo=20Barbosa=20Marques=20dos=20Santos?= <jpsantos@gmail.com> Date: Thu, 26 Oct 2023 08:19:23 -0300 Subject: [PATCH 6/7] Update lib/rack/signalwire_webhook_authentication.rb Co-authored-by: Ryan Williams <ryan.williams@signalwire.com> --- lib/rack/signalwire_webhook_authentication.rb | 1 - 1 file changed, 1 deletion(-) diff --git a/lib/rack/signalwire_webhook_authentication.rb b/lib/rack/signalwire_webhook_authentication.rb index 58bbb1f..5e26538 100644 --- a/lib/rack/signalwire_webhook_authentication.rb +++ b/lib/rack/signalwire_webhook_authentication.rb @@ -3,7 +3,6 @@ require 'rack/media_type' module Rack - class SignalwireWebhookAuthentication FORM_URLENCODED_MEDIA_TYPE = Rack::MediaType.type('application/x-www-form-urlencoded') From 542db143eca2823fdd55181d07591f2b865b9862 Mon Sep 17 00:00:00 2001 From: Joao Santos <jpsantos@gmail.com> Date: Thu, 26 Oct 2023 08:23:07 -0300 Subject: [PATCH 7/7] version bump --- CHANGELOG.md | 4 ++++ lib/signalwire/version.rb | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 157baf9..14d6481 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ This project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.htm ## [Unreleased] +## [2.5.0] - 2023-10-26 +### Added +- Add Middleware `SignalwireWebhookAuthentication` + ## [2.4.0] - 2023-10-13 ### Added - Add Webhook `ValidateRequest` diff --git a/lib/signalwire/version.rb b/lib/signalwire/version.rb index 71abf01..962140d 100644 --- a/lib/signalwire/version.rb +++ b/lib/signalwire/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Signalwire - VERSION = '2.4.0' + VERSION = '2.5.0' end