Rails 웹훅: 컨트롤러에서 HMAC 서명 검증하기

request.raw_post를 읽고 그 액션만 CSRF를 건너뛴 뒤, OpenSSL::HMAC.hexdigest를 각 sume-v1 항목과 secure_compare로 비교하고 head 204로 응답하세요.

읽는 시간 6분Sume
전체 글

Rails에서 웹훅을 받으려면, 어떤 코드도 본문을 파싱하기 전에 정확한 본문 바이트인 request.raw_post를 읽는 컨트롤러 액션으로 POST를 라우팅하고, 그 액션에서만 CSRF 보호를 건너뛰세요. 서명을 확인하고 head :no_content로 응답한 뒤, 느린 작업은 백그라운드 잡에 넘기세요. Sume 웹훅이라면 OpenSSL::HMAC.hexdigest("SHA256", secret, "#{timestamp}.#{raw}")를 계산하고, sume-v1= 뒤에 그 다이제스트를 붙인 값을 서명 헤더의 쉼표로 구분된 각 항목과 ActiveSupport::SecurityUtils.secure_compare로 비교하세요.

Rails 관련 내용은 Rails 8.1.4 API 문서의 ActionDispatch::Request, RequestForgeryProtection, SecurityUtils, Head와 Rails 가이드의 API 전용 애플리케이션, Active Job 문서에서 가져왔고, 다이제스트는 Ruby OpenSSL::HMAC 문서를 참고했습니다. Sume 관련 내용은 Run 웹훅 (영문), 웹훅 (영문), 웹훅 검증에서 가져왔습니다. 모두 2026-09-28에 확인했습니다. Sume는 Ruby gem을 제공하지 않습니다. SDK는 TypeScript용이며, 문서에 다른 언어로 수신기를 만드는 경우를 위한 서명 방식이 자세히 나와 있습니다. PHP 버전은 PHP 웹훅 서명 검증에 있습니다.

Sume 서명은 Ruby·Rails 호출과 어떻게 대응하나요?

Sume는 <timestamp>.<raw_body>에 HMAC-SHA256으로 서명해 x-sume-webhook-signature에 sume-v1=<hex_signature>를 담아 보내고, 타임스탬프는 x-sume-webhook-timestamp에 담습니다. 서명 시크릿을 교체하는 동안에는 헤더에 유효한 시크릿마다 항목이 하나씩 쉼표로 구분되어 실리며, 어느 항목이든 일치하면 유효한 전달입니다. 단계마다 대응하는 Ruby 또는 Rails 호출이 있습니다.

secure_compare는 길이가 가변적인 문자열을 위한 Rails의 안전한 비교 함수입니다. 타이밍 공격으로는 비교하는 내용을 알아낼 수 없고 길이만 알 수 있는데, 여기서는 어차피 길이가 고정되어 있습니다.

Rails API 문서의 ActionDispatch::Request, Http::Headers, SecurityUtils, Ruby OpenSSL::HMAC 문서, Sume의 Run 웹훅 (영문)과 웹훅 (영문) 페이지 기준, 2026-09-28 확인.
단계Sume 규칙Ruby 또는 Rails
원본 본문JSON을 파싱하기 전에 원본 바이트를 검증request.raw_post
헤더x-sume-webhook-timestamp, x-sume-webhook-signaturerequest.headers["X-Sume-Webhook-Signature"]
다이제스트<timestamp>.<raw_body>에 대한 HMAC-SHA256, hex 인코딩OpenSSL::HMAC.hexdigest("SHA256", secret, data)
비교상수 시간, sume-v1= 항목 중 어느 것이든 일치하면 수락header.split(",")의 각 항목과 secure_compare로 비교
재전송 허용 시간벗어난 타임스탬프는 거부. 오 분이 무난한 기본값(Time.now.to_i - ts.to_i).abs > 300

Rails 컨트롤러에서 웹훅은 어떻게 검증하나요?

액션은 먼저 원본 본문을 검증하고, 같은 문자열을 파싱하고, 이벤트를 큐에 넣은 뒤 204로 응답합니다. 현재 코드의 Sume TypeScript 검증기처럼 모든 항목을 확인하고 빈 시크릿을 거부하므로, 설정되지 않은 변수가 누구나 계산할 수 있는 HMAC 키가 되지 않습니다.

# config/routes.rb: post "/hooks/sume", to: "sume_webhooks#create"
class SumeWebhooksController < ApplicationController
  skip_forgery_protection only: :create # a webhook sender has no CSRF token

  def create
    raw = request.raw_post # the exact bytes Sume signed
    return head :unauthorized unless sume_signature_valid?(raw)

    SumeWebhookJob.perform_later(JSON.parse(raw)) # dedupe and work in the job
    head :no_content
  end

  private

  def sume_signature_valid?(raw)
    secret = ENV.fetch("SUME_COM_WEBHOOK_SIGNING_SECRET", "")
    ts = request.headers["X-Sume-Webhook-Timestamp"].to_s
    header = request.headers["X-Sume-Webhook-Signature"].to_s
    return false if secret.empty? || !ts.match?(/\A\d+\z/) || (Time.now.to_i - ts.to_i).abs > 300

    expected = "sume-v1=" + OpenSSL::HMAC.hexdigest("SHA256", secret, "#{ts}.#{raw}")
    header.split(",").map(&:strip).reduce(false) do |ok, entry|
      ActiveSupport::SecurityUtils.secure_compare(entry, expected) || ok # check every entry
    end
  end
end

CSRF 보호는 왜 건너뛰고, 왜 이 액션에서만 건너뛰나요?

Rails의 위조 방지(forgery protection)는 GET과 HEAD 요청은 확인하지 않지만, 웹훅은 POST로 도착하고 보내는 쪽에는 보낼 authenticity token이 없습니다. default_protect_from_forgery가 true이면 Rails는 with: :exception으로 보호하며, 이 방식은 ActionController::InvalidAuthenticityToken을 발생시킵니다. skip_forgery_protection은 skip_before_action :verify_authenticity_token을 감싼 것이고, only:는 건너뛰기를 액션 하나로 제한하므로 컨트롤러의 나머지 부분은 계속 보호됩니다. 요청이 Sume에서 왔다는 증거는 HMAC 검사가 대신 맡습니다.

컨트롤러가 ActionController::API를 상속하는 API 전용 앱에서는 skip_forgery_protection 줄을 빼세요. Rails 가이드가 API 전용 컨트롤러에 나열한 모듈에 위조 방지가 없으므로 건너뛸 것이 없습니다.

왜 모든 서명 검증이 실패하나요?

다음 Rails 실수부터 확인하세요.

  • 본문을 params에서 가져온 경우. 키 순서와 공백도 서명 대상의 일부이므로, 파싱했다가 다시 직렬화한 객체는 검증되지 않습니다. request.raw_post를 쓰세요.
  • 다이제스트가 바이너리인 경우. OpenSSL::HMAC.digest는 바이너리 문자열을 반환하지만 서명은 hex이며, hex를 반환하는 것은 hexdigest입니다.
  • 헤더 전체를 비교한 경우. 시크릿 교체 중에는 헤더에 유효한 시크릿마다 항목이 하나씩 실리므로, 헤더 전체에 대한 동등 비교는 그 기간의 모든 전달에서 실패합니다.
  • 시크릿이 맞지 않는 경우. x-sume-webhook-secret-fingerprint 헤더를 대시보드에서 시크릿 옆에 표시된 지문과 비교하세요. 나머지는 Sume 웹훅 전달 디버깅에서 다룹니다.

검증한 뒤 액션은 무엇을 해야 하나요?

빠르게 응답하고 작업은 잡으로 넘기세요. Sume는 시도당 10초를 허용하고, 느린 엔드포인트에는 재시도하며, 최대 10회까지 시도합니다. Rails 8.0부터 기본 Active Job 백엔드인 Solid Queue는 잡을 데이터베이스에 저장하므로, head :no_content가 응답하기 전에 perform_later가 이벤트를 저장합니다.

잡에서는 재시도에도 값이 반복되므로 실행 웹훅은 request_id로, Job 웹훅은 job_id로 중복을 제거하고, event로 라우팅하세요. 액션은 모르는 이벤트 타입을 포함해 검증된 모든 전달에 204로 응답합니다. Sume 문서에 따르면 그래야 새로 추가된 이벤트 타입이 500과 재시도 폭주로 번지지 않습니다. 실행 웹훅의 payload는 GET /v1/format-runs/{run_id}의 data와 바이트 단위로 동일하므로, 잡 하나로 웹훅과 폴링을 똑같이 처리할 수 있습니다.

출처

관련 글

연동 카테고리의 다른 글

연동 글 전체 보기

작성자 Sume