전달 검증하기
`OpenEmail.verify_webhook_signature`: 재전송 허용 창을 둔 상수 시간 비교, 그리고 파싱된 이벤트 반환.
요청 핸들러에서
웹훅 URL은 공개되어 있습니다. 인터넷에 있는 누구든 형태만 맞는 JSON을 그 주소로 POST할 수 있으므로, 서명을 확인하지 않고 이벤트의 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으로 전달하세요. 파싱한 뒤 다시 직렬화하면 키 순서와 공백이 바뀌어 서명이 맞지 않게 되므로, Rails의 params 같은 Hash는 검사되지 않고 ArgumentError를 발생시킵니다. headers:는 헤더 이름의 대소문자를 가리지 않는 Hash, 헤더가 HTTP_X_OPENEMAIL_SIGNATURE로 도착하는 Rack env, 또는 Rails의 request.headers를 받습니다. 값이 Array이면 첫 번째 요소를 읽습니다.
직접 구현한 검사에서 보통 빠지는 두 가지를 이 메서드가 처리합니다: MAC을 상수 시간으로 비교하므로 타이밍으로 올바른 접두사를 알아낼 수 없고, tolerance_seconds:(따로 지정하지 않으면 5분)보다 앞뒤로 벗어난 오래된 전달을 거부하므로 가로챈 요청을 영원히 재전송할 수 없습니다. tolerance_seconds: 0으로 두면 재전송 검사가 꺼집니다. 두 결함 모두 조용합니다. 둘 중 하나를 안고 있는 핸들러도 여러분이 떠올릴 만한 테스트는 전부 통과합니다.
실패하면 언제나 OpenEmail::WebhookSignatureError를 발생시킵니다: X-OpenEmail-Signature 헤더가 없거나, t=<seconds>,v1=<hex> 형식이 아니거나, 타임스탬프가 허용 창을 벗어났거나, 서명이 맞지 않는 경우입니다. 성공하면 본문을 id, type, createdAt, data를 가진 Symbol 키의 Hash로 파싱해 반환하므로, 두 번째 JSON.parse를 잘못할 일이 없습니다.
secret:이 없거나 비어 있으면 대신 ArgumentError를 발생시킵니다. 잘못된 전달이 아니라 설정의 실수이기 때문입니다. OpenEmail::WebhookSignatureError만 rescue해 400으로 응답하세요. 그러면 설정이 잘못된 서버는 500으로 응답하고, 문제를 고친 뒤 전달이 다시 시도됩니다.
gem이 이미 불러오는 표준 라이브러리의 OpenSSL을 사용하므로, 그 밖에 필요한 것은 없습니다.
Rack이나 Sinatra 앱에서
Rails 밖에서는 Rack env 자체를 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 webhookRack 버전은 config.ru 전체입니다. 어느 쪽이든 빨리 응답하세요: 5초 안에 응답을 받지 못한 전달은 실패로 간주되어 나중에 다시 전송되므로, 처리는 작업에 넘기고 2xx로 응답하세요.
OpenEmail::WEBHOOK_SIGNATURE_HEADERS는 전달이 담고 오는 세 헤더를 정의합니다: X-OpenEmail-Signature, 이벤트의 유형을 담은 X-OpenEmail-Event, 그리고 본문의 id와 같은 id를 담은 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는 서명된 합성 이벤트를 보내므로, 교체한 뒤 호출해 예전 시크릿을 빼기 전에 새 시크릿으로 검증이 통과하는지 확인하세요.