Проверка доставки
`OpenEmail.verify_webhook_signature`: за постоянное время, с окном защиты от повторов и с разобранным событием в результате.
В обработчике запроса
URL вебхука публичен. Кто угодно в интернете может отправить на него POST с JSON правильной формы, поэтому обработчик, который читает type события, не проверив подпись, является открытым API для записи.
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 нет окна перекрытия: доставки подписываются новым секретом с момента возврата из вызова. Сначала разверните приёмник, который принимает любой из двух секретов, затем выполните ротацию, сохраните новый секрет там, откуда его читает приёмник, и после этого уберите старый.
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 отправляет подписанное синтетическое событие, поэтому вызовите его после ротации, чтобы убедиться, что новый секрет проходит проверку, прежде чем убирать старый.