diff --git a/app/javascript/dashboard/i18n/locale/en/integrations.json b/app/javascript/dashboard/i18n/locale/en/integrations.json index 8ee990006..c8e488959 100644 --- a/app/javascript/dashboard/i18n/locale/en/integrations.json +++ b/app/javascript/dashboard/i18n/locale/en/integrations.json @@ -31,6 +31,14 @@ "WEBHOOK": { "SUBSCRIBED_EVENTS": "Subscribed Events", "LEARN_MORE": "Learn more about webhooks", + "SECRET": { + "LABEL": "Secret", + "COPY": "Copy secret to clipboard", + "COPY_SUCCESS": "Secret copied to clipboard", + "TOGGLE": "Toggle secret visibility", + "CREATED_DESC": "Your webhook has been created. Use the secret below to verify webhook signatures. Please copy it now — you can also find it later in the webhook edit form.", + "DONE": "Done" + }, "COUNT": "{n} webhook | {n} webhooks", "SEARCH_PLACEHOLDER": "Search webhooks...", "NO_RESULTS": "No webhooks found matching your search", diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue index 15db70c40..7213c735a 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/Index.vue @@ -58,6 +58,7 @@ export default { }, }, mounted() { + this.$store.dispatch('integrations/get', 'webhook'); this.$store.dispatch('webhooks/get'); }, methods: { diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue index 491a3cd87..76c4e895f 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/NewWebHook.vue @@ -1,60 +1,98 @@ - - - + + + + + {{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }} + + + {{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }} + + + + + + + + + + + + + + diff --git a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue index 3f4b31299..3bcef1ca2 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/integrations/Webhooks/WebhookForm.vue @@ -3,6 +3,8 @@ import { useVuelidate } from '@vuelidate/core'; import { required, url, minLength } from '@vuelidate/validators'; import wootConstants from 'dashboard/constants/globals'; import { getI18nKey } from 'dashboard/routes/dashboard/settings/helper/settingsHelper'; +import { copyTextToClipboard } from 'shared/helpers/clipboard'; +import { useAlert } from 'dashboard/composables'; import NextButton from 'dashboard/components-next/button/Button.vue'; const { EXAMPLE_WEBHOOK_URL } = wootConstants; @@ -57,10 +59,14 @@ export default { url: this.value.url || '', name: this.value.name || '', subscriptions: this.value.subscriptions || [], + secretVisible: false, supportedWebhookEvents: SUPPORTED_WEBHOOK_EVENTS, }; }, computed: { + hasSecret() { + return !!this.value.secret; + }, webhookURLInputPlaceholder() { return this.$t( 'INTEGRATION_SETTINGS.WEBHOOK.FORM.END_POINT.PLACEHOLDER', @@ -81,6 +87,10 @@ export default { subscriptions: this.subscriptions, }); }, + async copySecret() { + await copyTextToClipboard(this.value.secret); + useAlert(this.$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS')); + }, getI18nKey, }, }; @@ -111,6 +121,35 @@ export default { :placeholder="webhookNameInputPlaceholder" /> + + {{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }} + + + + + + {{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }} diff --git a/app/javascript/dashboard/store/modules/webhooks.js b/app/javascript/dashboard/store/modules/webhooks.js index eb096468e..774c173d3 100644 --- a/app/javascript/dashboard/store/modules/webhooks.js +++ b/app/javascript/dashboard/store/modules/webhooks.js @@ -42,6 +42,7 @@ export const actions = { } = response.data; commit(types.default.ADD_WEBHOOK, webhook); commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); + return webhook; } catch (error) { commit(types.default.SET_WEBHOOK_UI_FLAG, { creatingItem: false }); throw error; diff --git a/app/jobs/webhook_job.rb b/app/jobs/webhook_job.rb index 57d3739b7..54eac45e3 100644 --- a/app/jobs/webhook_job.rb +++ b/app/jobs/webhook_job.rb @@ -1,7 +1,7 @@ class WebhookJob < ApplicationJob queue_as :medium # There are 3 types of webhooks, account, inbox and agent_bot - def perform(url, payload, webhook_type = :account_webhook) - Webhooks::Trigger.execute(url, payload, webhook_type) + def perform(url, payload, webhook_type = :account_webhook, secret: nil, delivery_id: nil) + Webhooks::Trigger.execute(url, payload, webhook_type, secret: secret, delivery_id: delivery_id) end end diff --git a/app/listeners/webhook_listener.rb b/app/listeners/webhook_listener.rb index 82a9fc711..762eaa6ee 100644 --- a/app/listeners/webhook_listener.rb +++ b/app/listeners/webhook_listener.rb @@ -111,7 +111,9 @@ class WebhookListener < BaseListener account.webhooks.account_type.each do |webhook| next unless webhook.subscriptions.include?(payload[:event]) - WebhookJob.perform_later(webhook.url, payload) + WebhookJob.perform_later(webhook.url, payload, :account_webhook, + secret: webhook.secret, + delivery_id: SecureRandom.uuid) end end @@ -119,7 +121,8 @@ class WebhookListener < BaseListener return unless inbox.channel_type == 'Channel::Api' return if inbox.channel.webhook_url.blank? - WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook) + WebhookJob.perform_later(inbox.channel.webhook_url, payload, :api_inbox_webhook, + delivery_id: SecureRandom.uuid) end def deliver_webhook_payloads(payload, inbox) diff --git a/app/models/webhook.rb b/app/models/webhook.rb index 1d61c1614..6b36c4bbd 100644 --- a/app/models/webhook.rb +++ b/app/models/webhook.rb @@ -21,6 +21,9 @@ class Webhook < ApplicationRecord belongs_to :account belongs_to :inbox, optional: true + has_secure_token :secret + encrypts :secret if Chatwoot.encryption_configured? + validates :account_id, presence: true validates :url, uniqueness: { scope: [:account_id] }, format: URI::DEFAULT_PARSER.make_regexp(%w[http https]) validate :validate_webhook_subscriptions diff --git a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder index 5406cf183..7b1943c5d 100644 --- a/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder +++ b/app/views/api/v1/accounts/webhooks/_webhook.json.jbuilder @@ -3,6 +3,7 @@ json.name webhook.name json.url webhook.url json.account_id webhook.account_id json.subscriptions webhook.subscriptions +json.secret webhook.secret if webhook.inbox json.inbox do json.id webhook.inbox.id diff --git a/db/migrate/20260218075101_add_secret_to_webhooks.rb b/db/migrate/20260218075101_add_secret_to_webhooks.rb new file mode 100644 index 000000000..ff6c40c4c --- /dev/null +++ b/db/migrate/20260218075101_add_secret_to_webhooks.rb @@ -0,0 +1,5 @@ +class AddSecretToWebhooks < ActiveRecord::Migration[7.1] + def change + add_column :webhooks, :secret, :string + end +end diff --git a/db/migrate/20260226084618_backfill_webhook_secrets.rb b/db/migrate/20260226084618_backfill_webhook_secrets.rb new file mode 100644 index 000000000..aec6cfde7 --- /dev/null +++ b/db/migrate/20260226084618_backfill_webhook_secrets.rb @@ -0,0 +1,11 @@ +class BackfillWebhookSecrets < ActiveRecord::Migration[7.1] + def up + Webhook.find_each do |webhook| + webhook.update!(secret: SecureRandom.urlsafe_base64(24)) + end + end + + def down + # no-op: removing the column in the previous migration handles cleanup + end +end diff --git a/db/schema.rb b/db/schema.rb index fd4d18cb1..8a450e734 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[7.1].define(version: 2026_01_30_061021) do +ActiveRecord::Schema[7.1].define(version: 2026_02_26_084618) do # These extensions should be enabled to support this database enable_extension "pg_stat_statements" enable_extension "pg_trgm" @@ -1250,6 +1250,7 @@ ActiveRecord::Schema[7.1].define(version: 2026_01_30_061021) do t.integer "webhook_type", default: 0 t.jsonb "subscriptions", default: ["conversation_status_changed", "conversation_updated", "conversation_created", "contact_created", "contact_updated", "message_created", "message_updated", "webwidget_triggered"] t.string "name" + t.string "secret" t.index ["account_id", "url"], name: "index_webhooks_on_account_id_and_url", unique: true end diff --git a/enterprise/app/models/enterprise/audit/webhook.rb b/enterprise/app/models/enterprise/audit/webhook.rb index 34e0bcc1b..303141ce2 100644 --- a/enterprise/app/models/enterprise/audit/webhook.rb +++ b/enterprise/app/models/enterprise/audit/webhook.rb @@ -2,6 +2,6 @@ module Enterprise::Audit::Webhook extend ActiveSupport::Concern included do - audited associated_with: :account + audited associated_with: :account, except: [:secret] end end diff --git a/lib/webhooks/trigger.rb b/lib/webhooks/trigger.rb index ef3410b78..456e186ca 100644 --- a/lib/webhooks/trigger.rb +++ b/lib/webhooks/trigger.rb @@ -1,14 +1,16 @@ class Webhooks::Trigger SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze - def initialize(url, payload, webhook_type) + def initialize(url, payload, webhook_type, secret: nil, delivery_id: nil) @url = url @payload = payload @webhook_type = webhook_type + @secret = secret + @delivery_id = delivery_id end - def self.execute(url, payload, webhook_type) - new(url, payload, webhook_type).execute + def self.execute(url, payload, webhook_type, secret: nil, delivery_id: nil) + new(url, payload, webhook_type, secret: secret, delivery_id: delivery_id).execute end def execute @@ -21,15 +23,27 @@ class Webhooks::Trigger private def perform_request + body = @payload.to_json RestClient::Request.execute( method: :post, url: @url, - payload: @payload.to_json, - headers: { content_type: :json, accept: :json }, + payload: body, + headers: request_headers(body), timeout: webhook_timeout ) end + def request_headers(body) + headers = { content_type: :json, accept: :json } + headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present? + if @secret.present? + ts = Time.now.to_i.to_s + headers['X-Chatwoot-Timestamp'] = ts + headers['X-Chatwoot-Signature'] = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', @secret, "#{ts}.#{body}")}" + end + headers + end + def handle_error(error) return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless message @@ -72,7 +86,11 @@ class Webhooks::Trigger def message return if message_id.blank? - @message ||= Message.find_by(id: message_id) + if defined?(@message) + @message + else + @message = Message.find_by(id: message_id) + end end def message_id diff --git a/spec/jobs/agent_bots/webhook_job_spec.rb b/spec/jobs/agent_bots/webhook_job_spec.rb index a8117d84e..346d85e83 100644 --- a/spec/jobs/agent_bots/webhook_job_spec.rb +++ b/spec/jobs/agent_bots/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe AgentBots::WebhookJob do end it 'executes perform' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/jobs/webhook_job_spec.rb b/spec/jobs/webhook_job_spec.rb index 81802a3c0..c74c1d8a8 100644 --- a/spec/jobs/webhook_job_spec.rb +++ b/spec/jobs/webhook_job_spec.rb @@ -16,7 +16,7 @@ RSpec.describe WebhookJob do end it 'executes perform with default webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end @@ -24,7 +24,7 @@ RSpec.describe WebhookJob do let(:webhook_type) { :api_inbox_webhook } it 'executes perform with inbox webhook type' do - expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type) + expect(Webhooks::Trigger).to receive(:execute).with(url, payload, webhook_type, secret: nil, delivery_id: nil) perform_enqueued_jobs { job } end end diff --git a/spec/lib/webhooks/trigger_spec.rb b/spec/lib/webhooks/trigger_spec.rb index 79cf92150..1e047b557 100644 --- a/spec/lib/webhooks/trigger_spec.rb +++ b/spec/lib/webhooks/trigger_spec.rb @@ -168,6 +168,71 @@ describe Webhooks::Trigger do end end + describe 'request headers' do + let(:payload) { { event: 'message_created' } } + let(:body) { payload.to_json } + + context 'without secret or delivery_id' do + it 'sends only content-type and accept headers' do + expect(RestClient::Request).to receive(:execute).with( + hash_including(headers: { content_type: :json, accept: :json }) + ) + trigger.execute(url, payload, webhook_type) + end + end + + context 'with delivery_id' do + it 'adds X-Chatwoot-Delivery header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('test-uuid') + expect(args[:headers]).not_to have_key('X-Chatwoot-Signature') + expect(args[:headers]).not_to have_key('X-Chatwoot-Timestamp') + end + trigger.execute(url, payload, webhook_type, delivery_id: 'test-uuid') + end + end + + context 'with secret' do + let(:secret) { 'test-secret' } + + it 'adds X-Chatwoot-Timestamp header' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Timestamp']).to match(/\A\d+\z/) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'adds X-Chatwoot-Signature header with correct HMAC' do + expect(RestClient::Request).to receive(:execute) do |args| + ts = args[:headers]['X-Chatwoot-Timestamp'] + expected_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, "#{ts}.#{body}")}" + expect(args[:headers]['X-Chatwoot-Signature']).to eq(expected_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + + it 'signs timestamp.body not just body' do + expect(RestClient::Request).to receive(:execute) do |args| + args[:headers]['X-Chatwoot-Timestamp'] + wrong_sig = "sha256=#{OpenSSL::HMAC.hexdigest('SHA256', secret, body)}" + expect(args[:headers]['X-Chatwoot-Signature']).not_to eq(wrong_sig) + end + trigger.execute(url, payload, webhook_type, secret: secret) + end + end + + context 'with both secret and delivery_id' do + it 'includes all three security headers' do + expect(RestClient::Request).to receive(:execute) do |args| + expect(args[:headers]['X-Chatwoot-Delivery']).to eq('abc-123') + expect(args[:headers]['X-Chatwoot-Timestamp']).to be_present + expect(args[:headers]['X-Chatwoot-Signature']).to start_with('sha256=') + end + trigger.execute(url, payload, webhook_type, secret: 'mysecret', delivery_id: 'abc-123') + end + end + end + it 'does not update message status if webhook fails for other events' do payload = { event: 'conversation_created', conversation: { id: conversation.id }, id: message.id } diff --git a/spec/listeners/webhook_listener_spec.rb b/spec/listeners/webhook_listener_spec.rb index 5062b11bc..51dae239b 100644 --- a/spec/listeners/webhook_listener_spec.rb +++ b/spec/listeners/webhook_listener_spec.rb @@ -28,7 +28,10 @@ describe WebhookListener do context 'when webhook is configured and event is subscribed' do it 'triggers the webhook event' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, message.webhook_data.merge(event: 'message_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, message.webhook_data.merge(event: 'message_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.message_created(message_created_event) end end @@ -54,8 +57,10 @@ describe WebhookListener do conversation: api_conversation ) api_event = Events::Base.new(event_name, Time.zone.now, message: api_message) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, api_message.webhook_data.merge(event: 'message_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.message_created(api_event) end @@ -90,7 +95,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, inbox: inbox, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, conversation.webhook_data.merge(event: 'conversation_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, conversation.webhook_data.merge(event: 'conversation_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_created(conversation_created_event) end end @@ -101,9 +109,11 @@ describe WebhookListener do api_inbox = channel_api.inbox api_conversation = create(:conversation, account: account, inbox: api_inbox, assignee: user) api_event = Events::Base.new(event_name, Time.zone.now, conversation: api_conversation) - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, - api_conversation.webhook_data.merge(event: 'conversation_created'), - :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, + api_conversation.webhook_data.merge(event: 'conversation_created'), + :api_inbox_webhook, delivery_id: instance_of(String) + ).once listener.conversation_created(api_event) end @@ -156,7 +166,9 @@ describe WebhookListener do } } ] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.conversation_updated(conversation_updated_event) @@ -177,7 +189,10 @@ describe WebhookListener do context 'when webhook is configured' do it 'triggers webhook' do webhook = create(:webhook, account: account) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, contact.webhook_data.merge(event: 'contact_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, contact.webhook_data.merge(event: 'contact_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.contact_created(contact_event) end end @@ -213,7 +228,9 @@ describe WebhookListener do contact.webhook_data.merge( event: 'contact_updated', changed_attributes: [{ 'name' => { :current_value => 'Jane Doe', :previous_value => 'Jane' } }] - ) + ), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.contact_updated(contact_updated_event) end @@ -235,7 +252,10 @@ describe WebhookListener do it 'triggers webhook' do inbox_data = Inbox::EventDataPresenter.new(inbox).push_data webhook = create(:webhook, account: account, subscriptions: ['inbox_created']) - expect(WebhookJob).to receive(:perform_later).with(webhook.url, inbox_data.merge(event: 'inbox_created')).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, inbox_data.merge(event: 'inbox_created'), :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.inbox_created(inbox_created_event) end end @@ -272,7 +292,9 @@ describe WebhookListener do expect(WebhookJob).to receive(:perform_later).with( webhook.url, - inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data) + inbox_data.merge(event: 'inbox_updated', changed_attributes: changed_attributes_data), + :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) ).once listener.inbox_updated(inbox_updated_event) @@ -302,7 +324,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_on(typing_event) end end @@ -321,7 +346,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(channel_api.webhook_url, payload, :api_inbox_webhook).once + expect(WebhookJob).to receive(:perform_later).with( + channel_api.webhook_url, payload, :api_inbox_webhook, + delivery_id: instance_of(String) + ).once listener.conversation_typing_on(api_event) end end @@ -349,7 +377,10 @@ describe WebhookListener do is_private: false } - expect(WebhookJob).to receive(:perform_later).with(webhook.url, payload).once + expect(WebhookJob).to receive(:perform_later).with( + webhook.url, payload, :account_webhook, + secret: webhook.secret, delivery_id: instance_of(String) + ).once listener.conversation_typing_off(typing_event) end end diff --git a/spec/models/webhook_spec.rb b/spec/models/webhook_spec.rb index 81e6d9551..b8570de59 100644 --- a/spec/models/webhook_spec.rb +++ b/spec/models/webhook_spec.rb @@ -8,4 +8,20 @@ RSpec.describe Webhook do describe 'associations' do it { is_expected.to belong_to(:account) } end + + describe 'secret token' do + let!(:account) { create(:account) } + + it 'auto-generates a secret on create' do + webhook = create(:webhook, account: account) + expect(webhook.secret).to be_present + end + + it 'does not regenerate the secret on update' do + webhook = create(:webhook, account: account) + original_secret = webhook.secret + webhook.update!(url: "#{webhook.url}?updated=1") + expect(webhook.reload.secret).to eq(original_secret) + end + end end
+ {{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }} +