diff --git a/README.md b/README.md index 0a01349..7d515dd 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,6 @@ This client uses API v2, for v1 refer to [this documentation](https://mailtrap.docs.apiary.io/) - ## Prerequisites To get the most out of this official Mailtrap.io Ruby SDK: @@ -144,6 +143,30 @@ better flexibility in that regard. Go to your _Mailtrap account_ → _Email Send → _Sending Domains_ → _Your domain_ → _SMTP/API Settings_ to find the SMTP configuration example. +### Verifying webhook signatures + +Mailtrap signs every outbound webhook with HMAC-SHA256 and sends the +lowercase hex digest in the `Mailtrap-Signature` header. Verify the signature +against the raw request body using the `signing_secret` returned when you +created the webhook: + +```ruby +require 'mailtrap' + +# `raw_body` must be the unparsed request body bytes — do NOT re-serialize +# the parsed JSON, as that may reorder keys and invalidate the signature. +valid = Mailtrap::Webhooks.verify_signature( + payload: raw_body, + signature: request.headers['Mailtrap-Signature'], + signing_secret: ENV.fetch('MAILTRAP_WEBHOOK_SIGNING_SECRET') +) + +head :unauthorized unless valid +``` + +The helper performs a constant-time comparison and returns `false` (rather +than raising) for empty, missing, or malformed signatures. + ### Multiple Mailtrap Clients You can configure two Mailtrap clients to operate simultaneously. This setup is @@ -176,7 +199,7 @@ Email API: - Full Email Sending – [`full.rb`](examples/full.rb) - Batch Sending – [`batch.rb`](examples/batch.rb) -- Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb) +- Sending Domains API – [`sending_domains_api.rb`](examples/sending_domains_api.rb) - Sending Stats API – [`stats_api.rb`](examples/stats_api.rb) - Email Logs API – [`email_logs_api.rb`](examples/email_logs_api.rb) - Webhooks API – [`webhooks_api.rb`](examples/webhooks_api.rb) @@ -199,6 +222,7 @@ General: - Billing API – [`billing_api.rb`](examples/billing_api.rb) - Templates API – [`email_templates_api.rb`](examples/email_templates_api.rb) - Action Mailer – [`action_mailer.rb`](examples/action_mailer.rb) +- Verifying webhook signatures – [`webhooks_signature_verification.rb`](examples/webhooks_signature_verification.rb) ## Migration guide v1 → v2 @@ -213,14 +237,14 @@ Bug reports and pull requests are welcome on [GitHub](https://github.com/railswa ## Development After checking out the repo, run `bin/setup` to install dependencies. Then, run -`rake spec` to run the tests. You can also run `bin/console` for an interactive +`rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment. To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, -push git commits and the created tag, and push the `.gem` file to +push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org). To run the documentation server, first generate the documentation with @@ -241,4 +265,4 @@ Everyone interacting in the Mailtrap project's codebases, issue trackers, chat r ## Compatibility with previous releases -Versions of this package up to 2.0.2 were an [unofficial client](https://github.com/vchin/mailtrap-client) developed by [@vchin](https://github.com/vchin). Package version 3 is a completely new package. +Versions of this package up to 2.0.2 were an [unofficial client](https://github.com/vchin/mailtrap-client) developed by [@vchin](https://github.com/vchin). Package version 3 is a completely new package. diff --git a/examples/webhooks_signature_verification.rb b/examples/webhooks_signature_verification.rb new file mode 100644 index 0000000..220fd93 --- /dev/null +++ b/examples/webhooks_signature_verification.rb @@ -0,0 +1,14 @@ +require 'mailtrap' + +# --- Direct verification (e.g. for unit tests or custom routers) ---------- +payload = '{"event":"delivery","message_id":"abc-123"}' +signing_secret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e' +signature = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload) + +verified = Mailtrap::Webhooks.verify_signature( + payload: payload, + signature: signature, + signing_secret: signing_secret +) +# => true +raise 'Signature verification failed!' unless verified diff --git a/lib/mailtrap.rb b/lib/mailtrap.rb index 7a0f680..35f1b2c 100644 --- a/lib/mailtrap.rb +++ b/lib/mailtrap.rb @@ -27,6 +27,7 @@ require_relative 'mailtrap/sandbox_attachments_api' require_relative 'mailtrap/stats_api' require_relative 'mailtrap/webhooks_api' +require_relative 'mailtrap/webhooks' module Mailtrap # @!macro api_errors diff --git a/lib/mailtrap/webhooks.rb b/lib/mailtrap/webhooks.rb new file mode 100644 index 0000000..67c7d75 --- /dev/null +++ b/lib/mailtrap/webhooks.rb @@ -0,0 +1,49 @@ +# frozen_string_literal: true + +require 'openssl' + +module Mailtrap + # Helpers for working with inbound Mailtrap webhooks. + # + # @see https://docs.mailtrap.io/email-api-smtp/advanced/webhooks#verifying-the-signature + module Webhooks + # Hex-encoded HMAC-SHA256 signature length. + SIGNATURE_HEX_LENGTH = 64 + + # Verifies the HMAC-SHA256 signature of a Mailtrap webhook payload. + # + # Mailtrap signs every outbound webhook by computing + # `HMAC-SHA256(signing_secret, raw_request_body)` and sending the lowercase + # hex digest in the `Mailtrap-Signature` HTTP header. Compute the same + # digest on your side and compare it in constant time. + # + # The comparison is performed with {OpenSSL.fixed_length_secure_compare} to + # avoid timing side-channels. + # + # The method never raises on inputs that could plausibly arrive over the + # wire (empty strings, wrong-length signatures, non-hex characters, missing + # secret) — it simply returns `false`. This makes it safe to call directly + # from a controller without rescuing. + # + # @param payload [String] The raw request body, exactly as received. + # **Do not** parse and re-serialize the JSON — re-encoding may reorder + # keys or alter whitespace and invalidate the signature. + # @param signature [String] The value of the `Mailtrap-Signature` HTTP + # header (lowercase hex string). + # @param signing_secret [String] The webhook's `signing_secret`, returned + # by {WebhooksAPI#create} on webhook creation. + # @return [Boolean] `true` if the signature is valid for the given payload + # and secret, `false` otherwise. + def self.verify_signature(payload:, signature:, signing_secret:) + return false unless [payload, signature, signing_secret].all? { |v| v.is_a?(String) && !v.empty? } + return false if signature.bytesize != SIGNATURE_HEX_LENGTH + + expected = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload) + + OpenSSL.fixed_length_secure_compare(expected, signature) + rescue ArgumentError + # fixed_length_secure_compare raises ArgumentError on length mismatch + false + end + end +end diff --git a/spec/mailtrap/webhooks_spec.rb b/spec/mailtrap/webhooks_spec.rb new file mode 100644 index 0000000..7e87133 --- /dev/null +++ b/spec/mailtrap/webhooks_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +RSpec.describe Mailtrap::Webhooks do + # --------------------------------------------------------------------------- + # Cross-SDK fixture + # + # The (payload, signing_secret, expected_signature) triple below is the + # canonical fixture shared verbatim by every official Mailtrap SDK + # (mailtrap-ruby, mailtrap-python, mailtrap-php, mailtrap-nodejs, + # mailtrap-java, mailtrap-dotnet). Any change here MUST be mirrored in the + # equivalent test files in the other SDKs so the helpers stay byte-for-byte + # compatible across languages. + # --------------------------------------------------------------------------- + let(:fixture_payload) do + '{"event":"delivery","sending_stream":"transactional","category":"welcome",' \ + '"message_id":"a8b1d8f6-1f8d-4a3c-9b2e-1a2b3c4d5e6f",' \ + '"email":"recipient@example.com",' \ + '"event_id":"f1e2d3c4-b5a6-7890-1234-567890abcdef",' \ + '"timestamp":1716070000}' + end + let(:fixture_signing_secret) { '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e' } + let(:fixture_expected_signature) { '6d262e2611cd09be1f948382b5c611d63b0e585c4c9c5e40139d6ac3876d5433' } + + describe '.verify_signature' do + # --- 1. Valid signature for given payload + secret ---------------------- + context 'with a valid signature, payload and secret' do + it 'returns true' do + result = described_class.verify_signature( + payload: fixture_payload, + signature: fixture_expected_signature, + signing_secret: fixture_signing_secret + ) + + expect(result).to be true + end + end + + # --- 2. Wrong secret ---------------------------------------------------- + context 'with a wrong signing secret' do + it 'returns false' do + result = described_class.verify_signature( + payload: fixture_payload, + signature: fixture_expected_signature, + signing_secret: 'ffffffffffffffffffffffffffffffff' + ) + + expect(result).to be false + end + end + + # --- 3. Payload tampered (one byte changed) ----------------------------- + context 'when the payload is tampered with' do + it 'returns false' do + tampered = fixture_payload.sub('delivery', 'Delivery') + + result = described_class.verify_signature( + payload: tampered, + signature: fixture_expected_signature, + signing_secret: fixture_signing_secret + ) + + expect(result).to be false + end + end + + # --- 4. Signature with wrong length ------------------------------------- + context 'with a signature of the wrong length' do + it 'returns false without raising' do + too_short = fixture_expected_signature[0..30] + + result = described_class.verify_signature( + payload: fixture_payload, + signature: too_short, + signing_secret: fixture_signing_secret + ) + + expect(result).to be false + end + end + + # --- 5. Signature with non-hex characters ------------------------------- + context 'with non-hex characters in the signature' do + it 'returns false without raising' do + not_hex = 'z' * Mailtrap::Webhooks::SIGNATURE_HEX_LENGTH + + result = described_class.verify_signature( + payload: fixture_payload, + signature: not_hex, + signing_secret: fixture_signing_secret + ) + + expect(result).to be false + end + end + + # --- 6. Empty signature string ------------------------------------------ + context 'with an empty signature' do + it 'returns false' do + result = described_class.verify_signature( + payload: fixture_payload, + signature: '', + signing_secret: fixture_signing_secret + ) + + expect(result).to be false + end + end + + # --- 7. Empty signing_secret -------------------------------------------- + context 'with an empty signing secret' do + it 'returns false' do + result = described_class.verify_signature( + payload: fixture_payload, + signature: fixture_expected_signature, + signing_secret: '' + ) + + expect(result).to be false + end + end + + # --- 8. Empty payload + non-empty signature ----------------------------- + context 'with an empty payload but a non-empty signature' do + it 'returns false' do + result = described_class.verify_signature( + payload: '', + signature: fixture_expected_signature, + signing_secret: fixture_signing_secret + ) + + expect(result).to be false + end + end + + # --- 9. Known-good cross-SDK fixture ------------------------------------ + context 'when verifying the shared cross-SDK fixture' do + it 'matches the hardcoded HMAC-SHA256 digest' do + # Recompute the digest in-place so a regression in OpenSSL or the + # fixture itself fails loudly: this is the byte-for-byte contract + # every other Mailtrap SDK must satisfy. + computed = OpenSSL::HMAC.hexdigest('SHA256', fixture_signing_secret, fixture_payload) + + expect(computed).to eq(fixture_expected_signature) + expect( + described_class.verify_signature( + payload: fixture_payload, + signature: fixture_expected_signature, + signing_secret: fixture_signing_secret + ) + ).to be true + end + end + end +end