-
Notifications
You must be signed in to change notification settings - Fork 8
MT-22022: Add webhook signature verification helper #110
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
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,14 @@ | ||
| require 'mailtrap' | ||
|
|
||
|
Rabsztok marked this conversation as resolved.
|
||
| # --- 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) | ||
|
Comment on lines
+5
to
+6
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Use a clear placeholder secret to avoid secret-scanner false positives. The current hex-like literal can be mistaken for a real credential and trigger tooling noise in downstream scans. 🔧 Minimal tweak-signing_secret = '8d9a3c0e7f5b2d4a6c1e9f8b3a7d5c2e'
+signing_secret = 'example-webhook-signing-secret'
signature = OpenSSL::HMAC.hexdigest('SHA256', signing_secret, payload)🧰 Tools🪛 Betterleaks (1.2.0)[high] 5-5: Detected a Generic API Key, potentially exposing access to various services and sensitive operations. (generic-api-key) 🤖 Prompt for AI Agents
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I would use placeholder secret and signature too There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I like Stripe examples where they show a real world case of fetching the signature from HTTP header and passing to verifier https://docs.stripe.com/webhooks?verify=check-signatures-library#verify-webhook-signatures-with-official-libraries There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
require 'mailtrap'
require 'sinatra'
# Store your signing secret in the environment, never in source code
SIGNING_SECRET = ENV.fetch('MAILTRAP_WEBHOOK_SIGNING_SECRET')
# Disable Sinatra's body caching so we can read the raw bytes
post '/webhooks/mailtrap' do
request.body.rewind
payload = request.body.read
signature = request.env['HTTP_MAILTRAP_SIGNATURE']
unless Mailtrap::Webhooks.verify_signature(
payload: payload,
signature: signature,
signing_secret: SIGNING_SECRET
)
halt 400, 'Invalid signature'
end
# Signature verified — safe to process the event
event = JSON.parse(payload)
# handle event...
status 200
endKey parallels with the Stripe pattern:
Would you like me to open a plan to replace the current |
||
|
|
||
| verified = Mailtrap::Webhooks.verify_signature( | ||
| payload: payload, | ||
| signature: signature, | ||
| signing_secret: signing_secret | ||
| ) | ||
| # => true | ||
| raise 'Signature verification failed!' unless verified | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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":"[email protected]",' \ | ||
| '"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 |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Same as I commented in .NET SDK PR - I don't think it's such a core functionality that it must be mentioned in general README