diff --git a/README.md b/README.md index 9e002e19..6bd9cfdd 100644 --- a/README.md +++ b/README.md @@ -90,6 +90,32 @@ puts(file.id) Note that you can also pass a raw `IO` descriptor, but this disables retries, as the library can't be sure if the descriptor is a file or pipe (which cannot be rewound). +### Webhook verification + +Verifying webhook signatures is _optional but encouraged_. + +For more information about webhooks, see [the API docs](https://increase.com/documentation/webhooks#events-and-webhooks). + +### Parsing webhook payloads + +For most use cases, you will likely want to verify the webhook and parse the payload at the same time. To achieve this, we provide the method `Increase::Webhooks.unwrap`, which parses a webhook request and verifies that it was sent by Increase. This method will raise an error if the signature is invalid. + +Note that the `body` parameter must be the raw JSON string sent from the server (do not parse it first). The `.unwrap()` method will parse this JSON for you into an event object after verifying the webhook was sent from Increase. + +```ruby +require 'sinatra' + +post '/webhook' do + request.body.rewind + + event = Increase::Webhooks.unwrap( + request.body.read, + request.get_header('Increase-Webhook-Signature'), + "your webhook secret" + ) +end +``` + ### Handling errors When the library is unable to connect to the API, or if the API returns a non-success status code (i.e., 4xx or 5xx response), a subclass of `Increase::Errors::APIError` will be thrown: diff --git a/lib/increase.rb b/lib/increase.rb index 15fbd4de..26ffd7e1 100644 --- a/lib/increase.rb +++ b/lib/increase.rb @@ -49,6 +49,7 @@ require_relative "increase/request_options" require_relative "increase/file_part" require_relative "increase/errors" +require_relative "increase/webhooks" require_relative "increase/internal/transport/base_client" require_relative "increase/internal/transport/pooled_net_requester" require_relative "increase/client" diff --git a/lib/increase/webhooks.rb b/lib/increase/webhooks.rb new file mode 100644 index 00000000..910631a5 --- /dev/null +++ b/lib/increase/webhooks.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Increase + module Webhooks + class << self + def unwrap(payload, signature, secret = nil) + if secret + verify_signature(payload, signature, secret) + end + + JSON.parse(payload) + end + + def verify_signature(payload, signature, secret = nil) + begin + parsed_signature = parse_kv_pairs(signature) + rescue StandardError + raise "Unable to parse webhook signature." + end + + timestamp = parsed_signature["t"] + signature = parsed_signature["v1"] + + expected_signature = OpenSSL::HMAC.hexdigest( + OpenSSL::Digest.new("sha256"), + secret, + "#{timestamp}.#{payload}" + ) + + unless OpenSSL.secure_compare(expected_signature, signature) + raise "None of the given webhook signatures match the expected signature." + end + + nil + end + + private + + def parse_kv_pairs(text, item_sep: ",", value_sep: "=") + Hash[*text.split(item_sep).map { |t| t.split(value_sep) }.flatten] + end + end + end +end diff --git a/test/increase/webhooks_test.rb b/test/increase/webhooks_test.rb new file mode 100644 index 00000000..d3e1562e --- /dev/null +++ b/test/increase/webhooks_test.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +require_relative "test_helper" + +class Increase::Test::WebhooksTest < Minitest::Test + def setup + @timestamp = Time.now.utc.iso8601 + @secret = "whsec_test_secret" + @payload = "{\"type\":\"event\",\"id\":\"event_123abc\",\"created_at\":\"2020-01-31T23:59:59Z\"}" + @signed_payload = OpenSSL::HMAC.hexdigest( + OpenSSL::Digest.new("sha256"), + @secret, + "#{@timestamp}.#{@payload}" + ) + @signature = "t=#{@timestamp},v1=#{@signed_payload}" + end + + def test_unwrap + unwrapped = Increase::Webhooks.unwrap(@payload, @signature, @secret) + assert_equal(unwrapped, JSON.parse(@payload)) + + assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do + Increase::Webhooks.unwrap(@payload, @signature, "wrong_secret") + end + end + + def test_verify_signature_valid + assert_nil(Increase::Webhooks.verify_signature(@payload, @signature, @secret)) + end + + def test_verify_signature_invalid_secret + assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do + Increase::Webhooks.verify_signature(@payload, @signature, "wrong_secret") + end + end + + def test_verify_signature_invalid_payload + wrong_payload = "{\"type\":\"event\",\"id\":\"event_different\"}" + + assert_raises(StandardError, "None of the given webhook signatures match the expected signature.") do + Increase::Webhooks.verify_signature(wrong_payload, @signature, @secret) + end + end + + def test_verify_signature_invalid_signature_format + invalid_signature = "???" + + assert_raises(StandardError, "Unable to parse webhook signature.") do + Increase::Webhooks.verify_signature(@payload, invalid_signature, @secret) + end + end +end