mirror of
https://github.com/docusealco/docuseal.git
synced 2026-06-23 04:10:11 +00:00
add hmac webhook secret
This commit is contained in:
@@ -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: <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.
|
||||
|
||||
@@ -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
|
||||
+2
-1
@@ -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) }
|
||||
|
||||
Reference in New Issue
Block a user