Fixes #13619 ## Summary - Add `TwilioSignatureVerifyConcern` that validates the `X-Twilio-Signature` header using `Twilio::Security::RequestValidator` (already bundled via `twilio-ruby` gem) - Include the concern in `Twilio::CallbackController` and `Twilio::DeliveryStatusController` — both endpoints were previously accepting requests from any source with no authentication - Channels using API key authentication (`api_key_sid` present) skip validation with a warning log, since Twilio signs with the account auth token which isn't stored for those channels ## How it works 1. `before_action` looks up the `Channel::TwilioSms` from request params (`MessagingServiceSid` or `AccountSid` + phone number) 2. Validates the HMAC-SHA1 signature using the channel's auth token 3. Returns `403 Forbidden` if signature is invalid, missing, or channel not found 4. Handles reverse proxy URL reconstruction via `X-Forwarded-Proto` header Follows the same pattern used by `Webhooks::ShopifyController` and `Webhooks::TiktokController`. ## Test plan - [x] Valid signature → 204 No Content, job enqueued - [x] Invalid signature → 403 Forbidden, job not enqueued - [x] Missing signature header → 403 Forbidden - [x] Channel not found → 403 Forbidden - [x] API key channel → skips validation, job enqueued (with warning log) - [x] MessagingServiceSid lookup → validates and enqueues - [x] All existing Twilio service/job specs pass (99 examples, 0 failures) --------- Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com> Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com> Co-authored-by: Sojan Jose <sojan@pepalo.com>
136 lines
4.6 KiB
Ruby
136 lines
4.6 KiB
Ruby
require 'rails_helper'
|
|
|
|
RSpec.describe 'Twilio::DeliveryStatusController', type: :request do
|
|
include Rails.application.routes.url_helpers
|
|
|
|
describe 'POST /twilio/delivery_status' do
|
|
let(:account) { create(:account) }
|
|
let(:twilio_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
|
|
let(:params) do
|
|
{
|
|
'MessageSid' => 'SM123',
|
|
'MessageStatus' => 'delivered',
|
|
'AccountSid' => 'AC123',
|
|
'From' => twilio_channel.phone_number
|
|
}
|
|
end
|
|
|
|
def post_with_signature(url, params:, channel: twilio_channel)
|
|
validator = Twilio::Security::RequestValidator.new(channel.auth_token)
|
|
signature = validator.build_signature_for(url, params)
|
|
post url, params: params, headers: { 'X-Twilio-Signature' => signature }
|
|
end
|
|
|
|
context 'with valid signature' do
|
|
it 'enqueues the delivery status job' do
|
|
url = twilio_delivery_status_index_url
|
|
expect do
|
|
post_with_signature(url, params: params)
|
|
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
|
|
end
|
|
|
|
it 'returns no content status' do
|
|
url = twilio_delivery_status_index_url
|
|
post_with_signature(url, params: params)
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'with invalid signature' do
|
|
it 'returns forbidden status' do
|
|
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
|
|
it 'does not enqueue the job' do
|
|
expect do
|
|
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
|
|
end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
|
|
end
|
|
end
|
|
|
|
context 'with missing signature header' do
|
|
it 'returns forbidden status' do
|
|
post twilio_delivery_status_index_url, params: params
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
|
|
context 'when channel uses API key authentication' do
|
|
let(:twilio_channel) do
|
|
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
|
|
end
|
|
|
|
it 'skips signature validation and enqueues the job' do
|
|
expect do
|
|
post twilio_delivery_status_index_url, params: params
|
|
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
|
|
end
|
|
end
|
|
|
|
context 'with MessagingServiceSid lookup' do
|
|
let(:twilio_channel) { create(:channel_twilio_sms, account: account, account_sid: 'AC123') }
|
|
let(:params) do
|
|
{
|
|
'MessageSid' => 'SM123',
|
|
'MessageStatus' => 'delivered',
|
|
'AccountSid' => 'AC123',
|
|
'MessagingServiceSid' => twilio_channel.messaging_service_sid
|
|
}
|
|
end
|
|
|
|
it 'validates and enqueues the job' do
|
|
url = twilio_delivery_status_index_url
|
|
post_with_signature(url, params: params)
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'when To does not map to a channel but From does' do
|
|
let(:params) do
|
|
{
|
|
'MessageSid' => 'SM123',
|
|
'MessageStatus' => 'delivered',
|
|
'AccountSid' => 'AC123',
|
|
'To' => '+19999999999',
|
|
'From' => twilio_channel.phone_number
|
|
}
|
|
end
|
|
|
|
it 'falls back to From lookup and enqueues the delivery status job' do
|
|
url = twilio_delivery_status_index_url
|
|
|
|
expect do
|
|
post_with_signature(url, params: params)
|
|
end.to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
|
|
|
|
expect(response).to have_http_status(:no_content)
|
|
end
|
|
end
|
|
|
|
context 'when To maps to an API-key channel but From maps to a different channel' do
|
|
let!(:api_key_channel) do
|
|
create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123', api_key_sid: 'SK123')
|
|
end
|
|
let!(:from_channel) { create(:channel_twilio_sms, :with_phone_number, account: account, account_sid: 'AC123') }
|
|
let(:params) do
|
|
{
|
|
'MessageSid' => 'SM123',
|
|
'MessageStatus' => 'delivered',
|
|
'AccountSid' => 'AC123',
|
|
'To' => api_key_channel.phone_number,
|
|
'From' => from_channel.phone_number
|
|
}
|
|
end
|
|
|
|
it 'rejects invalid signatures instead of skipping verification' do
|
|
expect do
|
|
post twilio_delivery_status_index_url, params: params, headers: { 'X-Twilio-Signature' => 'invalid' }
|
|
end.not_to have_enqueued_job(Webhooks::TwilioDeliveryStatusJob)
|
|
|
|
expect(response).to have_http_status(:forbidden)
|
|
end
|
|
end
|
|
end
|
|
end
|