ITエンジニアによるITエンジニアのためのブログ

RailsでHMACによるウェブフック改ざんチェック

ウェブフックを自身のウェブアプリケーションで受け取る際は、そのリクエストが悪意のある第三者によって改ざんされていないか検証することが不可欠です。この改ざんチェックには、HMAC(Hash-based Message Authentication Code)が一般的に用いられます。

この記事では、Ruby on Railsアプリケーションでウェブフックを受信する際に、HMACを用いて改ざんチェックを実装する方法を解説します。

目次

  • HMACによる改ざんチェックの仕組み
  • Ruby on Railsでの実装
    • ルーティング設定
    • コントローラーの実装 (ActionController::API を使用)
    • ActionController::Base を使う場合
    • HMACを16進数文字列で扱う場合
    • 共有秘密鍵が16進数文字列の場合
  • まとめ

HMACによる改ざんチェックの仕組み

HMACを用いた改ざんチェックは、以下の手順で行われます。

  1. 秘密鍵の共有: 送信側(Webhookを提供するサービスなど)と受信側(あなたのRailsアプリケーション)とで、事前に秘密鍵を安全に共有します。
  2. HMACの生成と送信: 送信側は、共有した秘密鍵とリクエストボディ(メッセージ本体)を使ってHMACを計算します。生成されたHMACは、リクエストヘッダー(例: X-Signature, X-Hub-Signature など)に含めて送信されます。
  3. HMACの検証: 受信側は、リクエストを受け取ったら、共有した秘密鍵と受信したリクエストボディを使ってHMACを独自に計算します。そして、計算したHMACとリクエストヘッダーに含まれていたHMACとを比較します。

二つのHMACが一致すれば、そのリクエストは共有された秘密鍵を知る正しい送信者から送られたものであり、かつ通信経路上で改ざんされていないことが検証できます(秘密鍵が漏洩していない限り)。

Ruby on Railsでの実装

ルーティング設定

まず、config/routes.rb でWebhookを受け付けるためのルートを定義します。

# config/routes.rb
Rails.application.routes.draw do
  # POSTメソッドで/webhookパスへのリクエストを ExamplesControllerのwebhookアクションにルーティング
  post 'webhook', to: 'examples#webhook'
end

コントローラーの実装 (ActionController::API を使用)

Webhookのエンドポイントでは、通常セッション管理やCSRF保護は不要です。そのため、ActionController::API を継承すると、これらの機能がデフォルトで無効になり、軽量なコントローラーを作成できます。

before_action を使用して、webhook アクションが実行される前にHMAC検証メソッド (check_hmac) が呼び出されるように設定します。

class ExamplesController < ActionController::API
  # 環境変数 'WEBHOOK_SECRET_KEY' から秘密鍵を取得
  # Railsのcredentialsを使う場合は Rails.application.credentials.webhook_secret_keyなど
  SECRET_KEY = ENV['WEBHOOK_SECRET_KEY']

  # webhook アクションの前に check_hmac メソッドを実行
  before_action :check_hmac

  def webhook
    # request.raw_postはcheck_hmacで使った後でも再度呼び出せる
    payload = JSON.parse(request.raw_post)
    # ... ペイロードを使った処理 ...
    Rails.logger.info "Webhook processed successfully: #{payload.keys.join(', ')}" # ログ出力例
    head :ok
  end

  private

  def check_hmac
    # request.raw_post を使ってリクエストボディを取得
    request_body = request.raw_post

    # 共有秘密鍵とリクエストボディからHMACを計算
    # OpenSSL::HMAC.digest はバイナリデータを返す
    binary_hmac = OpenSSL::HMAC.digest('sha256', SECRET_KEY, request_body)

    # 送信側がHMACをBase64エンコードして送ってくることを想定
    # 計算したバイナリHMACをBase64エンコードする (改行なしの strict モード)
    calculated_hmac = Base64.strict_encode64(binary_hmac)
   
    # リクエストヘッダーから送信されてきたHMACを取得
    # ヘッダー名は 'Signature', 'X-Signature', 'X-Hub-Signature' など、送信側の仕様に合わせる 
    received_hmac = request.headers['X-Signature']

    # ヘッダーにHMACが含まれていない、または計算結果と一致しない場合はエラー
    # !! 注意: 文字列の比較には ActiveSupport::SecurityUtils.secure_compare を使うこと !!
    #          これはタイミング攻撃を防ぐため。
    unless received_hmac && ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, received_hmac)
      # ログ出力例
      Rails.logger.warn "Invalid HMAC signature. Received: #{received_hmac}, Calculated: #{calculated_hmac}" 

      # 認証失敗時のレスポンス
      # 401 Unauthorized や 403 Forbidden の方がより適切な場合もある
      render(status: :bad_request, json: { message: 'Invalid HMAC signature' })
    end
  end
end

コード解説:

  • SECRET_KEY: 共有した秘密鍵を環境変数やRailsの認証情報管理(credentials)から取得します。
  • request.raw_post: request.body.readを使うことも可能ですが、request.bodyはIOストリームで一度きりしか読み込めないため、何回でも呼び出せるrequest.raw_postを代わりに使用します。
  • OpenSSL::HMAC.digest('sha256', SECRET_KEY, request_body): HMACを計算します。
    • 第一引数: ハッシュアルゴリズム (ここでは ‘sha256’)。送信側の仕様に合わせます。
    • 第二引数: 共有秘密鍵。
    • 第三引数: ハッシュ化するメッセージ(リクエストボディ)。
    • 戻り値はバイナリデータです。
  • Base64.strict_encode64(binary_hmac): 計算したバイナリHMACをBase64文字列にエンコードします。送信側がBase64エンコードして送ってくる場合に必要です。strict_encode64 は改行を含まないため、HTTPヘッダーに適しています。
  • request.headers['X-Signature']: リクエストヘッダーから送信されてきたHMAC文字列を取得します。ヘッダー名は送信側の仕様に合わせてください (X-SignatureX-Hub-Signature などが一般的です)。
  • ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, received_hmac): 非常に重要な部分です。単純な文字列比較 (==) は、処理時間の差異から秘密情報を推測される「タイミング攻撃」に対して脆弱な可能性があります。secure_compare は、比較する文字列の長さが異なる場合を除き、比較処理にかかる時間を一定にすることで、この脆弱性を軽減します。必ずこちらを使用してください。
  • render(status: :bad_request, ...): 検証に失敗した場合、クライアントにエラーレスポンスを返します。ステータスコードは :bad_request(400) の他に、認証の失敗を示す :unauthorized(401) や :forbidden(403) がより意味的に適切な場合もあります。

ActionController::Base を使う場合

既存の ActionController::Base を継承したコントローラーにWebhookエンドポイントを追加する場合、そのままではRailsのCSRF保護機能によってリクエストがブロックされてしまう可能性があります。特定のアクションに対してCSRF保護を無効にする必要があります。

class ExamplesController < ActionController::Base
  # webhookアクションに対してCSRF保護をスキップ
  # これは skip_before_action :verify_authenticity_token, only: :webhook と同等です
  skip_forgery_protection only: :webhook

  # または、nullセッションを使用する方法もあります
  # protect_from_forgery with: :null_session, only: :webhook

  # webhook アクションの前に check_hmac メソッドを実行
  before_action :check_hmac, only: :webhook

  def webhook
    # ... webhook の処理 ...
    payload = JSON.parse(request.raw_post) # raw_post を使う例
    head :ok
  end

  def check_hmac
    # 上記の ActionController::API の例と同じ実装
    request_body = request.raw_post
    # ... (HMAC計算と検証) ...
    unless received_hmac && ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, received_hmac)
      render(status: :bad_request, json: { message: 'Invalid HMAC signature' })
    end
  end
end

HMACを16進数文字列で扱う場合

HMACの送受信形式として、Base64ではなく16進数文字列が使われる仕様の場合もあります。OpenSSL::HMAC には16進数文字列を直接生成する hexdigest メソッドがあります。

private

def check_hmac
  request_body = request.raw_post

  # HMACを16進数文字列で計算
  calculated_hmac = OpenSSL::HMAC.hexdigest('sha256', SECRET_KEY, request_body)

  received_hmac = request.headers['X-Signature-Hex'] # ヘッダー名は仕様に合わせる

  # secure_compare で比較
  unless received_hmac && ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, received_hmac)
    render(status: :bad_request, json: { message: 'Invalid HMAC signature' })
  end
end

SHA256 (256ビット) の場合、HMACは64文字の16進数文字列になります (256ビット / 4ビット = 64文字)。これはBase64エンコード (44文字) よりも長いため、データ転送量の観点からはBase64が好まれる傾向があります。

なお、OpenSSL::HMAC.digest で得たバイナリデータを自前で binary_hmac.unpack1('H*') のように16進数文字列に変換することも可能ですが、hexdigest を使う方が簡潔です。

共有秘密鍵が16進数文字列の場合

Webhookの仕様によっては、共有秘密鍵が16進数文字列で提供されるものの、HMAC計算時にはそれをバイナリデータに変換して使用する必要があるケースも存在します。送信側と受信側で秘密鍵の形式(文字列かバイナリか)が異なるとHMACの値が一致しないため、注意が必要です。

その場合は、 Array#pack を使って、16進数文字列で表現された秘密鍵をバイナリデータに戻してから OpenSSL::HMAC.digest (または hexdigest) に渡します。


def check_hmac
  request_body = request.raw_post
  # 環境変数から16進数文字列の秘密鍵を取得
  hex_secret_key = ENV['WEBHOOK_SECRET_KEY_HEX']

  # 16進数文字列をバイナリデータに変換
  # 'H*' は16進数文字列全体をバイナリに変換する指示
  binary_secret_key = [hex_secret_key].pack('H*')

  # バイナリ形式の秘密鍵を使ってHMACを計算 (ここでは digest を使う例)
  binary_hmac = OpenSSL::HMAC.digest('sha256', binary_secret_key, request_body)
  calculated_hmac = Base64.strict_encode64(binary_hmac) # 送信形式に合わせてエンコード

  received_hmac = request.headers['X-Signature']

  unless received_hmac && ActiveSupport::SecurityUtils.secure_compare(calculated_hmac, received_hmac)
    render(status: :bad_request, json: { message: 'Invalid HMAC signature' })
  end

まとめ

RailsアプリケーションでWebhookを受信する際は、HMAC署名を検証することでリクエストの改ざんをチェックし、送信元の正当性を確認することが重要です。これにより、悪意のあるリクエストからアプリケーションを保護し、よりセキュアなシステムを構築できます。

HMACの計算方法、エンコード形式(Base64か16進数か)、使用するヘッダー名、秘密鍵の形式(文字列かバイナリか)などは、Webhookを提供するサービスの仕様によって異なるため、必ずドキュメントを確認し、実装を合わせてください。そして、HMACの比較には必ず ActiveSupport::SecurityUtils.secure_compare を使用しましょう。