<%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>
diff --git a/app/views/webhook_settings/show.html.erb b/app/views/webhook_settings/show.html.erb
index e552396d..c40a3628 100644
--- a/app/views/webhook_settings/show.html.erb
+++ b/app/views/webhook_settings/show.html.erb
@@ -23,7 +23,7 @@
<%= link_to webhook_secret_path(@webhook_url), class: 'btn btn-outline btn-sm bg-white', data: { turbo_frame: 'modal' } do %>
<%= svg_icon('lock', class: 'w-4 h-4') %>
-
<%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %>
+
<%= t('security') %>
<% end %>
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %>
diff --git a/config/locales/i18n.yml b/config/locales/i18n.yml
index a2839022..8bfc1f35 100644
--- a/config/locales/i18n.yml
+++ b/config/locales/i18n.yml
@@ -593,6 +593,12 @@ en: &en
unable_to_resend_webhook_request: Unable to resend webhook request.
new_webhook: New Webhook
delete_webhook: Delete webhook
+ security: Security
+ webhook_security: Webhook Security
+ secret: Secret
+ hmac: HMAC
+ hmac_signing_secret: HMAC Signing Secret
+ hmac_signature_header_hint_html: 'Each request is signed with the %{header} header: <timestamp>.<sha256>.'
count_submissions_have_been_created: '%{count} submissions have been created.'
sms_length_cant_be_longer_than_120_bytes: SMS length can't be longer than 120 bytes
connected_successfully: Connected successfully.
@@ -1637,6 +1643,12 @@ es: &es
unable_to_resend_webhook_request: No se pudo reenviar la solicitud del webhook.
new_webhook: Nuevo Webhook
delete_webhook: Eliminar webhook
+ security: Seguridad
+ webhook_security: Seguridad del Webhook
+ secret: Secreto
+ hmac: HMAC
+ hmac_signing_secret: Secreto de firma HMAC
+ hmac_signature_header_hint_html: 'Cada solicitud se firma con el encabezado %{header}: <timestamp>.<sha256>.'
count_submissions_have_been_created: '%{count} envíos han sido creados.'
sms_length_cant_be_longer_than_120_bytes: La longitud del SMS no puede ser mayor a 120 bytes.
connected_successfully: Conectado con éxito.
@@ -2678,6 +2690,12 @@ it: &it
unable_to_resend_webhook_request: Impossibile reinviare la richiesta del webhook.
new_webhook: Nuovo Webhook
delete_webhook: Elimina webhook
+ security: Sicurezza
+ webhook_security: Sicurezza del Webhook
+ secret: Segreto
+ hmac: HMAC
+ hmac_signing_secret: Segreto di firma HMAC
+ hmac_signature_header_hint_html: 'Ogni richiesta viene firmata con l''intestazione %{header}: <timestamp>.<sha256>.'
count_submissions_have_been_created: '%{count} invii sono stati creati.'
sms_length_cant_be_longer_than_120_bytes: "La lunghezza dell'SMS non può superare i 120 byte."
connected_successfully: Collegamento avvenuto con successo.
@@ -3720,6 +3738,12 @@ fr: &fr
unable_to_resend_webhook_request: Impossible de renvoyer la requête webhook.
new_webhook: Nouveau webhook
delete_webhook: Supprimer le webhook
+ security: Sécurité
+ webhook_security: Sécurité du webhook
+ secret: Secret
+ hmac: HMAC
+ hmac_signing_secret: Secret de signature HMAC
+ hmac_signature_header_hint_html: 'Chaque requête est signée avec l''en-tête %{header} : <timestamp>.<sha256>.'
count_submissions_have_been_created: "%{count} soumissions ont été créées."
sms_length_cant_be_longer_than_120_bytes: La longueur du SMS ne peut pas dépasser 120 octets
connected_successfully: Connecté avec succès.
@@ -4758,6 +4782,12 @@ pt: &pt
unable_to_resend_webhook_request: Não foi possível reenviar a solicitação do webhook.
new_webhook: Novo Webhook
delete_webhook: Excluir webhook
+ security: Segurança
+ webhook_security: Segurança do Webhook
+ secret: Segredo
+ hmac: HMAC
+ hmac_signing_secret: Segredo de assinatura HMAC
+ hmac_signature_header_hint_html: 'Cada requisição é assinada com o cabeçalho %{header}: <timestamp>.<sha256>.'
count_submissions_have_been_created: '%{count} submissões foram criadas.'
sms_length_cant_be_longer_than_120_bytes: O comprimento do SMS não pode ultrapassar 120 bytes
connected_successfully: Conectado com sucesso.
@@ -5799,6 +5829,12 @@ de: &de
unable_to_resend_webhook_request: Webhook-Anfrage konnte nicht erneut gesendet werden.
new_webhook: Neuer Webhook
delete_webhook: Webhook löschen
+ security: Sicherheit
+ webhook_security: Webhook-Sicherheit
+ secret: Geheimnis
+ hmac: HMAC
+ hmac_signing_secret: HMAC-Signaturgeheimnis
+ hmac_signature_header_hint_html: 'Jede Anfrage wird mit dem %{header}-Header signiert: <timestamp>.<sha256>.'
count_submissions_have_been_created: '%{count} Einreichungen wurden erstellt.'
sms_length_cant_be_longer_than_120_bytes: Die SMS-Länge darf 120 Bytes nicht überschreiten.
connected_successfully: Erfolgreich verbunden.
@@ -7245,6 +7281,12 @@ nl: &nl
unable_to_resend_webhook_request: Kan webhook-verzoek niet opnieuw verzenden.
new_webhook: Nieuwe webhook
delete_webhook: Webhook verwijderen
+ security: Beveiliging
+ webhook_security: Webhook-beveiliging
+ secret: Geheim
+ hmac: HMAC
+ hmac_signing_secret: HMAC-ondertekeningsgeheim
+ hmac_signature_header_hint_html: 'Elk verzoek wordt ondertekend met de %{header}-header: <timestamp>.<sha256>.'
count_submissions_have_been_created: "%{count} inzendingen zijn aangemaakt."
sms_length_cant_be_longer_than_120_bytes: SMS-lengte mag niet langer zijn dan 120 bytes
connected_successfully: Succesvol verbonden.
diff --git a/config/routes.rb b/config/routes.rb
index 6b569394..8b330ba0 100644
--- a/config/routes.rb
+++ b/config/routes.rb
@@ -83,6 +83,7 @@ Rails.application.routes.draw do
resources :submitters_resubmit, only: %i[update]
resources :template_folders_autocomplete, only: %i[index]
resources :webhook_secret, only: %i[show update]
+ resources :webhook_hmac, only: %i[show]
resources :webhook_preferences, only: %i[update]
resource :templates_upload, only: %i[create]
authenticated do
diff --git a/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb b/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb
new file mode 100644
index 00000000..0419ad19
--- /dev/null
+++ b/db/migrate/20260506120000_add_hmac_to_webhook_urls.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class AddHmacToWebhookUrls < ActiveRecord::Migration[8.1]
+ class MigrationWebhookUrl < ApplicationRecord
+ self.table_name = 'webhook_urls'
+
+ encrypts :hmac_secret
+ end
+
+ def up
+ add_column :webhook_urls, :hmac_secret, :text
+
+ MigrationWebhookUrl.find_each do |webhook_url|
+ webhook_url.update_columns(hmac_secret: WebhookUrls::Signatures.generate_secret)
+ end
+
+ change_column_null :webhook_urls, :hmac_secret, false
+ end
+
+ def down
+ remove_column :webhook_urls, :hmac_secret
+ end
+end
diff --git a/db/schema.rb b/db/schema.rb
index e0ba7a4f..138adba4 100644
--- a/db/schema.rb
+++ b/db/schema.rb
@@ -10,7 +10,7 @@
#
# It's strongly recommended that you check this file into your version control system.
-ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
+ActiveRecord::Schema[8.1].define(version: 2026_05_06_120000) do
# These are extensions that must be enabled in order to support this database
enable_extension "btree_gin"
enable_extension "pg_catalog.plpgsql"
@@ -545,6 +545,7 @@ ActiveRecord::Schema[8.1].define(version: 2026_04_16_100000) do
t.bigint "account_id", null: false
t.datetime "created_at", null: false
t.text "events", null: false
+ t.text "hmac_secret", null: false
t.text "secret", null: false
t.string "sha1", null: false
t.datetime "updated_at", null: false
diff --git a/lib/send_webhook_request.rb b/lib/send_webhook_request.rb
index 87de376a..2eb346e9 100644
--- a/lib/send_webhook_request.rb
+++ b/lib/send_webhook_request.rb
@@ -15,11 +15,7 @@ module SendWebhookRequest
# rubocop:disable Metrics/AbcSize
def call(webhook_url, event_uuid:, event_type:, record:, data:, attempt: 0)
- uri = begin
- URI(webhook_url.url)
- rescue URI::Error
- Addressable::URI.parse(webhook_url.url).normalize
- end
+ uri = parse_uri(webhook_url.url)
if Docuseal.multitenant?
raise HttpsError, 'Only HTTPS is allowed.' if (uri.scheme != 'https' || [443, nil].exclude?(uri.port)) &&
@@ -43,6 +39,8 @@ module SendWebhookRequest
data: data
}.to_json
+ req.headers['X-Docuseal-Signature'] = WebhookUrls::Signatures.sign(webhook_url.hmac_secret, body: req.body)
+
req.options.read_timeout = 15
req.options.open_timeout = 8
end
@@ -55,6 +53,12 @@ module SendWebhookRequest
end
# rubocop:enable Metrics/AbcSize
+ def parse_uri(url)
+ URI(url)
+ rescue URI::Error
+ Addressable::URI.parse(url).normalize
+ end
+
def create_webhook_event(webhook_url, event_uuid:, event_type:, record:)
return if event_uuid.blank?
diff --git a/lib/webhook_urls/signatures.rb b/lib/webhook_urls/signatures.rb
new file mode 100644
index 00000000..937a316e
--- /dev/null
+++ b/lib/webhook_urls/signatures.rb
@@ -0,0 +1,40 @@
+# frozen_string_literal: true
+
+module WebhookUrls
+ module Signatures
+ SECRET_PREFIX = 'whsec_'
+ SECRET_BYTES = 24
+ TOLERANCE = 5 * 60
+
+ InvalidSignatureError = Class.new(StandardError)
+ TimestampError = Class.new(StandardError)
+
+ module_function
+
+ def generate_secret
+ SECRET_PREFIX + Base64.strict_encode64(SecureRandom.bytes(SECRET_BYTES))
+ end
+
+ def sign(secret, body:, timestamp: Time.current.to_i)
+ "#{timestamp}.#{OpenSSL::HMAC.hexdigest('sha256', secret, "#{timestamp}.#{body}")}"
+ end
+
+ def verify(secret, body:, header:, tolerance: TOLERANCE)
+ ts, sig = header.to_s.split('.', 2)
+ ts = Integer(ts, exception: false)
+
+ raise InvalidSignatureError unless ts && sig
+
+ now = Time.current.to_i
+
+ raise TimestampError, 'Too old' if ts < now - tolerance
+ raise TimestampError, 'In future' if ts > now + tolerance
+
+ expected = OpenSSL::HMAC.hexdigest('sha256', secret, "#{ts}.#{body}")
+
+ raise InvalidSignatureError unless ActiveSupport::SecurityUtils.secure_compare(expected, sig)
+
+ true
+ end
+ end
+end
diff --git a/spec/jobs/send_submission_created_webhook_request_job_spec.rb b/spec/jobs/send_submission_created_webhook_request_job_spec.rb
index 62a1d432..8a70c6fb 100644
--- a/spec/jobs/send_submission_created_webhook_request_job_spec.rb
+++ b/spec/jobs/send_submission_created_webhook_request_job_spec.rb
@@ -57,6 +57,23 @@ RSpec.describe SendSubmissionCreatedWebhookRequestJob do
).once
end
+ it 'signs the request with the HMAC secret' do
+ captured_body = nil
+ captured_signature = nil
+ stub_request(:post, webhook_url.url).with do |req|
+ captured_body = req.body
+ captured_signature = req.headers['X-Docuseal-Signature']
+ end.to_return(status: 200)
+
+ described_class.new.perform('submission_id' => submission.id, 'webhook_url_id' => webhook_url.id,
+ 'event_uuid' => SecureRandom.uuid)
+
+ expect(captured_signature).to be_present
+ expect(WebhookUrls::Signatures.verify(webhook_url.hmac_secret,
+ body: captured_body,
+ header: captured_signature)).to be(true)
+ end
+
it "doesn't send a webhook request if the event is not in the webhook's events" do
webhook_url.update!(events: ['submission.completed'])
diff --git a/spec/system/webhook_settings_spec.rb b/spec/system/webhook_settings_spec.rb
index 938b6eb9..9a27cb31 100644
--- a/spec/system/webhook_settings_spec.rb
+++ b/spec/system/webhook_settings_spec.rb
@@ -49,7 +49,7 @@ RSpec.describe 'Webhook Settings' do
expect(page).to have_field('webhook_url[url]', type: 'url', with: webhook_url.url)
expect(page).to have_button('Save')
expect(page).to have_button('Delete')
- expect(page).to have_link('Add Secret')
+ expect(page).to have_link('Security')
WebhookUrl::EVENTS.each do |event|
expect(page).to have_field(event, type: 'checkbox', checked: webhook_url.events.include?(event))
@@ -123,7 +123,7 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({})
- click_link 'Add Secret'
+ click_link 'Security'
within '#modal' do
fill_in 'Key', with: 'X-Signature'
@@ -136,7 +136,7 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({ 'X-Signature' => 'secret-value' })
end
- expect(page).to have_link('Edit Secret')
+ expect(page).to have_link('Security')
expect(page).to have_content('Webhook Secret has been saved.')
end
@@ -145,7 +145,7 @@ RSpec.describe 'Webhook Settings' do
visit settings_webhooks_path
- click_link 'Edit Secret'
+ click_link 'Security'
within '#modal' do
fill_in 'Key', with: ''
@@ -158,10 +158,26 @@ RSpec.describe 'Webhook Settings' do
expect(webhook_url.secret).to eq({})
end
- expect(page).to have_link('Add Secret')
+ expect(page).to have_link('Security')
expect(page).to have_content('Webhook Secret has been saved.')
end
+ it 'shows the HMAC signing secret on the HMAC tab' do
+ webhook_url = create(:webhook_url, account:)
+
+ visit settings_webhooks_path
+
+ click_link 'Security'
+
+ within '#modal' do
+ click_link 'HMAC'
+
+ expect(page).to have_field('hmac_secret')
+ end
+
+ expect(webhook_url.reload.hmac_secret).to start_with('whsec_')
+ end
+
context 'when testing the webhook' do
let!(:webhook_url) { create(:webhook_url, account:) }
let!(:template) { create(:template, account:, author: user) }