Aller à la documentation
Ruby

Vérifier une livraison

`OpenEmail.verify_webhook_signature` : à temps constant, avec une fenêtre de rejeu, et l'événement analysé en retour.

Dans un gestionnaire de requêtes

Une URL de webhook est publique. N'importe quoi sur Internet peut y envoyer en POST du JSON de la bonne forme : un gestionnaire qui lit le type de l'événement sans vérifier la signature est donc une API d'écriture ouverte.

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

Passez le corps BRUT, sous forme de String. L'analyser puis le resérialiser change l'ordre des clés et les espaces, et la signature ne correspondra plus : c'est pourquoi un Hash, comme les params de Rails, lève ArgumentError au lieu d'être vérifié. headers: prend un Hash avec le nom de l'en-tête dans n'importe quelle casse, un env Rack, où l'en-tête arrive sous la forme HTTP_X_OPENEMAIL_SIGNATURE, ou les request.headers de Rails. Une valeur qui est un Array est lue à partir de son premier élément.

Deux points dont cette vérification s'occupe et qu'un contrôle écrit à la main néglige d'ordinaire. Elle compare le MAC en temps constant, si bien qu'on ne peut pas en retrouver le préfixe correct en mesurant le temps de réponse. Elle rejette aussi une livraison décalée de plus de tolerance_seconds: dans un sens comme dans l'autre, cinq minutes sauf indication contraire, pour qu'une requête capturée ne puisse pas être rejouée indéfiniment. tolerance_seconds: 0 désactive la vérification de rejeu. Les deux bugs sont silencieux : un gestionnaire qui a l'un ou l'autre passe tous les tests que vous penseriez à écrire.

Elle lève OpenEmail::WebhookSignatureError à chaque échec : en-tête X-OpenEmail-Signature absent, en-tête qui ne respecte pas la forme t=<seconds>,v1=<hex>, horodatage hors de la fenêtre, ou signature qui ne correspond pas. En cas de succès, elle renvoie le corps analysé en un Hash à clés Symbol, id, type, createdAt et data : il n'y a donc pas de second JSON.parse à rater.

Un secret: absent ou vide lève plutôt ArgumentError, car c'est une erreur dans votre configuration et non une mauvaise livraison. N'interceptez que OpenEmail::WebhookSignatureError et répondez 400 : un serveur mal configuré répond ainsi 500, et la livraison est retentée une fois que vous l'avez corrigé.

Elle utilise OpenSSL, de la bibliothèque standard, que la gem charge déjà : elle n'a donc besoin de rien d'autre.

Dans une application Rack ou Sinatra

En dehors de Rails, passez l'env Rack lui-même comme headers: et lisez le corps brut dans la requête.

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 version Rack est un config.ru complet. Répondez vite dans les deux cas : une livraison qui ne reçoit pas de réponse en 5 secondes compte comme échouée et est renvoyée plus tard. Confiez donc le travail à un job et répondez par un 2xx.

OpenEmail::WEBHOOK_SIGNATURE_HEADERS nomme les trois en-têtes que porte une livraison : X-OpenEmail-Signature, X-OpenEmail-Event avec le type de l'événement, et X-OpenEmail-Delivery avec son id, le même id que dans le corps. Cet id reste identique à chaque nouvelle tentative et à chaque rejeu de l'événement : c'est donc lui qu'il faut stocker pour ignorer les événements déjà traités.

Pendant la rotation du secret

webhooks.rotate_secret n'a aucune fenêtre de recouvrement : les livraisons sont signées avec le nouveau secret dès que l'appel revient. Déployez d'abord un récepteur qui accepte l'un ou l'autre secret, faites la rotation, stockez le nouveau secret là où le récepteur le lit, puis abandonnez l'ancien.

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 envoie un événement synthétique signé : appelez-le donc après la rotation pour prouver que le nouveau secret se vérifie avant de retirer l'ancien.