Skip to content

Commit

Permalink
Adds rack middleware to make request verification easier in rack apps.
Browse files Browse the repository at this point in the history
This adds a  class that can be used to verify requests with a public key in rack applications like Rails or Sinatra.
This also changed the  class to fail verification if an error was thrown. In the case where a signature was missing, OpenSSL would throw an error instead of returning false for a invalid signature.
  • Loading branch information
philnash committed Jun 23, 2020
1 parent 33b8306 commit 532c24f
Show file tree
Hide file tree
Showing 7 changed files with 233 additions and 34 deletions.
52 changes: 52 additions & 0 deletions lib/rack/sendgrid_webhook_verification.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
# frozen_string_literal: true

module Rack
# Middleware that verifies webhooks from SendGrid using the EventWebhook
# verifier.
#
# The middleware takes a public key with which to set up the request
# validator and any number of paths. When a path matches the incoming request
# path, the request will be verified using the signature and timestamp of the
# request.
#
# Example:
#
# require 'rack'
# use Rack::SendGridWebhookVerification, ENV['PUBLIC_KEY'], /\/emails/
#
# The above appends this middleware to the stack, using a public key saved in
# the ENV and only against paths that match /\/emails/. If the request
# validates then it gets passed on to the action as normal. If the request
# doesn't validate then the middleware responds immediately with a 403 status.
class SendGridWebhookVerification
def initialize(app, public_key, *paths)
@app = app
@public_key = public_key
@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)

event_webhook = SendGrid::EventWebhook.new
ec_public_key = event_webhook.convert_public_key_to_ecdsa(@public_key)
verified = event_webhook.verify_signature(
ec_public_key,
request.body.read,
request.env[SendGrid::EventWebhookHeader::SIGNATURE],
request.env[SendGrid::EventWebhookHeader::TIMESTAMP]
)

if verified
return @app.call(env)
else
return [
403,
{ 'Content-Type' => 'text/plain' },
['SendGrid Request Verification Failed.']
]
end
end
end
end
1 change: 1 addition & 0 deletions lib/sendgrid-ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -30,3 +30,4 @@
require_relative 'sendgrid/helpers/stats/stats_response'
require_relative 'sendgrid/helpers/stats/metrics'
require_relative 'sendgrid/helpers/permissions/scope'
require_relative 'rack/sendgrid_webhook_verification'
5 changes: 3 additions & 2 deletions lib/sendgrid/helpers/eventwebhook/eventwebhook.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,12 @@ def convert_public_key_to_ecdsa(public_key)
# - +timestamp+ -> timestamp value obtained from the 'X-Twilio-Email-Event-Webhook-Timestamp' header
def verify_signature(public_key, payload, signature, timestamp)
verify_engine
timestamped_playload = timestamp + payload
timestamped_playload = "#{timestamp}#{payload}"
payload_digest = Digest::SHA256.digest(timestamped_playload)
decoded_signature = Base64.decode64(signature)

public_key.dsa_verify_asn1(payload_digest, decoded_signature)
rescue
false
end

def verify_engine
Expand Down
1 change: 1 addition & 0 deletions sendgrid-ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,5 @@ Gem::Specification.new do |spec|
spec.add_development_dependency 'faker'
spec.add_development_dependency 'rubocop'
spec.add_development_dependency 'minitest', '~> 5.9'
spec.add_development_dependency 'rack'
end
16 changes: 16 additions & 0 deletions spec/fixtures/event_webhook.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
require "json"

module Fixtures
module EventWebhook
PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEDr2LjtURuePQzplybdC+u4CwrqDqBaWjcMMsTbhdbcwHBcepxo7yAQGhHPTnlvFYPAZFceEu/1FwCM/QmGUhA=='
FAILING_PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqTxd43gyp8IOEto2LdIfjRQrIbsd4SXZkLW6jDutdhXSJCWHw8REntlo7aNDthvj+y7GjUuFDb/R1NGe1OPzpA=='
SIGNATURE = 'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH2j/0='
FAILING_SIGNATURE = 'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH3j/0='
TIMESTAMP = '1588788367'
PAYLOAD = {
'category'=>'example_payload',
'event'=>'test_event',
'message_id'=>'message_id',
}.to_json
end
end
116 changes: 116 additions & 0 deletions spec/rack/sendgrid_webhook_verification_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,116 @@
require 'spec_helper'
require 'rack/mock'
require './spec/fixtures/event_webhook'

unless RUBY_PLATFORM == 'java'
describe Rack::SendGridWebhookVerification do
let(:public_key) { Fixtures::EventWebhook::PUBLIC_KEY }
before do
@app = ->(_env) { [200, { 'Content-Type' => 'text/plain' }, ['Hello']] }
end

describe 'new' do
it 'should initialize with an app, public key and a path' do
expect do
Rack::SendGridWebhookVerification.new(@app, 'ABC', /\/email/)
end.not_to raise_error
end

it 'should initialize with an app, public keys and paths' do
expect do
Rack::SendGridWebhookVerification.new(@app, 'ABC', /\/email/, /\/event/)
end.not_to raise_error
end
end

describe 'calling against one path' do
let(:middleware) { Rack::SendGridWebhookVerification.new(@app, public_key, /\/email/) }

it "should not intercept when the path doesn't match" do
expect(SendGrid::EventWebhook).to_not receive(:new)
request = Rack::MockRequest.env_for('/login')
status, headers, body = middleware.call(request)
expect(status).to eq(200)
end

it 'should allow a request through if it is verified' do
options = {
:input => Fixtures::EventWebhook::PAYLOAD,
'Content-Type' => "application/json"
}
options[SendGrid::EventWebhookHeader::SIGNATURE] = Fixtures::EventWebhook::SIGNATURE
options[SendGrid::EventWebhookHeader::TIMESTAMP] = Fixtures::EventWebhook::TIMESTAMP
request = Rack::MockRequest.env_for('/email', options)
status, headers, body = middleware.call(request)
expect(status).to eq(200)
end

it 'should short circuit a request to 403 if there is no signature or timestamp' do
options = {
:input => Fixtures::EventWebhook::PAYLOAD,
'Content-Type' => "application/json"
}
request = Rack::MockRequest.env_for('/email', options)
status, headers, body = middleware.call(request)
expect(status).to eq(403)
end

it 'should short circuit a request to 403 if the signature is incorrect' do
options = {
:input => Fixtures::EventWebhook::PAYLOAD,
'Content-Type' => "application/json"
}
options[SendGrid::EventWebhookHeader::SIGNATURE] = Fixtures::EventWebhook::FAILING_SIGNATURE
options[SendGrid::EventWebhookHeader::TIMESTAMP] = Fixtures::EventWebhook::TIMESTAMP
request = Rack::MockRequest.env_for('/email', options)
status, headers, body = middleware.call(request)
expect(status).to eq(403)
end

it 'should short circuit a request to 403 if the payload is incorrect' do
options = {
:input => 'payload',
'Content-Type' => "application/json"
}
options[SendGrid::EventWebhookHeader::SIGNATURE] = Fixtures::EventWebhook::SIGNATURE
options[SendGrid::EventWebhookHeader::TIMESTAMP] = Fixtures::EventWebhook::TIMESTAMP
request = Rack::MockRequest.env_for('/email', options)
status, headers, body = middleware.call(request)
expect(status).to eq(403)
end
end

describe 'calling with multiple paths' do
let(:middleware) { Rack::SendGridWebhookVerification.new(@app, public_key, /\/email/, /\/events/) }

it "should not intercept when the path doesn't match" do
expect(SendGrid::EventWebhook).to_not receive(:new)
request = Rack::MockRequest.env_for('/sms_events')
status, headers, body = middleware.call(request)
expect(status).to eq(200)
end

it 'should allow a request through if it is verified' do
options = {
:input => Fixtures::EventWebhook::PAYLOAD,
'Content-Type' => "application/json"
}
options[SendGrid::EventWebhookHeader::SIGNATURE] = Fixtures::EventWebhook::SIGNATURE
options[SendGrid::EventWebhookHeader::TIMESTAMP] = Fixtures::EventWebhook::TIMESTAMP
request = Rack::MockRequest.env_for('/events', options)
status, headers, body = middleware.call(request)
expect(status).to eq(200)
end

it 'should short circuit a request to 403 if there is no signature or timestamp' do
options = {
:input => Fixtures::EventWebhook::PAYLOAD,
'Content-Type' => "application/json"
}
request = Rack::MockRequest.env_for('/events', options)
status, headers, body = middleware.call(request)
expect(status).to eq(403)
end
end
end
end
76 changes: 44 additions & 32 deletions spec/sendgrid/helpers/eventwebhook/eventwebhook_spec.rb
Original file line number Diff line number Diff line change
@@ -1,70 +1,82 @@
require "json"
require 'spec_helper'
require './spec/fixtures/event_webhook'

describe SendGrid::EventWebhook do
PUBLIC_KEY = 'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEEDr2LjtURuePQzplybdC+u4CwrqDqBaWjcMMsTbhdbcwHBcepxo7yAQGhHPTnlvFYPAZFceEu/1FwCM/QmGUhA=='
SIGNATURE = 'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH2j/0='
TIMESTAMP = '1588788367'
PAYLOAD = {
'category'=>'example_payload',
'event'=>'test_event',
'message_id'=>'message_id',
}.to_json

describe '.verify_signature' do
it 'verifies a valid signature' do
unless skip_jruby
expect(verify(PUBLIC_KEY, PAYLOAD, SIGNATURE, TIMESTAMP)).to be
expect(verify(
Fixtures::EventWebhook::PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
Fixtures::EventWebhook::SIGNATURE,
Fixtures::EventWebhook::TIMESTAMP
)).to be true
end
end

it 'rejects a bad key' do
unless skip_jruby
expect(verify(
'MFkwEwYHKoZIzj0CAQYIKoZIzj0DAQcDQgAEqTxd43gyp8IOEto2LdIfjRQrIbsd4SXZkLW6jDutdhXSJCWHw8REntlo7aNDthvj+y7GjUuFDb/R1NGe1OPzpA==',
PAYLOAD,
SIGNATURE,
TIMESTAMP
)).not_to be
Fixtures::EventWebhook::FAILING_PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
Fixtures::EventWebhook::SIGNATURE,
Fixtures::EventWebhook::TIMESTAMP
)).to be false
end
end

it 'rejects a bad payload' do
unless skip_jruby
expect(verify(
PUBLIC_KEY,
'payload',
SIGNATURE,
TIMESTAMP
)).not_to be
Fixtures::EventWebhook::PUBLIC_KEY,
'payload',
Fixtures::EventWebhook::SIGNATURE,
Fixtures::EventWebhook::TIMESTAMP
)).to be false
end
end

it 'rejects a bad signature' do
unless skip_jruby
expect(verify(
PUBLIC_KEY,
PAYLOAD,
'MEUCIQCtIHJeH93Y+qpYeWrySphQgpNGNr/U+UyUlBkU6n7RAwIgJTz2C+8a8xonZGi6BpSzoQsbVRamr2nlxFDWYNH3j/0=',
TIMESTAMP
)).not_to be
Fixtures::EventWebhook::PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
Fixtures::EventWebhook::FAILING_SIGNATURE,
Fixtures::EventWebhook::TIMESTAMP
)).to be false
end
end

it 'rejects a bad timestamp' do
unless skip_jruby
expect(verify(
PUBLIC_KEY,
PAYLOAD,
SIGNATURE,
'timestamp'
)).not_to be
Fixtures::EventWebhook::PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
Fixtures::EventWebhook::SIGNATURE,
'timestamp'
)).to be false
end
end

it 'rejects a missing signature' do
unless skip_jruby
expect(verify(
Fixtures::EventWebhook::PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
nil,
Fixtures::EventWebhook::TIMESTAMP
)).to be false
end
end

it 'throws an error when using jruby' do
if skip_jruby
expect{ verify(PUBLIC_KEY, PAYLOAD, SIGNATURE, TIMESTAMP) }.to raise_error(SendGrid::EventWebhook::NotSupportedError)
expect{ verify(
Fixtures::EventWebhook::PUBLIC_KEY,
Fixtures::EventWebhook::PAYLOAD,
Fixtures::EventWebhook::SIGNATURE,
Fixtures::EventWebhook::TIMESTAMP
)}.to raise_error(SendGrid::EventWebhook::NotSupportedError)
end
end
end
Expand Down

0 comments on commit 532c24f

Please sign in to comment.