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.
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.
- Ikiform mengirim header webhook-id dalam huruf kecil.
- Di Sinatra, Rack menyimpannya sebagai HTTP_WEBHOOK_ID di environment request, jadi baca dari sana.
- request.body.read di Sinatra mengembalikan body mentah, yang diverifikasi sebelum JSON.parse.
- Di Rails, request.headers memetakan nama header ke key Rack untuk Anda, dan request.raw_post mengembalikan body mentah untuk diverifikasi gem.
Sinatra dengan OpenSSL
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
endrequire "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
endunpack1("m0")danpack("m0")adalah base64 ketat tanpa librarybase64, yang tidak lagi menjadi default gem sejak Ruby 3.4 dan jika tidak harus ditambahkan ke Gemfile.OpenSSL.secure_comparebersifat 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.readadalah pembacaan pertama. Hindari middleware sepertiRack::JSONBodyParserdi route ini. halt 400menghentikan route dengan alasan sebagai body, yang kemudian tampil di log pengiriman Ikiform, jadi secret yang salah terbaca sebagaino_matching_signaturedi 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
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
endJalankan 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.
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
endRails.application.routes.draw do
post "webhooks/ikiform", to: "ikiform_webhooks#create"
endActionController::APItidak punya perlindungan CSRF. Jika controller mewarisiApplicationController, tambahkanskip_forgery_protection, atau Rails menolak post.request.raw_postadalah body seperti dikirim, di-cache setelah pembacaan pertama, jadi memanggilnya dua kali aman. Jangan verifikasiparams.to_json: Rails sudah mem-parsing dan menggabungkan JSON keparams, 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_latermemasukkan pekerjaan ke antrean danhead :no_contentlangsung menjawab204, 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:
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
endclass 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
endInsert 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.
IkiformSignature di atas di controller; modul itu berjalan di Ruby 3.0 dan yang lebih baru.Pastikan berfungsi
- Jalankan file Minitest. Empat run, tanpa kegagalan.
- Deploy di balik URL
https:publik dan tambahkan di Webhooks di dashboard. - Atur secret di credentials atau
IKIFORM_WEBHOOK_SECRETdan restart app server. - Pilih Send test. Log pengiriman menampilkan
204, dan log Rails menunjukkan request sampai keIkiformWebhooksController#createtanpa peringatan CSRF. - Ubah satu karakter secret dan kirim uji lagi. Log menampilkan
400beserta alasannya.
Hal yang sering menjebak
| Masalah | Penyebab dan solusi |
|---|---|
missing_headers di Sinatra | Kode membaca request.env["webhook-id"]. Key Rack-nya adalah HTTP_WEBHOOK_ID |
ActionController::InvalidAuthenticityToken di Rails | Controller mewarisi perlindungan CSRF. Gunakan ActionController::API atau skip_forgery_protection |
cannot load such file -- base64 setelah upgrade Ruby | Ruby 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 Rails | Ada yang mem-parsing body sebelum controller, atau params di-encode ulang. Verifikasi request.raw_post |
Pertanyaan
Gunakan request.raw_post. Saat action Anda berjalan, Rails sudah mem-parsing body JSON ke params, tetapi raw_post tetap menyimpan byte persis seperti dikirim.
Baca dari environment Rack, dalam huruf besar dengan tanda hubung diganti garis bawah dan diawali HTTP_: webhook-id menjadi request.env["HTTP_WEBHOOK_ID"].
Ada, standardwebhooks. StandardWebhooks::Webhook.new(secret).verify(body, headers) menerima secret whsec_ dan hash dengan key header huruf kecil, dan memunculkan WebhookVerificationError saat pemeriksaan gagal.
Hanya jika controller mewarisi ActionController::Base. Controller ActionController::API tidak punya pemeriksaan CSRF. Bagaimanapun juga, tanda tangan adalah perlindungan untuk action ini.
Tanda tangan Standard Webhooks berupa base64, bukan hex. Gunakan OpenSSL::HMAC.digest untuk mendapat byte mentah dan encode dengan [digest].pack("m0"), lalu bandingkan dengan nilai setelah v1, di header.
Mentok di salah satu langkah, atau ingin panduan untuk situasi Anda? Email hello@ikiform.com.