Verificar uma entrega
`OpenEmail.verify_webhook_signature`: em tempo constante, com uma janela de repetição, e devolve o evento já analisado.
Num handler de pedidos
Um URL de webhook é público. Qualquer coisa na internet lhe pode enviar por POST um JSON com a forma certa, por isso um handler que lê o type do evento sem verificar a assinatura é uma API de escrita aberta.
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 endendPasse o corpo EM BRUTO, como String. Analisar e voltar a serializar altera a ordem das chaves e os espaços em branco, e a assinatura deixa de corresponder, e é por isso que um Hash, como os params do Rails, lança ArgumentError em vez de ser verificado. headers: aceita um Hash com o nome do cabeçalho em maiúsculas ou minúsculas, um env do Rack, onde o cabeçalho chega como HTTP_X_OPENEMAIL_SIGNATURE, ou o request.headers do Rails. Um valor que seja um Array é lido a partir do primeiro elemento.
Duas coisas que isto trata e que uma verificação feita à mão costuma não tratar: compara o MAC em tempo constante, para que o prefixo correto não possa ser recuperado cronometrando-o, e rejeita uma entrega com mais de tolerance_seconds: de diferença em qualquer das direções, cinco minutos salvo indicação em contrário, para que um pedido capturado não possa ser reutilizado para sempre. tolerance_seconds: 0 desliga a verificação de repetição. Ambos os bugs são silenciosos. Um handler com qualquer um deles passa em todos os testes que lhe ocorreria escrever.
Lança OpenEmail::WebhookSignatureError em todas as falhas: um cabeçalho X-OpenEmail-Signature em falta, um que não esteja na forma t=<seconds>,v1=<hex>, um carimbo temporal fora da janela, ou uma assinatura que não corresponde. Em caso de sucesso, devolve o corpo analisado num Hash com chaves Symbol, id, type, createdAt e data, por isso não há um segundo JSON.parse para errar.
Um secret: em falta ou vazio lança antes ArgumentError, porque isso é um erro na sua configuração e não uma entrega inválida. Apanhe apenas OpenEmail::WebhookSignatureError e responda 400, para que um servidor mal configurado responda 500 e a entrega seja tentada de novo depois de o corrigir.
Usa o OpenSSL da biblioteca padrão, que a gem já carrega, por isso não precisa de mais nada.
Numa aplicação Rack ou Sinatra
Fora do Rails, passe o próprio env do Rack como headers: e leia o corpo em bruto a partir do pedido.
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 webhookA versão Rack é um config.ru completo. Responda depressa em qualquer uma delas: uma entrega que não obtenha resposta em 5 segundos conta como falhada e é enviada de novo mais tarde, por isso passe o trabalho a uma tarefa e responda com um 2xx.
OpenEmail::WEBHOOK_SIGNATURE_HEADERS nomeia os três cabeçalhos que uma entrega traz: X-OpenEmail-Signature, X-OpenEmail-Event com o tipo do evento, e X-OpenEmail-Delivery com o seu id, o mesmo id que está no corpo. Esse id mantém-se igual em todas as repetições e reenvios do evento, por isso é esse que deve guardar quando ignora eventos que já tratou.
Durante a rotação do segredo
webhooks.rotate_secret não tem janela de sobreposição: as entregas são assinadas com o novo segredo a partir do momento em que a chamada retorna. Primeiro faça o deploy de um recetor que aceite qualquer um dos segredos, depois rode, guarde o novo segredo onde o recetor o lê e, por fim, abandone o antigo.
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 endendwebhooks.test envia um evento sintético assinado, por isso chame-o depois da rotação para provar que o novo segredo é verificado antes de remover o antigo.