From 45ae954c0c5359d7f333ab0dd1d3b93c9545441a Mon Sep 17 00:00:00 2001 From: Pete Matsyburka Date: Wed, 6 May 2026 11:45:35 +0300 Subject: [PATCH] add hmac webhook secret --- app/controllers/webhook_hmac_controller.rb | 7 ++++ app/models/webhook_url.rb | 24 +++++++---- app/views/webhook_hmac/show.html.erb | 20 +++++++++ app/views/webhook_secret/show.erb | 8 +++- app/views/webhook_settings/show.html.erb | 2 +- config/locales/i18n.yml | 42 +++++++++++++++++++ config/routes.rb | 1 + ...20260506120000_add_hmac_to_webhook_urls.rb | 23 ++++++++++ db/schema.rb | 3 +- lib/send_webhook_request.rb | 14 ++++--- lib/webhook_urls/signatures.rb | 40 ++++++++++++++++++ ...ission_created_webhook_request_job_spec.rb | 17 ++++++++ spec/system/webhook_settings_spec.rb | 26 +++++++++--- 13 files changed, 205 insertions(+), 22 deletions(-) create mode 100644 app/controllers/webhook_hmac_controller.rb create mode 100644 app/views/webhook_hmac/show.html.erb create mode 100644 db/migrate/20260506120000_add_hmac_to_webhook_urls.rb create mode 100644 lib/webhook_urls/signatures.rb diff --git a/app/controllers/webhook_hmac_controller.rb b/app/controllers/webhook_hmac_controller.rb new file mode 100644 index 00000000..f0710b15 --- /dev/null +++ b/app/controllers/webhook_hmac_controller.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class WebhookHmacController < ApplicationController + load_and_authorize_resource :webhook_url, parent: false + + def show; end +end diff --git a/app/models/webhook_url.rb b/app/models/webhook_url.rb index 6e92161d..7c7b48cc 100644 --- a/app/models/webhook_url.rb +++ b/app/models/webhook_url.rb @@ -4,14 +4,15 @@ # # Table name: webhook_urls # -# id :bigint not null, primary key -# events :text not null -# secret :text not null -# sha1 :string not null -# url :text not null -# created_at :datetime not null -# updated_at :datetime not null -# account_id :bigint not null +# id :bigint not null, primary key +# events :text not null +# hmac_secret :text not null +# secret :text not null +# sha1 :string not null +# url :text not null +# created_at :datetime not null +# updated_at :datetime not null +# account_id :bigint not null # # Indexes # @@ -47,10 +48,15 @@ class WebhookUrl < ApplicationRecord serialize :secret, coder: JSON before_validation :set_sha1 + before_validation :set_hmac_secret - encrypts :url, :secret + encrypts :url, :secret, :hmac_secret def set_sha1 self.sha1 = Digest::SHA1.hexdigest(url) end + + def set_hmac_secret + self.hmac_secret ||= WebhookUrls::Signatures.generate_secret + end end diff --git a/app/views/webhook_hmac/show.html.erb b/app/views/webhook_hmac/show.html.erb new file mode 100644 index 00000000..e5d526d0 --- /dev/null +++ b/app/views/webhook_hmac/show.html.erb @@ -0,0 +1,20 @@ +<%= render 'shared/turbo_modal', title: t('webhook_security') do %> +
+
+ <%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %> + <%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %> +
+
+
+ + <% token = @webhook_url.hmac_secret %> + <% obscured = "#{token[0, 10]}#{'*' * [token.length - 10, 0].max}" %> +
+ + + + <%= render 'shared/clipboard_copy', icon: 'copy', text: token, class: 'base-button', icon_class: 'w-6 h-6 text-white', copy_title: t('copy'), copied_title: t('copied') %> +
+

<%= t('hmac_signature_header_hint_html', header: 'X-Docuseal-Signature'.html_safe) %>

+
+<% end %> diff --git a/app/views/webhook_secret/show.erb b/app/views/webhook_secret/show.erb index 88b996c5..05b54b6a 100644 --- a/app/views/webhook_secret/show.erb +++ b/app/views/webhook_secret/show.erb @@ -1,4 +1,10 @@ -<%= render 'shared/turbo_modal', title: t('webhook_secret') do %> +<%= render 'shared/turbo_modal', title: t('webhook_security') do %> +
+
+ <%= link_to t('secret'), webhook_secret_path(@webhook_url), class: 'block bg-base-300 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-l-3xl', data: { turbo_frame: 'modal' } %> + <%= link_to t('hmac'), webhook_hmac_path(@webhook_url), class: 'block bg-base-200 md:min-w-[112px] text-sm font-semibold whitespace-nowrap py-1.5 px-4 rounded-r-3xl', data: { turbo_frame: 'modal' } %> +
+
<%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<%= 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) }