Перейти к документации
Ruby

Проверка доставки

`OpenEmail.verify_webhook_signature`: за постоянное время, с окном защиты от повторов и с разобранным событием в результате.

В обработчике запроса

URL вебхука публичен. Кто угодно в интернете может отправить на него POST с JSON правильной формы, поэтому обработчик, который читает type события, не проверив подпись, является открытым API для записи.

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

Передавайте ИСХОДНОЕ тело в виде String. Разбор и повторная сериализация меняют порядок ключей и пробелы, и подпись не совпадёт, поэтому Hash, например params из Rails, выбрасывает ArgumentError, а не проверяется. headers: принимает Hash с именем заголовка в любом регистре, окружение Rack, где заголовок приходит как HTTP_X_OPENEMAIL_SIGNATURE, или request.headers из Rails. Значение-Array читается по первому элементу.

Две вещи, которые эта функция учитывает, а самодельная проверка обычно нет: она сравнивает MAC за постоянное время, поэтому правильный префикс нельзя восстановить по времени ответа, и она отклоняет доставку, расхождение которой по времени в любую сторону больше tolerance_seconds: (пять минут, если не указано иное), поэтому перехваченный запрос нельзя воспроизводить вечно. tolerance_seconds: 0 отключает проверку на повтор. Обе ошибки незаметны. Обработчик с любой из них проходит все тесты, которые придут вам в голову.

Она выбрасывает OpenEmail::WebhookSignatureError при любом сбое: отсутствующий заголовок X-OpenEmail-Signature, заголовок не в форме t=<seconds>,v1=<hex>, метка времени вне окна или несовпадающая подпись. При успехе возвращает тело, разобранное в Hash с ключами типа Symbol: id, type, createdAt и data, поэтому второго JSON.parse, в котором можно ошибиться, нет.

Отсутствующий или пустой secret: вместо этого выбрасывает ArgumentError, потому что это ошибка в вашей конфигурации, а не плохая доставка. Перехватывайте только OpenEmail::WebhookSignatureError и отвечайте 400, тогда неправильно настроенный сервер ответит 500, и доставка будет повторена, когда вы это исправите.

Она использует OpenSSL из стандартной библиотеки, который гем уже загружает, поэтому больше ничего не нужно.

В приложении Rack или Sinatra

Вне Rails передайте само окружение Rack как headers: и прочитайте исходное тело из запроса.

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

Версия для Rack является целым config.ru. В обеих отвечайте быстро: доставка, не получившая ответа в течение 5 секунд, считается неудачной и позже отправляется снова, поэтому передайте работу заданию и ответьте 2xx.

OpenEmail::WEBHOOK_SIGNATURE_HEADERS называет три заголовка, которые несёт доставка: X-OpenEmail-Signature, X-OpenEmail-Event с типом события и X-OpenEmail-Delivery с его идентификатором, тем же id, что и в теле. Этот идентификатор одинаков при каждом повторе и повторной отправке события, поэтому именно его стоит хранить, чтобы пропускать уже обработанные события.

Во время ротации секрета

У webhooks.rotate_secret нет окна перекрытия: доставки подписываются новым секретом с момента возврата из вызова. Сначала разверните приёмник, который принимает любой из двух секретов, затем выполните ротацию, сохраните новый секрет там, откуда его читает приёмник, и после этого уберите старый.

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 отправляет подписанное синтетическое событие, поэтому вызовите его после ротации, чтобы убедиться, что новый секрет проходит проверку, прежде чем убирать старый.