Saltar para a documentação
Ruby

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.

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

Passe 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 webhook

A 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.

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 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.