add hmac webhook secret

pull/669/merge
Pete Matsyburka 1 month ago
parent 1304849b55
commit 45ae954c0c

@ -0,0 +1,7 @@
# frozen_string_literal: true
class WebhookHmacController < ApplicationController
load_and_authorize_resource :webhook_url, parent: false
def show; end
end

@ -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

@ -0,0 +1,20 @@
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= 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' } %>
</div>
</div>
<div class="form-control">
<label class="label" for="hmac_secret"><%= t('hmac_signing_secret') %></label>
<% token = @webhook_url.hmac_secret %>
<% obscured = "#{token[0, 10]}#{'*' * [token.length - 10, 0].max}" %>
<div class="flex gap-2">
<masked-input class="block w-full" data-token="<%= token %>">
<input id="hmac_secret" type="text" value="<%= obscured %>" class="base-input font-mono w-full" autocomplete="off" readonly>
</masked-input>
<%= 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') %>
</div>
<p class="text-sm mt-2 opacity-70"><%= t('hmac_signature_header_hint_html', header: '<code>X-Docuseal-Signature</code>'.html_safe) %></p>
</div>
<% end %>

@ -1,4 +1,10 @@
<%= render 'shared/turbo_modal', title: t('webhook_secret') do %>
<%= render 'shared/turbo_modal', title: t('webhook_security') do %>
<div class="text-center mb-4">
<div class="inline-flex justify-center">
<%= 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' } %>
</div>
</div>
<%= form_for @webhook_url, url: webhook_secret_path, method: :patch, html: { class: 'space-y-4' }, data: { turbo_frame: :_top } do |f| %>
<div class="space-y-2">
<%= f.fields_for :secret, Struct.new(:key, :value).new(*@webhook_url.secret.to_a.first) do |ff| %>

@ -23,7 +23,7 @@
<div class="flex items-center space-x-2 md:absolute md:right-0">
<%= 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') %>
<span><%= @webhook_url.secret.present? ? t('edit_secret') : t('add_secret') %></span>
<span><%= t('security') %></span>
<% end %>
<div class="tooltip tooltip-left md:tooltip-top" data-tip="<%= t('delete_webhook') %>">
<%= button_to settings_webhook_path(@webhook_url), class: 'btn btn-warning btn-sm', method: :delete, data: { turbo_confirm: t('are_you_sure_') } do %>

@ -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: &lt;timestamp&gt;.&lt;sha256&gt;.'
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}: &lt;timestamp&gt;.&lt;sha256&gt;.'
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}: &lt;timestamp&gt;.&lt;sha256&gt;.'
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} : &lt;timestamp&gt;.&lt;sha256&gt;.'
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}: &lt;timestamp&gt;.&lt;sha256&gt;.'
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: &lt;timestamp&gt;.&lt;sha256&gt;.'
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: &lt;timestamp&gt;.&lt;sha256&gt;.'
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.

@ -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

@ -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

@ -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

@ -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?

@ -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

@ -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'])

@ -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) }

Loading…
Cancel
Save