From df985275d2f8f69c84652995d03b2f0f8c48716c Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:23:40 +0200 Subject: [PATCH 1/3] Add constant-time Assembly notification verification --- README.md | 17 ++++++++ lib/transloadit.rb | 31 ++++++++++++++ lib/transloadit/exception.rb | 7 ++++ test/unit/test_transloadit.rb | 79 +++++++++++++++++++++++++++++++++++ 4 files changed, 134 insertions(+) diff --git a/README.md b/README.md index 85d8118..d71ee70 100644 --- a/README.md +++ b/README.md @@ -266,6 +266,23 @@ 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. 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..54f39b5 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::InvalidSignature] if authentication fails + # @raise [MultiJson::ParseError] if the authenticated body contains invalid JSON + # + def verify_notification!(fields) + unless fields.is_a?(Hash) && secret.is_a?(String) && !secret.empty? + raise Transloadit::Exception::InvalidSignature + end + + 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..8a81848 100644 --- a/lib/transloadit/exception.rb +++ b/lib/transloadit/exception.rb @@ -8,6 +8,13 @@ module Transloadit::Exception class RequestFailed < StandardError 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..0e329ec 100644 --- a/test/unit/test_transloadit.rb +++ b/test/unit/test_transloadit.rb @@ -8,6 +8,85 @@ @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 require an Auth Secret without exposing notification contents" do + client = Transloadit.new(key: "workspace-key") + error = assert_raises Transloadit::Exception::InvalidSignature do + client.verify_notification!(transloadit: @payload, signature: @signature) + 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 From ff03f60528cb18ad119064965b50a459d826e28e Mon Sep 17 00:00:00 2001 From: tim-kos Date: Tue, 13 Oct 2026 09:30:53 +0200 Subject: [PATCH 2/3] Keep JSON compatible with MultiJson on Ruby 3.1 --- transloadit.gemspec | 2 ++ 1 file changed, 2 insertions(+) diff --git a/transloadit.gemspec b/transloadit.gemspec index afcbef7..f66916f 100644 --- a/transloadit.gemspec +++ b/transloadit.gemspec @@ -23,6 +23,8 @@ Gem::Specification.new do |gem| gem.add_dependency "faraday", ">= 2.0", "< 3.0" gem.add_dependency "faraday-multipart", "~> 1.0" + # Ruby 3.1 uses MultiJson 1.19, whose positional parse options are incompatible with JSON 3. + gem.add_dependency "json", "< 3.0" gem.add_dependency "multi_json" gem.add_dependency "mime-types" From d85aa53b32cf7ec5e1557a68c18057196e036310 Mon Sep 17 00:00:00 2001 From: tim-kos Date: Sun, 18 Oct 2026 19:11:02 +0200 Subject: [PATCH 3/3] Separate missing secrets from invalid notification signatures Verifying without an Auth Secret is a configuration error, so raise Transloadit::Exception::MissingSecret instead of InvalidSignature. Callers such as Rails integrations can then answer forged callbacks with 401 and surface misconfiguration as a server error. Move the JSON < 3 constraint from the gemspec to the Ruby 3.1 test bundle, matching #90, so applications on newer Rubies can still use JSON 3. Co-Authored-By: Claude Opus 5.5 (1M context) --- Gemfile | 4 ++++ README.md | 4 +++- lib/transloadit.rb | 6 +++--- lib/transloadit/exception.rb | 8 ++++++++ test/unit/test_transloadit.rb | 12 ++++++++++-- transloadit.gemspec | 2 -- 6 files changed, 28 insertions(+), 8 deletions(-) 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 d71ee70..e60d286 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,9 @@ status = transloadit.verify_notification!(request.POST) assembly_id = status.fetch('assembly_id') ``` -The client must have an Auth Secret. This helper accepts string or symbol field +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 diff --git a/lib/transloadit.rb b/lib/transloadit.rb index 54f39b5..88e5998 100644 --- a/lib/transloadit.rb +++ b/lib/transloadit.rb @@ -141,13 +141,13 @@ def to_json # # @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) - unless fields.is_a?(Hash) && secret.is_a?(String) && !secret.empty? - raise Transloadit::Exception::InvalidSignature - end + 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] diff --git a/lib/transloadit/exception.rb b/lib/transloadit/exception.rb index 8a81848..c34d303 100644 --- a/lib/transloadit/exception.rb +++ b/lib/transloadit/exception.rb @@ -8,6 +8,14 @@ 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 diff --git a/test/unit/test_transloadit.rb b/test/unit/test_transloadit.rb index 0e329ec..4eadf12 100644 --- a/test/unit/test_transloadit.rb +++ b/test/unit/test_transloadit.rb @@ -68,12 +68,20 @@ end end - it "must require an Auth Secret without exposing notification contents" do + it "must fail as a configuration error without an Auth Secret" do client = Transloadit.new(key: "workspace-key") - error = assert_raises Transloadit::Exception::InvalidSignature do + 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 diff --git a/transloadit.gemspec b/transloadit.gemspec index f66916f..afcbef7 100644 --- a/transloadit.gemspec +++ b/transloadit.gemspec @@ -23,8 +23,6 @@ Gem::Specification.new do |gem| gem.add_dependency "faraday", ">= 2.0", "< 3.0" gem.add_dependency "faraday-multipart", "~> 1.0" - # Ruby 3.1 uses MultiJson 1.19, whose positional parse options are incompatible with JSON 3. - gem.add_dependency "json", "< 3.0" gem.add_dependency "multi_json" gem.add_dependency "mime-types"