Skip to content

Commit

Permalink
Verifying the webhook signature (#95)
Browse files Browse the repository at this point in the history
  • Loading branch information
ryanwi authored Oct 12, 2023
1 parent 24be60d commit d472338
Show file tree
Hide file tree
Showing 5 changed files with 149 additions and 1 deletion.
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
1 change: 1 addition & 0 deletions lib/signalwire/sdk.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion lib/signalwire/version.rb
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# frozen_string_literal: true

module Signalwire
VERSION = '2.3.4'
VERSION = '2.4.0'
end
56 changes: 56 additions & 0 deletions lib/signalwire/webhook/validate_request.rb
Original file line number Diff line number Diff line change
@@ -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
87 changes: 87 additions & 0 deletions spec/signalwire/webhook/validate_request_spec.rb
Original file line number Diff line number Diff line change
@@ -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

0 comments on commit d472338

Please sign in to comment.