diff --git a/Gemfile b/Gemfile index ce1d73b..6b6f03f 100644 --- a/Gemfile +++ b/Gemfile @@ -5,4 +5,8 @@ gem "minitest" gem "simplecov" gem "simplecov-cobertura" +# Ruby 3.1 resolves MultiJson 1.19, which cannot parse JSON 3 (ruby-sdk#89). +# Keep its test bundle compatible without restricting consumers on newer Rubies. +gem "json", "< 3.0" if RUBY_VERSION.start_with?("3.1.") + gemspec diff --git a/README.md b/README.md index 85d8118..e60d286 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,25 @@ transloadit.assembly( Read up more on the Notifications [on Transloadit's documentation page](https://transloadit.com/docs/#notifications) +Verify a notification before trusting its status: + +```ruby +# The original POST form values, without parsing or reserializing the JSON first. +status = transloadit.verify_notification!(request.POST) +assembly_id = status.fetch('assembly_id') +``` + +The client must have an Auth Secret; without one the helper raises +`Transloadit::Exception::MissingSecret`, a configuration error rather than an +authentication failure. This helper accepts string or symbol field +keys and both the legacy SHA-1 signature and its `sha1:`-prefixed form. It compares +the signature in constant time and raises `Transloadit::Exception::InvalidSignature` +for missing, malformed, or mismatched authentication. A successfully authenticated +body that contains invalid JSON raises `MultiJson::ParseError`. + +Authenticate and process notifications idempotently using your Assembly IDs; +signature verification alone does not prevent a valid notification from being replayed. + ### 7. Other Assembly methods Transloadit also provides methods to retrieve/replay Assemblies and their Notifications. diff --git a/lib/transloadit.rb b/lib/transloadit.rb index a9f6f1f..88e5998 100644 --- a/lib/transloadit.rb +++ b/lib/transloadit.rb @@ -135,6 +135,37 @@ def to_json MultiJson.dump(to_hash) end + # + # Verifies an Assembly notification before parsing its status. Pass the received + # form fields unchanged: reserializing the JSON changes the signed bytes. + # + # @param [Hash] fields the transloadit and signature notification form fields + # @return [Hash] the verified Assembly status + # @raise [Transloadit::Exception::MissingSecret] if the client has no Auth Secret + # @raise [Transloadit::Exception::InvalidSignature] if authentication fails + # @raise [MultiJson::ParseError] if the authenticated body contains invalid JSON + # + def verify_notification!(fields) + raise Transloadit::Exception::MissingSecret unless secret.is_a?(String) && !secret.empty? + raise Transloadit::Exception::InvalidSignature unless fields.is_a?(Hash) + + payload = fields["transloadit"] || fields[:transloadit] + signature = fields["signature"] || fields[:signature] + unless payload.is_a?(String) && signature.is_a?(String) + raise Transloadit::Exception::InvalidSignature + end + + match = /\A(?:sha1:)?([0-9a-f]{40})\z/.match(signature) + raise Transloadit::Exception::InvalidSignature unless match + + expected = OpenSSL::HMAC.hexdigest("SHA1", secret, payload) + unless OpenSSL.fixed_length_secure_compare(expected, match[1]) + raise Transloadit::Exception::InvalidSignature + end + + MultiJson.load(payload) + end + # @param workspace [String] Workspace slug # @param template [String] Template slug or template ID # @param input [String] Input value that is provided as `${fields.input}` in the template diff --git a/lib/transloadit/exception.rb b/lib/transloadit/exception.rb index 4071199..c34d303 100644 --- a/lib/transloadit/exception.rb +++ b/lib/transloadit/exception.rb @@ -8,6 +8,21 @@ module Transloadit::Exception class RequestFailed < StandardError end + # Exception raised when a notification is verified without a trusted Auth Secret. + # This is a configuration error, unlike a notification that fails authentication. + class MissingSecret < StandardError + def initialize + super("An Auth Secret is required to verify notifications") + end + end + + # Exception raised when an Assembly notification cannot be authenticated. + class InvalidSignature < StandardError + def initialize + super("Invalid notification signature") + end + end + # # Exception raised when Rate limit error response is returned from the API. # See {Rate Limiting}[https://transloadit.com/docs/api-docs/#rate-limiting] diff --git a/test/unit/test_transloadit.rb b/test/unit/test_transloadit.rb index 4a07589..4eadf12 100644 --- a/test/unit/test_transloadit.rb +++ b/test/unit/test_transloadit.rb @@ -8,6 +8,93 @@ @max_size = 100 end + describe "notification verification" do + before do + @client = Transloadit.new(key: "workspace-key", secret: "notification-secret") + @payload = "{\n \"ok\": \"ASSEMBLY_COMPLETED\", \"assembly_id\": \"assembly-id\"\n}" + @signature = OpenSSL::HMAC.hexdigest("SHA1", @client.secret, @payload) + end + + it "must verify prefixed signatures over the exact received JSON" do + status = @client.verify_notification!( + "transloadit" => @payload, "signature" => "sha1:#{@signature}" + ) + + _(status).must_equal "ok" => "ASSEMBLY_COMPLETED", "assembly_id" => "assembly-id" + end + + it "must accept legacy signatures and symbol form-field keys" do + status = @client.verify_notification!(transloadit: @payload, signature: @signature) + + _(status.fetch("assembly_id")).must_equal "assembly-id" + end + + it "must reject a notification signed with a different secret" do + signature = OpenSSL::HMAC.hexdigest("SHA1", "other-secret", @payload) + + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: @payload, signature: signature) + end + end + + it "must reject changed payload bytes even when the parsed status is identical" do + compact = MultiJson.dump(MultiJson.load(@payload)) + + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: compact, signature: @signature) + end + end + + it "must reject malformed signatures before comparing or parsing" do + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: "invalid JSON", signature: "sha1:short") + end + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: @payload, signature: "sha256:#{@signature}") + end + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: @payload, signature: "g" * 40) + end + end + + it "must reject missing or incorrectly typed fields" do + assert_raises(Transloadit::Exception::InvalidSignature) { @client.verify_notification!({}) } + assert_raises(Transloadit::Exception::InvalidSignature) { @client.verify_notification!(nil) } + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: {}, signature: @signature) + end + assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: @payload, signature: nil) + end + end + + it "must fail as a configuration error without an Auth Secret" do + client = Transloadit.new(key: "workspace-key") + error = assert_raises Transloadit::Exception::MissingSecret do + client.verify_notification!(transloadit: @payload, signature: @signature) + end + + _(error.message).must_equal "An Auth Secret is required to verify notifications" + end + + it "must report authentication failures without exposing notification contents" do + error = assert_raises Transloadit::Exception::InvalidSignature do + @client.verify_notification!(transloadit: @payload, signature: "0" * 40) + end + + _(error.message).must_equal "Invalid notification signature" + end + + it "must only parse malformed JSON after successfully verifying its signature" do + payload = "invalid JSON" + signature = OpenSSL::HMAC.hexdigest("SHA1", @client.secret, payload) + + assert_raises MultiJson::ParseError do + @client.verify_notification!(transloadit: payload, signature: signature) + end + end + end + it "must allow initialization" do t = Transloadit.new(key: @key, secret: @secret) _(t).must_be_kind_of Transloadit