feat: sign webhooks for API channel and agentbots (#13892)
Account webhooks sign outgoing payloads with HMAC-SHA256, but agent bot and API inbox webhooks were delivered unsigned. This PR adds the same signing to both. Each model gets a dedicated `secret` column rather than reusing the agent bot's `access_token` (for API auth back into Chatwoot) or the API inbox's `hmac_token` (for inbound contact identity verification). These serve different trust boundaries and shouldn't be coupled — rotating a signing secret shouldn't invalidate API access or contact verification. The existing `Webhooks::Trigger` already signs when a secret is present, so the backend change is just passing `secret:` through to the jobs. Shared token logic is extracted into a `WebhookSecretable` concern included by `Webhook`, `AgentBot`, and `Channel::Api`. The frontend reuses the existing `AccessToken` component for secret display. Secrets are admin-only and excluded from enterprise audit logs. ### How to test Point an agent bot or API inbox webhook URL at a request inspector. Send a message and verify `X-Chatwoot-Signature` and `X-Chatwoot-Timestamp` headers are present. Reset the secret from settings and confirm subsequent deliveries use the new value. --------- Co-authored-by: Sojan Jose <sojan@pepalo.com>
This commit is contained in:
@@ -25,8 +25,10 @@ describe AgentBotListener do
|
||||
context 'when agent bot is configured' do
|
||||
it 'sends message to agent bot' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
||||
message.webhook_data.merge(event: 'message_created')).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url, message.webhook_data.merge(event: 'message_created'),
|
||||
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(event)
|
||||
end
|
||||
|
||||
@@ -48,8 +50,14 @@ describe AgentBotListener do
|
||||
it 'sends message to both bots exactly once' do
|
||||
payload = message.webhook_data.merge(event: 'message_created')
|
||||
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url, payload).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(conversation_bot.outgoing_url, payload).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url, payload, :agent_bot_webhook,
|
||||
secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
conversation_bot.outgoing_url, payload, :agent_bot_webhook,
|
||||
secret: conversation_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.message_created(event)
|
||||
end
|
||||
@@ -74,9 +82,11 @@ describe AgentBotListener do
|
||||
|
||||
it 'sends webhook to the inbox agent bot with changed_attributes' do
|
||||
create(:agent_bot_inbox, inbox: inbox, agent_bot: agent_bot)
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(event: 'conversation_updated',
|
||||
changed_attributes: nil)).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(event: 'conversation_updated', changed_attributes: nil),
|
||||
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
@@ -93,11 +103,14 @@ describe AgentBotListener do
|
||||
|
||||
it 'sends webhook with changed_attributes to the assigned agent bot' do
|
||||
expected_changed_attributes = [{ 'assignee_agent_bot_id' => { previous_value: nil, current_value: agent_bot.id } }]
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(
|
||||
event: 'conversation_updated',
|
||||
changed_attributes: expected_changed_attributes
|
||||
)).once
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later).with(
|
||||
agent_bot.outgoing_url,
|
||||
conversation.webhook_data.merge(
|
||||
event: 'conversation_updated',
|
||||
changed_attributes: expected_changed_attributes
|
||||
),
|
||||
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_updated(event)
|
||||
end
|
||||
end
|
||||
@@ -121,7 +134,8 @@ describe AgentBotListener do
|
||||
expect(AgentBots::WebhookJob).to receive(:perform_later)
|
||||
.with(
|
||||
agent_bot.outgoing_url,
|
||||
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' })
|
||||
conversation.contact_inbox.webhook_data.merge(event: 'webwidget_triggered', event_info: { country: 'US' }),
|
||||
:agent_bot_webhook, secret: agent_bot.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
|
||||
listener.webwidget_triggered(event)
|
||||
|
||||
@@ -59,7 +59,7 @@ describe WebhookListener do
|
||||
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, delivery_id: instance_of(String)
|
||||
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.message_created(api_event)
|
||||
end
|
||||
@@ -112,7 +112,7 @@ describe WebhookListener do
|
||||
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)
|
||||
:api_inbox_webhook, secret: channel_api.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_created(api_event)
|
||||
end
|
||||
@@ -348,7 +348,7 @@ describe WebhookListener do
|
||||
|
||||
expect(WebhookJob).to receive(:perform_later).with(
|
||||
channel_api.webhook_url, payload, :api_inbox_webhook,
|
||||
delivery_id: instance_of(String)
|
||||
secret: channel_api.secret, delivery_id: instance_of(String)
|
||||
).once
|
||||
listener.conversation_typing_on(api_event)
|
||||
end
|
||||
|
||||
Reference in New Issue
Block a user