diff --git a/CHANGELOG.md b/CHANGELOG.md index 43a9995..2945833 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.4.0] - 2023- +### Added +- Add Webhook `ValidateRequest` + ## [2.3.4] - 2020-09-09 ### Fixed - Correctly ignore non-call events and clear handlers on call end diff --git a/lib/signalwire/sdk.rb b/lib/signalwire/sdk.rb index 2f66f7c..17521e2 100644 --- a/lib/signalwire/sdk.rb +++ b/lib/signalwire/sdk.rb @@ -10,6 +10,7 @@ require 'signalwire/sdk/fax_response' require 'signalwire/sdk/messaging_response' require 'signalwire/rest/client' +require 'signalwire/webhook/validate_request' module Signalwire module Sdk diff --git a/lib/signalwire/version.rb b/lib/signalwire/version.rb index 880cf6f..71abf01 100644 --- a/lib/signalwire/version.rb +++ b/lib/signalwire/version.rb @@ -1,5 +1,5 @@ # frozen_string_literal: true module Signalwire - VERSION = '2.3.4' + VERSION = '2.4.0' end diff --git a/lib/signalwire/webhook/validate_request.rb b/lib/signalwire/webhook/validate_request.rb new file mode 100644 index 0000000..757f5bb --- /dev/null +++ b/lib/signalwire/webhook/validate_request.rb @@ -0,0 +1,56 @@ +# frozen_string_literal: true + +require 'openssl' + +module Signalwire::Webhook + class ValidateRequest + attr_reader :private_key + + def initialize(private_key) + @private_key = private_key + raise ArgumentError, 'Private key is required' if @private_key.nil? + end + + def validate(url, raw_body, header) + return false if header.nil? || url.nil? + + # compatibility validation for POST parameters of x-www-form-urlencoded requests + if raw_body.is_a?(Hash) + return validate_with_compatibility_api(url, raw_body, header) + end + + # relay json validation + payload = url + raw_body + expected_signature = compute_signature(payload) + valid = secure_compare(expected_signature, header) + + return true if valid + + # fallback compatibilty json validation + validate_with_compatibility_api(url, raw_body, header) + end + + private + + def validate_with_compatibility_api(url, params, signature) + validator = Twilio::Security::RequestValidator.new(private_key) + validator.validate(url, params, signature) + end + + def compute_signature(payload) + OpenSSL::HMAC.hexdigest(OpenSSL::Digest.new('sha1'), private_key, payload) + end + + # Constant time string comparison, from ActiveSupport + def secure_compare(a, b) + return false if a.nil? || b.nil? + return false unless a.bytesize == b.bytesize + + l = a.unpack "C#{a.bytesize}" + + res = 0 + b.each_byte { |byte| res |= byte ^ l.shift } + res == 0 + end + end +end diff --git a/spec/signalwire/webhook/validate_request_spec.rb b/spec/signalwire/webhook/validate_request_spec.rb new file mode 100644 index 0000000..43a57e9 --- /dev/null +++ b/spec/signalwire/webhook/validate_request_spec.rb @@ -0,0 +1,87 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Signalwire + RSpec.describe Webhook::ValidateRequest do + subject { described_class.new(private_key) } + let(:url) { 'https://81f2-2-45-18-191.ngrok-free.app/' } + let(:private_key) { 'PSK_7TruNcSNTxp4zNrykMj4EPzF' } + let(:header) { 'b18500437ebb010220ddd770cbe6fd531ea0ba0d' } + let(:body) { + '{"call":{"call_id":"b5d63b2e-f75b-4dc8-b6d4-269b635f96c0","node_id":"fa3570ae-f8bd-42c2-83f4-9950d906c91b@us-west","segment_id":"b5d63b2e-f75b-4dc8-b6d4-269b635f96c0","call_state":"created","direction":"inbound","type":"phone","from":"+12135877632","to":"+12089806814","from_number":"+12135877632","to_number":"+12089806814","project_id":"4b7ae78a-d02e-4889-a63b-08b156d5916e","space_id":"62615f44-2a34-4235-b38b-76b5a1de6ef8"},"vars":{}}' + } + + describe '#validate' do + context 'when key is valid' do + it 'validates' do + valid = subject.validate(url, body, header) + expect(valid).to eq(true) + end + end + + context 'when key is invalid' do + let(:private_key) { 'PSK_foo' } + + it 'validates signatures do not match' do + valid = subject.validate(url, body, header) + expect(valid).to eq(false) + end + end + + context 'when url is not correct' do + let(:url) { 'https://81f2-2-45-18-191.ngrok-free.app/bar?q=hello' } + + it 'validates signatures do not match' do + valid = subject.validate(url, body, header) + expect(valid).to eq(false) + end + end + + context 'when body is not correct' do + let(:body) { + '{"foo":"bar"}' + } + + it 'validates signatures do not match' do + valid = subject.validate(url, body, header) + expect(valid).to eq(false) + end + end + + context 'when inital validation fails' do + subject { described_class.new('12345') } + + let(:default_params) { + { + CallSid: 'CA1234567890ABCDE', + Caller: '+14158675309', + Digits: '1234', + From: '+14158675309', + To: '+18005551212', + } + } + let(:default_signature) { 'RSOYDt4T1cUTdK1PDd93/VVr8B8=' } + let(:request_url) { 'https://mycompany.com/myapp.php?foo=1&bar=2' } + + it 'fallback and validates the request' do + valid = subject.validate( + request_url, + default_params, + default_signature + ) + expect(valid).to eq(true) + end + + it 'fallback and should not validate the request' do + valid = subject.validate( + request_url, + default_params, + 'wrong_one!' + ) + expect(valid).to eq(false) + end + end + end + end +end