feat: add per-webhook secret with backfill migration (#13573)

This commit is contained in:
Shivam Mishra
2026-02-26 17:26:12 +05:30
committed by GitHub
parent 7c60ad9e28
commit c218eff5ec
19 changed files with 319 additions and 78 deletions

View File

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

View File

@@ -58,6 +58,7 @@ export default {
},
},
mounted() {
this.$store.dispatch('integrations/get', 'webhook');
this.$store.dispatch('webhooks/get');
},
methods: {

View File

@@ -1,60 +1,98 @@
<script>
<script setup>
import { ref, computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { useStore } from 'vuex';
import { useAlert } from 'dashboard/composables';
import { useBranding } from 'shared/composables/useBranding';
import { mapGetters } from 'vuex';
import { copyTextToClipboard } from 'shared/helpers/clipboard';
import WebhookForm from './WebhookForm.vue';
import NextButton from 'dashboard/components-next/button/Button.vue';
export default {
components: { WebhookForm },
props: {
onClose: {
type: Function,
required: true,
},
},
setup() {
const { replaceInstallationName } = useBranding();
return {
replaceInstallationName,
};
},
computed: {
...mapGetters({
uiFlags: 'webhooks/getUIFlags',
}),
},
methods: {
async onSubmit(webhook) {
try {
await this.$store.dispatch('webhooks/create', { webhook });
useAlert(
this.$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
);
this.onClose();
} catch (error) {
const message =
error.response.data.message ||
this.$t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
},
const props = defineProps({
onClose: {
type: Function,
required: true,
},
});
const { t } = useI18n();
const store = useStore();
const { replaceInstallationName } = useBranding();
const createdWebhook = ref(null);
const uiFlags = computed(() => store.getters['webhooks/getUIFlags']);
const onSubmit = async webhook => {
try {
const result = await store.dispatch('webhooks/create', { webhook });
createdWebhook.value = result;
} catch (error) {
const message =
error.response.data.message ||
t('INTEGRATION_SETTINGS.WEBHOOK.EDIT.API.ERROR_MESSAGE');
useAlert(message);
}
};
const handleCopySecret = async () => {
await copyTextToClipboard(createdWebhook.value.secret);
useAlert(t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY_SUCCESS'));
};
</script>
<template>
<div class="h-auto overflow-auto flex flex-col">
<woot-modal-header
:header-title="$t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName($t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="$t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="onClose"
/>
<template v-if="createdWebhook">
<woot-modal-header
:header-title="
t('INTEGRATION_SETTINGS.WEBHOOK.ADD.API.SUCCESS_MESSAGE')
"
/>
<div class="px-8 pb-6">
<p class="text-sm text-n-slate-11 mb-4">
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.CREATED_DESC') }}
</p>
<label>
{{ t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="createdWebhook.secret"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
icon="i-lucide-copy"
slate
faded
@click="handleCopySecret"
/>
</div>
</label>
<div class="flex justify-end mt-4">
<NextButton
blue
:label="t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.DONE')"
@click="props.onClose()"
/>
</div>
</div>
</template>
<template v-else>
<woot-modal-header
:header-title="t('INTEGRATION_SETTINGS.WEBHOOK.ADD.TITLE')"
:header-content="
replaceInstallationName(t('INTEGRATION_SETTINGS.WEBHOOK.FORM.DESC'))
"
/>
<WebhookForm
:is-submitting="uiFlags.creatingItem"
:submit-label="t('INTEGRATION_SETTINGS.WEBHOOK.FORM.ADD_SUBMIT')"
@submit="onSubmit"
@cancel="props.onClose()"
/>
</template>
</div>
</template>

View File

@@ -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"
/>
</label>
<label v-if="hasSecret" class="mb-4">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.LABEL') }}
<div class="flex items-center gap-2">
<input
:value="
secretVisible ? value.secret : '••••••••••••••••••••••••••••••••'
"
type="text"
readonly
class="!mb-0 font-mono"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.TOGGLE')"
type="button"
:icon="secretVisible ? 'i-lucide-eye-off' : 'i-lucide-eye'"
slate
faded
@click="secretVisible = !secretVisible"
/>
<NextButton
v-tooltip.top="$t('INTEGRATION_SETTINGS.WEBHOOK.SECRET.COPY')"
type="button"
icon="i-lucide-copy"
slate
faded
@click="copySecret"
/>
</div>
</label>
<label :class="{ error: v$.url.$error }" class="mb-2">
{{ $t('INTEGRATION_SETTINGS.WEBHOOK.FORM.SUBSCRIPTIONS.LABEL') }}
</label>

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
class AddSecretToWebhooks < ActiveRecord::Migration[7.1]
def change
add_column :webhooks, :secret, :string
end
end

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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