Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

added webhook verification to mux_ruby + helper-func infrastructure #46

Open
wants to merge 14 commits into
base: master
Choose a base branch
from
Open
6 changes: 6 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
root = true

[*.rb]
indent_size = 2
indent_style = space
insert_final_newline = true
2 changes: 2 additions & 0 deletions .github/workflows/ci.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ jobs:
run: gem install bundler
- name: Install Ruby Dependencies
run: bundle install --jobs 4 --retry 3
- name: Run unit test
run: rake spec
- name: Run Integration Tests
env:
MUX_TOKEN_ID: ${{ secrets.MUX_TOKEN_ID }}
Expand Down
2 changes: 2 additions & 0 deletions .github/workflows/release.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ jobs:
run: gem install bundler
- name: Install Ruby Dependencies
run: bundle install --jobs 4 --retry 3
- name: Run unit test
run: rake spec
- name: Run Integration Tests
env:
MUX_TOKEN_ID: ${{ secrets.MUX_TOKEN_ID }}
Expand Down
8 changes: 8 additions & 0 deletions .openapi-generator-ignore
Original file line number Diff line number Diff line change
Expand Up @@ -25,3 +25,11 @@

git_push.sh
.travis.yml

lib/helpers.rb
lib/helpers/*.rb

spec/api_client_spec.rb
spec/configuration_spec.rb
spec/api/*.rb
spec/models/*.rb
4 changes: 3 additions & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
PATH
remote: .
specs:
mux_ruby (3.0.0)
mux_ruby (3.3.1)
securecompare (= 1.0.0)
typhoeus (~> 1.0, >= 1.0.1)

GEM
Expand Down Expand Up @@ -50,6 +51,7 @@ GEM
ruby-progressbar (~> 1.7)
unicode-display_width (>= 1.4.0, < 1.6)
ruby-progressbar (1.11.0)
securecompare (1.0.0)
solid_assert (1.0.0)
typhoeus (1.4.0)
ethon (>= 0.9.0)
Expand Down
1 change: 1 addition & 0 deletions gen/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ clean-products:

build: ensure clean-products
${OAS_CLI} generate -g "${GENERATOR_TYPE}" -c "${CONFIG_PATH}" -t "${TEMPLATE_DIR}" -o "${OUTPUT_DIR}" -i "${OAS_SPEC_PATH}"
cp -R ${OUTPUT_DIR}/lib-manual/* ${OUTPUT_DIR}/lib/mux_ruby

config-help: ensure
${OAS_CLI} config-help -g "${GENERATOR_TYPE}"
Expand Down
3 changes: 3 additions & 0 deletions gen/templates/gem.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,9 @@ require '{{importPath}}'
{{/apis}}
{{/apiInfo}}

# Custom imports
require 'mux_ruby/helpers'

module {{moduleName}}
class << self
# Customize default settings for the SDK using block.
Expand Down
1 change: 1 addition & 0 deletions gen/templates/gemspec.mustache
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ Gem::Specification.new do |s|
{{^isFaraday}}
s.add_runtime_dependency 'typhoeus', '~> 1.0', '>= 1.0.1'
{{/isFaraday}}
s.add_runtime_dependency 'securecompare', '1.0.0'

s.add_development_dependency 'rspec', '~> 3.6', '>= 3.6.0'

Expand Down
1 change: 1 addition & 0 deletions lib-manual/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require 'mux_ruby/helpers/webhook_verifier'
74 changes: 74 additions & 0 deletions lib-manual/helpers/webhook_verifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require 'openssl'
require 'securecompare'

module MuxRuby
module Helpers
class WebhookVerifier
DEFAULT_TOLERANCE = 300
HEADER_SCHEMES = [:v1].freeze

# Initializer.
#
# @param [String] secret the webhook secret from your Mux control panel
# @param [Integer] tolerance the signature timing tolerance, in seconds (generally leave as is)
# @param [Array<Symbol>] header_schemes the list of accepted header schemes for this verifier
def initialize(secret: nil, tolerance: DEFAULT_TOLERANCE, header_schemes: [:v1])
raise "secret '#{secret.inspect}' must be a String" unless secret.is_a?(String)
raise "tolerance '#{tolerance.inspect}' must be a positive number." \
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure we use tolerance anywhere else, typically it's expiration. Why the change here?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It's tolerance in the node SDK.

unless tolerance.is_a?(Integer) && tolerance > 0
raise "header schemes '#{header_schemes.inspect}' must all be in HEADER_SCHEMES: '#{HEADER_SCHEMES.inspect}'" \
unless header_schemes.all? { |h| HEADER_SCHEMES.include?(h) }

@secret = secret.dup
@tolerance = tolerance
@header_schemes = header_schemes.map(&:to_s).sort.uniq.reverse
end

# Initializer.
#
# @param [String] request_body the raw, unmodified body of the request
# @param [String] header the Mux-Signature header
# @param [Time] current_timestamp (for test purposes) the current time expected for this webhook (defaults to `Time.utc`)
# @return [Boolean] true if webhook is verified; false otherwise
def verify(request_body:, header:, current_timestamp: Time.now.getutc)
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I don't remember this from Ruby land circa 2013, but is arg:, just specifying a nullish default value?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It specifies a named argument rather than a positional argument. I think they're clearer.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@mmcc this must have come after your Ruby time (it was right near the end of my Ruby time) -- keyword args, which are a fantastic language feature:

https://thoughtbot.com/blog/ruby-2-keyword-arguments

In this case calling verify without request_body: and header: args would cause a runtime error.

In the past we would sometimes use an options hash and then have conditionals inside method to check for things that were passed into the options hash.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, this just replaces the options hash.

@dylanjha , I didn't know you were a Ruby guy. Oh, do I have code for you to review...!

header_parts = self.kv_from_header(header)

timestamp = header_parts['t'].to_i
scheme_used = @header_schemes.detect { |scheme| !header_parts[scheme].nil? }
mux_signature = header_parts[scheme_used]

if timestamp.nil? || mux_signature.nil?
false
else
case scheme_used
when 'v1'
expected_signature = self.compute_v1_signature("#{timestamp}.#{request_body}")
if SecureCompare.compare(expected_signature, mux_signature)
delta = current_timestamp.to_i - timestamp

if (delta <= @tolerance)
true
else
false
end
else
false
end
else
warn "Unhandled *but recognized* Mux signature format '#{scheme_used}'. Please contact Mux."
false
end
end
end

private
def kv_from_header(header)
Hash[header.strip.gsub(/^Mux-Signature:/, "").strip.split(",").map { |tup| tup.split("=") }]
end

def compute_v1_signature(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload)
end
end
end
end
eropple marked this conversation as resolved.
Show resolved Hide resolved
3 changes: 3 additions & 0 deletions lib/mux_ruby.rb
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,9 @@
require 'mux_ruby/api/url_signing_keys_api'
require 'mux_ruby/api/video_views_api'

# Custom imports
require 'mux_ruby/helpers'

module MuxRuby
class << self
# Customize default settings for the SDK using block.
Expand Down
1 change: 1 addition & 0 deletions lib/mux_ruby/helpers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
require 'mux_ruby/helpers/webhook_verifier'
74 changes: 74 additions & 0 deletions lib/mux_ruby/helpers/webhook_verifier.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
require 'openssl'
require 'securecompare'

module MuxRuby
module Helpers
class WebhookVerifier
DEFAULT_TOLERANCE = 300
HEADER_SCHEMES = [:v1].freeze

# Initializer.
#
# @param [String] secret the webhook secret from your Mux control panel
# @param [Integer] tolerance the signature timing tolerance, in seconds (generally leave as is)
# @param [Array<Symbol>] header_schemes the list of accepted header schemes for this verifier
def initialize(secret: nil, tolerance: DEFAULT_TOLERANCE, header_schemes: [:v1])
raise "secret '#{secret.inspect}' must be a String" unless secret.is_a?(String)
raise "tolerance '#{tolerance.inspect}' must be a positive number." \
unless tolerance.is_a?(Integer) && tolerance > 0
raise "header schemes '#{header_schemes.inspect}' must all be in HEADER_SCHEMES: '#{HEADER_SCHEMES.inspect}'" \
unless header_schemes.all? { |h| HEADER_SCHEMES.include?(h) }

@secret = secret.dup
@tolerance = tolerance
@header_schemes = header_schemes.map(&:to_s).sort.uniq.reverse
end

# Initializer.
#
# @param [String] request_body the raw, unmodified body of the request
# @param [String] header the Mux-Signature header
# @param [Time] current_timestamp (for test purposes) the current time expected for this webhook (defaults to `Time.utc`)
# @return [Boolean] true if webhook is verified; false otherwise
def verify(request_body:, header:, current_timestamp: Time.now.getutc)
header_parts = self.kv_from_header(header)

timestamp = header_parts['t'].to_i
scheme_used = @header_schemes.detect { |scheme| !header_parts[scheme].nil? }
mux_signature = header_parts[scheme_used]

if timestamp.nil? || mux_signature.nil?
false
else
case scheme_used
when 'v1'
expected_signature = self.compute_v1_signature("#{timestamp}.#{request_body}")
if SecureCompare.compare(expected_signature, mux_signature)
delta = current_timestamp.to_i - timestamp

if (delta <= @tolerance)
true
else
false
end
else
false
end
else
warn "Unhandled *but recognized* Mux signature format '#{scheme_used}'. Please contact Mux."
false
end
end
end

private
def kv_from_header(header)
Hash[header.strip.gsub(/^Mux-Signature:/, "").strip.split(",").map { |tup| tup.split("=") }]
end

def compute_v1_signature(payload)
OpenSSL::HMAC.hexdigest("SHA256", @secret, payload)
end
end
end
end
1 change: 1 addition & 0 deletions mux_ruby.gemspec
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ Gem::Specification.new do |s|
s.required_ruby_version = ">= 2.4"

s.add_runtime_dependency 'typhoeus', '~> 1.0', '>= 1.0.1'
s.add_runtime_dependency 'securecompare', '1.0.0'

s.add_development_dependency 'rspec', '~> 3.6', '>= 3.6.0'

Expand Down
Loading