Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
Expand Up @@ -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
19 changes: 19 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,6 +266,25 @@ transloadit.assembly(

Read up more on the <dfn>Notifications</dfn> [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 <dfn>Assemblies</dfn> and their <dfn>Notifications</dfn>.
Expand Down
31 changes: 31 additions & 0 deletions lib/transloadit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
15 changes: 15 additions & 0 deletions lib/transloadit/exception.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
87 changes: 87 additions & 0 deletions test/unit/test_transloadit.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading