Ir a la documentación
Ruby

Verificar una entrega

`OpenEmail.verify_webhook_signature`: en tiempo constante, con una ventana de repetición, y devuelve el evento ya analizado.

En un handler de solicitudes

La URL de un webhook es pública. Cualquier cosa en internet puede hacerle POST de un JSON con la forma correcta, así que un gestor que lee el type del evento sin comprobar la firma es una API de escritura abierta.

app/controllers/open_email_webhooks_controller.rb
class OpenEmailWebhooksController < ActionController::API  def create    event = OpenEmail.verify_webhook_signature(      payload: request.raw_post,      headers: request.headers,      secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"),      tolerance_seconds: 300    )     Rails.logger.info("#{event[:type]} #{event[:data]}")    head :no_content  rescue OpenEmail::WebhookSignatureError    head :bad_request  endend

Pasa el cuerpo EN BRUTO, como String. Analizarlo y volver a serializarlo cambia el orden de las claves y los espacios, y la firma no coincidirá, por eso un Hash, como los params de Rails, lanza ArgumentError en lugar de comprobarse. headers: acepta un Hash con el nombre de la cabecera en cualquier combinación de mayúsculas y minúsculas, un env de Rack, donde la cabecera llega como HTTP_X_OPENEMAIL_SIGNATURE, o los request.headers de Rails. Un valor que es un Array se lee de su primer elemento.

Dos cosas que esto resuelve y que una comprobación casera no suele resolver: compara el MAC en tiempo constante, de modo que el prefijo correcto no puede recuperarse midiendo tiempos, y rechaza una entrega con más de tolerance_seconds: de antigüedad en cualquiera de las dos direcciones, cinco minutos salvo que digas otra cosa, así que una solicitud capturada no se puede repetir para siempre. tolerance_seconds: 0 desactiva la comprobación de repetición. Ambos fallos son silenciosos. Un gestor con cualquiera de los dos pasa todas las pruebas que se te ocurriría escribir.

Lanza OpenEmail::WebhookSignatureError ante cualquier fallo: una cabecera X-OpenEmail-Signature ausente, una que no tenga la forma t=<seconds>,v1=<hex>, una marca de tiempo fuera de la ventana o una firma que no coincide. Si tiene éxito, devuelve el cuerpo analizado como un Hash con claves Symbol, id, type, createdAt y data, así que no hay un segundo JSON.parse en el que equivocarse.

Un secret: ausente o vacío lanza en cambio ArgumentError, porque es un error de tu configuración y no una entrega incorrecta. Captura solo OpenEmail::WebhookSignatureError y responde 400, así un servidor mal configurado responde 500 y la entrega se vuelve a intentar una vez que lo corriges.

Usa OpenSSL, de la biblioteca estándar, que la gema ya carga, así que no necesita nada más.

En una app Rack o Sinatra

Fuera de Rails, pasa el propio env de Rack como headers: y lee el cuerpo en bruto de la solicitud.

require "openemail"require "rack" webhook = lambda do |env|  event = OpenEmail.verify_webhook_signature(    payload: Rack::Request.new(env).body.read,    headers: env,    secret: ENV.fetch("OPENEMAIL_WEBHOOK_SECRET")  )   warn "#{event[:type]} #{event[:id]}"  [204, {}, []]rescue OpenEmail::WebhookSignatureError  [400, {"content-type" => "text/plain"}, ["bad signature"]]end run webhook

La versión Rack es un config.ru completo. Responde rápido en ambas: una entrega que no recibe respuesta en 5 segundos cuenta como fallida y se vuelve a enviar más tarde, así que delega la tarea en un trabajo en segundo plano y responde con un 2xx.

OpenEmail::WEBHOOK_SIGNATURE_HEADERS nombra las tres cabeceras que lleva una entrega: X-OpenEmail-Signature, X-OpenEmail-Event con el tipo del evento, y X-OpenEmail-Delivery con su id, el mismo id que en el cuerpo. Ese id no cambia en ningún reintento ni reenvío del evento, así que es el que hay que guardar para omitir los eventos que ya procesaste.

Mientras rota el secreto

webhooks.rotate_secret no tiene ventana de solapamiento: las entregas se firman con el secreto nuevo desde el momento en que devuelve. Despliega primero un receptor que acepte cualquiera de los dos secretos, rota, guarda el secreto nuevo donde lo lee el receptor y luego retira el antiguo.

two_secrets.rb
def verify_delivery(payload, headers)  secrets = [ENV.fetch("OPENEMAIL_WEBHOOK_SECRET"), ENV["OPENEMAIL_WEBHOOK_SECRET_NEXT"]].compact   secrets.each_with_index do |secret, index|    return OpenEmail.verify_webhook_signature(payload:, headers:, secret:)  rescue OpenEmail::WebhookSignatureError    raise if index == secrets.size - 1  endend

webhooks.test envía un evento sintético firmado, así que llámalo después de la rotación para demostrar que el secreto nuevo se verifica antes de retirar el antiguo.