Class: Mercadopago::Webhook::Validator

Inherits:
Object
  • Object
show all
Defined in:
lib/mercadopago/webhook/validator.rb

Overview

Stateless utility that validates the signature of a MercadoPago webhook.

On failure Validator.validate raises InvalidWebhookSignatureError; on success it returns nil. The comparison is performed in constant time via OpenSSL.fixed_length_secure_compare to mitigate timing attacks.

**QR Code notifications are not signed** by MercadoPago — do not call this validator for those events; they will always fail signature verification.

Class Method Summary collapse

Class Method Details

.validate(x_signature, x_request_id, data_id, secret, tolerance_seconds: nil, supported_versions: nil, now: nil) ⇒ void

This method returns an undefined value.

Validates the signature of a MercadoPago webhook notification.

Parameters:

  • x_signature (String, nil)

    raw value of the x-signature request header

  • x_request_id (String, nil)

    value of the x-request-id request header. May be nil or blank; in that case the request-id: pair is omitted from the manifest before computing the HMAC

  • data_id (String, nil)

    value of the data.id query parameter. May be nil or blank; in that case the id: pair is omitted. Lowercased before HMAC

  • secret (String)

    secret signature configured in Tus Integraciones (HMAC key)

  • tolerance_seconds (Integer, nil) (defaults to: nil)

    optional maximum allowed drift in seconds between the header timestamp and the current clock. Omit to skip the check

  • supported_versions (Array<String>, nil) (defaults to: nil)

    optional ordered list of signature versions to accept. Defaults to %w. The first version found in the header is used

  • now (Proc, nil) (defaults to: nil)

    optional callable returning the current time in milliseconds since Unix epoch. Intended for tests

Raises:

  • (InvalidWebhookSignatureError)

    when the signature is missing, malformed, or does not match the expected HMAC

  • (ArgumentError)

    when secret is nil or empty



100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
# File 'lib/mercadopago/webhook/validator.rb', line 100

def self.validate(x_signature, x_request_id, data_id, secret,
                  tolerance_seconds: nil, supported_versions: nil, now: nil)
  raise ArgumentError, 'secret must not be empty' if secret.nil? || secret.empty?

  x_signature = normalize(x_signature)
  x_request_id = normalize(x_request_id)
  data_id = normalize(data_id)
  versions = supported_versions && !supported_versions.empty? ? supported_versions : DEFAULT_SUPPORTED_VERSIONS
  now_proc = now || -> { (Time.now.to_f * 1000).to_i }

  if x_signature.nil?
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::MISSING_SIGNATURE_HEADER,
      request_id: x_request_id
    )
  end

  ts, hashes = parse_signature_header(x_signature)

  if ts.nil? && hashes.empty?
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::MALFORMED_SIGNATURE_HEADER,
      request_id: x_request_id
    )
  end

  if ts.nil?
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::MISSING_TIMESTAMP,
      request_id: x_request_id
    )
  end

  unless ts.match?(/\A\d+\z/)
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::MALFORMED_SIGNATURE_HEADER,
      request_id: x_request_id,
      timestamp: ts
    )
  end

  received_hash = nil
  versions.each do |v|
    if hashes.key?(v)
      received_hash = hashes[v]
      break
    end
  end

  if received_hash.nil?
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::MISSING_HASH,
      request_id: x_request_id,
      timestamp: ts
    )
  end

  manifest = build_manifest(data_id, x_request_id, ts)
  computed = OpenSSL::HMAC.hexdigest('SHA256', secret, manifest)

  unless constant_time_equal(computed, received_hash)
    raise InvalidWebhookSignatureError.new(
      SignatureFailureReason::SIGNATURE_MISMATCH,
      request_id: x_request_id,
      timestamp: ts
    )
  end

  unless tolerance_seconds.nil?
    drift_ms = (now_proc.call - ts.to_i).abs
    if drift_ms > tolerance_seconds * 1000
      raise InvalidWebhookSignatureError.new(
        SignatureFailureReason::TIMESTAMP_OUT_OF_TOLERANCE,
        request_id: x_request_id,
        timestamp: ts
      )
    end
  end

  nil
end