Verifikasi tanda tangan webhook

Verifikasi tanda tangan webhook5 menit baca

Verifikasi tanda tangan webhook di Ruby dan Rails

Verifikasi tanda tangan webhook Ikiform di Sinatra dengan OpenSSL dan test Minitest, dan di Rails dengan gem standardwebhooks dan request.raw_post.

Preet SutharPendiri, Ikiform

Baca body mentah (request.body.read di Sinatra, request.raw_post di Rails) dan ketiga header webhook-*. Hitung OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}") dengan secret whsec_ hasil decode base64 sebagai key, encode base64 hasilnya, dan bandingkan dengan setiap tanda tangan v1 memakai OpenSSL.secure_compare. Di Rails, gem standardwebhooks melakukan seluruh pemeriksaan di Webhook#verify.

Sinatra mendapat verifier tanpa dependency dengan suite Minitest; Rails mendapat gem standardwebhooks di controller API dan job yang mampu menangani pengulangan. Kedua verifier berfungsi di framework mana pun, juga di Hanami atau Roda, karena semuanya berjalan di atas Rack.

Apa yang berbeda di Ruby

Rack mengubah setiap header request menjadi key environment: webhook-id menjadi HTTP_WEBHOOK_ID. Rails menyembunyikannya di balik request.headers["webhook-id"], yang memetakan nama untuk Anda. Sinatra tidak, jadi request.env["webhook-id"] selalu nil, dan verifier yang menerima header nil gagal dengan missing_headers meski Ikiform mengirimnya. Rails punya versi sendiri dari jebakan body: Rails mem-parsing body JSON ke params karena content type application/json, dan wrap_parameters mungkin menyalin key di bawah nama controller. Keduanya tidak mengubah request.raw_post, satu-satunya nilai yang perlu diverifikasi.

Di mana header webhook Ikiform berakhir di Sinatra dan di RailsSinatraRailswebhook-id:dlv_...di jaringanenv["HTTP_WEBHOOK_ID"]nama versiRackrequest.body.readbody mentahverify!,laluJSON.parsewebhook-id:dlv_...di jaringanrequest.headers["webhook-id"]dipetakanuntuk Andarequest.raw_postbody mentahWebhook#verifyDi mana header webhook Ikiform berakhir di Sinatra dan di RailsSinatraRailswebhook-id: dlv_...di jaringanenv["HTTP_WEBHOOK_ID"]nama versi Rackrequest.body.readbody mentahverify!, lalu JSON.parsewebhook-id: dlv_...di jaringanrequest.headers["webhook-id"]dipetakan untuk Andarequest.raw_postbody mentahWebhook#verify
  1. Ikiform mengirim header webhook-id dalam huruf kecil.
  2. Di Sinatra, Rack menyimpannya sebagai HTTP_WEBHOOK_ID di environment request, jadi baca dari sana.
  3. request.body.read di Sinatra mengembalikan body mentah, yang diverifikasi sebelum JSON.parse.
  4. Di Rails, request.headers memetakan nama header ke key Rack untuk Anda, dan request.raw_post mengembalikan body mentah untuk diverifikasi gem.
Header sama, dua ejaan. Verifier hanya butuh ketiga nilai dalam hash dengan key huruf kecil.

Sinatra dengan OpenSSL

ikiform_signature.rb
require "openssl"

module IkiformSignature
  class Invalid < StandardError; end

  TOLERANCE = 5 * 60

  def self.verify!(body, headers, secret, now: Time.now.to_i)
    id = headers["webhook-id"].to_s
    timestamp = headers["webhook-timestamp"].to_s
    signatures = headers["webhook-signature"].to_s
    raise Invalid, "missing_headers" if id.empty? || timestamp.empty? || signatures.empty?
    raise Invalid, "invalid_timestamp" unless timestamp.match?(/\A[0-9]{1,12}\z/)

    age = now - timestamp.to_i
    raise Invalid, "timestamp_too_old" if age > TOLERANCE
    raise Invalid, "timestamp_too_new" if age < -TOLERANCE

    key = secret.delete_prefix("whsec_").unpack1("m0")
    expected = [OpenSSL::HMAC.digest("SHA256", key, "#{id}.#{timestamp}.#{body}")].pack("m0")
    matched = signatures.split(" ").any? do |entry|
      version, value = entry.split(",", 2)
      version == "v1" && !value.nil? && OpenSSL.secure_compare(value, expected)
    end
    raise Invalid, "no_matching_signature" unless matched
  end
end
app.rb
require "json"
require "sinatra"
require_relative "ikiform_signature"

SECRET = ENV.fetch("IKIFORM_WEBHOOK_SECRET")

post "/webhooks/ikiform" do
  body = request.body.read
  headers = {
    "webhook-id" => request.env["HTTP_WEBHOOK_ID"],
    "webhook-timestamp" => request.env["HTTP_WEBHOOK_TIMESTAMP"],
    "webhook-signature" => request.env["HTTP_WEBHOOK_SIGNATURE"]
  }

  begin
    IkiformSignature.verify!(body, headers, SECRET)
  rescue IkiformSignature::Invalid => error
    halt 400, error.message
  end

  event = JSON.parse(body)
  logger.info("submission #{event["id"]}") if event["type"] == "submission.created"
  status 204
end
  • unpack1("m0") dan pack("m0") adalah base64 ketat tanpa library base64, yang tidak lagi menjadi default gem sejak Ruby 3.4 dan jika tidak harus ditambahkan ke Gemfile.
  • OpenSSL.secure_compare bersifat constant-time dan butuh Ruby 3.0 atau lebih baru. Fungsi ini meng-hash kedua nilai lebih dulu, jadi panjang yang berbeda tidak masalah.
  • Menyusun string yang ditandatangani dari id ASCII dan body biner aman: Ruby mempertahankan byte body apa adanya.
  • Sinatra tidak mem-parsing body JSON, jadi request.body.read adalah pembacaan pertama. Hindari middleware seperti Rack::JSONBodyParser di route ini.
  • halt 400 menghentikan route dengan alasan sebagai body, yang kemudian tampil di log pengiriman Ikiform, jadi secret yang salah terbaca sebagai no_matching_signature di sana.
  • Rack 3 tidak lagi menjamin body request bisa di-rewind, jadi baca sekali ke dalam string dan gunakan string itu untuk verifikasi maupun parsing.

Test Minitest

ikiform_signature_test.rb
require "json"
require "minitest/autorun"
require_relative "ikiform_signature"

class IkiformSignatureTest < Minitest::Test
  SECRET = "whsec_Fg8c6hm1Gt41v5RSPKm72LggDx8S1mlWkbBYpHPAnpw="
  ROLLED = "whsec_4YWiO+F83kohh/SGNr2zcgChkpVJ65ySVeV23Qabr2A="
  BODY = '{"id":"dlv_01k7v2d4n8r2p6s0w3y5b7c9e1","type":"endpoint.test","timestamp":"2026-10-17T12:00:00.000Z","api_version":"2026-10-17","data":{"endpoint_id":"whk_01k7v2e1b3d5f7h9k1m3n5q7s9","message":"This is a test event from Ikiform."}}'
  HEADERS = {
    "webhook-id" => "dlv_01k7v2d4n8r2p6s0w3y5b7c9e1",
    "webhook-timestamp" => "1792300000",
    "webhook-signature" => "v1,Duq+Iuh0xbRZyS+C/TO5W1hcGLGwg+ifBiWiYkq15qA="
  }.freeze
  NOW = 1_792_300_000 + 30

  def test_accepts_the_vector
    assert_nil IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW)
  end

  def test_accepts_either_secret_during_a_roll
    both = HEADERS.merge("webhook-signature" => "#{HEADERS["webhook-signature"]} v1,gfHP3s7ANdIpnM7gewtWtSvTrHgmsbEFgCaeRRR0Eak=")
    assert_nil IkiformSignature.verify!(BODY, both, SECRET, now: NOW)
    assert_nil IkiformSignature.verify!(BODY, both, ROLLED, now: NOW)
  end

  def test_rejects_a_reserialized_body
    pretty = JSON.pretty_generate(JSON.parse(BODY))
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(pretty, HEADERS, SECRET, now: NOW) }
    assert_equal "no_matching_signature", error.message
  end

  def test_rejects_a_stale_timestamp
    error = assert_raises(IkiformSignature::Invalid) { IkiformSignature.verify!(BODY, HEADERS, SECRET, now: NOW + 600) }
    assert_equal "timestamp_too_old", error.message
  end
end

Jalankan dengan ruby ikiform_signature_test.rb. Test memakai test vector dengan now yang tetap. JSON.pretty_generate adalah cara body diserialisasi ulang di Ruby, jadi kasus itu menunjukkan pemeriksaan akan menangkapnya. Di proyek yang memakai Bundler, tambahkan minitest ke grup test di Gemfile: gem itu disertakan bersama Ruby sebagai bundled gem, dan Bundler hanya memuat gem yang tercantum di Gemfile.

Rails dengan gem standardwebhooks

Tambahkan gem "standardwebhooks" ke Gemfile dan jalankan bundle install. Simpan secret dengan bin/rails credentials:edit di bawah ikiform.webhook_secret, atau baca dari ENV.

app/controllers/ikiform_webhooks_controller.rb
class IkiformWebhooksController < ActionController::API
  def create
    webhook = StandardWebhooks::Webhook.new(Rails.application.credentials.dig(:ikiform, :webhook_secret))
    event = webhook.verify(request.raw_post, {
      "webhook-id" => request.headers["webhook-id"],
      "webhook-timestamp" => request.headers["webhook-timestamp"],
      "webhook-signature" => request.headers["webhook-signature"]
    })

    if event["type"] == "submission.created"
      IkiformSubmissionJob.perform_later(event["id"], event.dig("data", "fields"))
    end
    head :no_content
  rescue StandardWebhooks::WebhookVerificationError => error
    render plain: error.message, status: :bad_request
  end
end
config/routes.rb
Rails.application.routes.draw do
  post "webhooks/ikiform", to: "ikiform_webhooks#create"
end
  • ActionController::API tidak punya perlindungan CSRF. Jika controller mewarisi ApplicationController, tambahkan skip_forgery_protection, atau Rails menolak post.
  • request.raw_post adalah body seperti dikirim, di-cache setelah pembacaan pertama, jadi memanggilnya dua kali aman. Jangan verifikasi params.to_json: Rails sudah mem-parsing dan menggabungkan JSON ke params, dan byte-nya berbeda.
  • Gem membaca header dari hash dengan key huruf kecil yang persis, jadi susun seperti contoh. Gem memeriksa timestamp dan setiap tanda tangan v1, lalu mengembalikan JSON yang sudah di-parse.
  • perform_later memasukkan pekerjaan ke antrean dan head :no_content langsung menjawab 204, jauh di bawah 15 detik Ikiform. Job bisa melewati id pengiriman yang sudah pernah dilihatnya.

Melewati pengulangan di job

Langsung menjawab 204 dan mengerjakan tugas di job adalah bentuk yang tepat, dan itu berarti job harus mampu menangani pengiriman yang sama dua kali: saat Ikiform tidak mendapat jawaban Anda dalam 15 detik, Ikiform mencoba ulang dengan webhook-id yang sama, dan controller Anda memasukkan job kedua ke antrean. Unique index membuat yang kedua menjadi no-op:

db/migrate/20261025000000_create_webhook_deliveries.rb
class CreateWebhookDeliveries < ActiveRecord::Migration[8.0]
  def change
    create_table :webhook_deliveries do |t|
      t.string :delivery_id, null: false
      t.timestamps
    end
    add_index :webhook_deliveries, :delivery_id, unique: true
  end
end
app/jobs/ikiform_submission_job.rb
class IkiformSubmissionJob < ApplicationJob
  queue_as :default

  def perform(delivery_id, fields)
    WebhookDelivery.transaction do
      WebhookDelivery.create!(delivery_id: delivery_id)
      SubmissionMailer.with(fields: fields).notify_team.deliver_now
    end
  rescue ActiveRecord::RecordNotUnique
    Rails.logger.info("Skipping repeated delivery #{delivery_id}")
  end
end

Insert dan pekerjaannya berbagi satu transaksi, jadi jika email gagal, id di-rollback dan percobaan ulang job itu sendiri berjalan lagi. RecordNotUnique berasal dari unique index database, yang tetap berlaku meski dua job berjalan bersamaan di worker berbeda; validates :delivery_id, uniqueness: true di level model saja tidak cukup. SubmissionMailer mewakili apa pun yang dilakukan job dengan field.

Rilis gem saat ini butuh Ruby 3.3 atau lebih baru. Di Ruby yang lebih lama, gunakan modul IkiformSignature di atas di controller; modul itu berjalan di Ruby 3.0 dan yang lebih baru.

Pastikan berfungsi

  1. Jalankan file Minitest. Empat run, tanpa kegagalan.
  2. Deploy di balik URL https: publik dan tambahkan di Webhooks di dashboard.
  3. Atur secret di credentials atau IKIFORM_WEBHOOK_SECRET dan restart app server.
  4. Pilih Send test. Log pengiriman menampilkan 204, dan log Rails menunjukkan request sampai ke IkiformWebhooksController#create tanpa peringatan CSRF.
  5. Ubah satu karakter secret dan kirim uji lagi. Log menampilkan 400 beserta alasannya.

Hal yang sering menjebak

MasalahPenyebab dan solusi
missing_headers di SinatraKode membaca request.env["webhook-id"]. Key Rack-nya adalah HTTP_WEBHOOK_ID
ActionController::InvalidAuthenticityToken di RailsController mewarisi perlindungan CSRF. Gunakan ActionController::API atau skip_forgery_protection
cannot load such file -- base64 setelah upgrade RubyRuby 3.4 mengeluarkan base64 dari default gem. Tambahkan ke Gemfile, atau gunakan pack("m0") seperti di atas
NoMethodError: undefined method 'secure_compare'Ruby lebih lama dari 3.0. Upgrade, atau gunakan Rack::Utils.secure_compare, yang mengembalikan false untuk panjang yang berbeda
Tanda tangan hanya gagal di RailsAda yang mem-parsing body sebelum controller, atau params di-encode ulang. Verifikasi request.raw_post

Pertanyaan

Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.