Compare commits

...
40 changed files with 1495 additions and 514 deletions
@@ -1,4 +1,6 @@
class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuilder
include ::Messages::Instagram::StoryReplyAttachmentHandling
attr_reader :messaging attr_reader :messaging
def initialize(messaging, inbox, outgoing_echo: false) def initialize(messaging, inbox, outgoing_echo: false)
@@ -115,24 +117,6 @@ class Messages::Instagram::BaseMessageBuilder < Messages::Messenger::MessageBuil
create_story_reply_attachment(story_reply_attributes['url']) create_story_reply_attachment(story_reply_attributes['url'])
end end
def create_story_reply_attachment(story_url)
return if story_url.blank?
attachment = @message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
attachment.save!
begin
attach_file(attachment, story_url)
rescue Down::Error, StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
end
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
def build_conversation def build_conversation
@contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id) @contact_inbox ||= contact.contact_inboxes.find_by!(source_id: message_source_id)
Conversation.create!(conversation_params.merge( Conversation.create!(conversation_params.merge(
@@ -0,0 +1,55 @@
module Messages::Instagram::StoryReplyAttachmentHandling
private
def create_story_reply_attachment(story_url)
return if story_url.blank?
attach_story_reply_file(build_story_reply_attachment(story_url), story_url)
update_story_reply_message
end
def build_story_reply_attachment(story_url)
@message.attachments.new(
file_type: :ig_story,
account_id: @message.account_id,
external_url: story_url
)
end
def attach_story_reply_file(attachment, story_url)
downloaded_file = nil
SafeFetch.fetch(story_url, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES) do |attachment_file|
downloaded_file = attachment_file
persist_story_reply_attachment(attachment, attachment_file)
end
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading Messenger attachment from #{story_url}: #{e.message}: Skipping"
discard_story_reply_attachment(attachment)
rescue StandardError => e
Rails.logger.warn "Failed to download Instagram story attachment: #{e.message}"
discard_story_reply_attachment(attachment)
ensure
downloaded_file&.close!
end
def persist_story_reply_attachment(attachment, attachment_file)
attachment.file.attach(
io: attachment_file.tempfile,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
attachment.save!
end
def discard_story_reply_attachment(attachment)
return attachment.destroy if attachment.persisted?
@message.attachments.proxy_association.target.delete(attachment)
end
def update_story_reply_message
@message.content_attributes[:image_type] = 'ig_story_reply'
@message.save!
end
end
@@ -5,14 +5,46 @@ class Messages::Messenger::MessageBuilder
# This check handles very rare case if there are multiple files to attach with only one unsupported file # This check handles very rare case if there are multiple files to attach with only one unsupported file
return if unsupported_file_type?(attachment['type']) return if unsupported_file_type?(attachment['type'])
attachment_obj, downloaded_file = prepare_attachment(attachment)
return unless attachment_obj
finalize_attachment(attachment_obj)
ensure
downloaded_file&.close!
end
def prepare_attachment(attachment)
params = attachment_params(attachment) params = attachment_params(attachment)
attachment_obj = @message.attachments.new(params.except(:remote_file_url)) attachment_obj = @message.attachments.new(params.except(:remote_file_url))
downloaded_file = persist_attachment(attachment, attachment_obj, params[:remote_file_url])
return [nil, downloaded_file] if params[:remote_file_url].present? && downloaded_file.blank?
[attachment_obj, downloaded_file]
end
def persist_attachment(attachment, attachment_obj, remote_file_url)
return save_facebook_reel_attachment(attachment, attachment_obj) if facebook_reel?(attachment)
return save_remote_file_attachment(attachment_obj, remote_file_url) if remote_file_url.present?
attachment_obj.save! attachment_obj.save!
if facebook_reel?(attachment) nil
update_facebook_reel_content(attachment) end
elsif params[:remote_file_url]
attach_file(attachment_obj, params[:remote_file_url]) def save_facebook_reel_attachment(attachment, attachment_obj)
end attachment_obj.save!
update_facebook_reel_content(attachment)
nil
end
def save_remote_file_attachment(attachment_obj, remote_file_url)
downloaded_file = attach_file(attachment_obj, remote_file_url)
return if downloaded_file.blank?
attachment_obj.save!
downloaded_file
end
def finalize_attachment(attachment_obj)
fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention' fetch_story_link(attachment_obj) if attachment_obj.file_type == 'story_mention'
fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story' fetch_ig_story_link(attachment_obj) if attachment_obj.file_type == 'ig_story'
fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post' fetch_ig_post_link(attachment_obj) if attachment_obj.file_type == 'ig_post'
@@ -20,14 +52,22 @@ class Messages::Messenger::MessageBuilder
end end
def attach_file(attachment, file_url) def attach_file(attachment, file_url)
attachment_file = Down.download( downloaded_file = nil
file_url SafeFetch.fetch(
) file_url,
attachment.file.attach( allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES
io: attachment_file, ) do |attachment_file|
filename: attachment_file.original_filename, downloaded_file = attachment_file
content_type: attachment_file.content_type attachment.file.attach(
) io: attachment_file.tempfile,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
)
end
downloaded_file
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading Messenger attachment from #{file_url}: #{e.message}: Skipping"
nil
end end
def attachment_params(attachment) def attachment_params(attachment)
@@ -21,6 +21,8 @@ class Api::V1::Accounts::UploadController < Api::V1::Accounts::BaseController
def create_from_url def create_from_url
SafeFetch.fetch(params[:external_url].to_s) do |result| SafeFetch.fetch(params[:external_url].to_s) do |result|
create_and_save_blob(result.tempfile, result.filename, result.content_type) create_and_save_blob(result.tempfile, result.filename, result.content_type)
ensure
result.close!
end end
rescue SafeFetch::HttpError => e rescue SafeFetch::HttpError => e
render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity) render_error(I18n.t('errors.upload.fetch_failed_with_message', message: e.message), :unprocessable_entity)
+42 -16
View File
@@ -13,23 +13,12 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
RATE_LIMIT_WINDOW = 1.minute RATE_LIMIT_WINDOW = 1.minute
def perform(avatarable, avatar_url) def perform(avatarable, avatar_url)
return unless avatarable.respond_to?(:avatar) return unless syncable_avatar?(avatarable, avatar_url)
return unless url_valid?(avatar_url)
return unless should_sync_avatar?(avatarable, avatar_url) fetch_avatar(avatarable, avatar_url)
rescue SafeFetch::HttpError => e
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE) log_http_error(avatar_url, e)
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file) rescue SafeFetch::Error => e
avatarable.avatar.attach(
io: avatar_file,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
rescue Down::NotFound
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
rescue Down::Error => e
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}" Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
ensure ensure
update_avatar_sync_attributes(avatarable, avatar_url) update_avatar_sync_attributes(avatarable, avatar_url)
@@ -37,6 +26,43 @@ class Avatar::AvatarFromUrlJob < ApplicationJob
private private
def syncable_avatar?(avatarable, avatar_url)
avatarable.respond_to?(:avatar) &&
url_valid?(avatar_url) &&
should_sync_avatar?(avatarable, avatar_url)
end
def fetch_avatar(avatarable, avatar_url)
SafeFetch.fetch(
avatar_url,
max_bytes: MAX_DOWNLOAD_SIZE,
allowed_content_type_prefixes: [],
allowed_content_types: %w[image/jpeg image/png image/gif]
) do |avatar_file|
attach_avatar(avatarable, avatar_file)
ensure
avatar_file.close!
end
end
def attach_avatar(avatarable, avatar_file)
raise SafeFetch::FetchError, 'Invalid file' unless valid_file?(avatar_file)
avatarable.avatar.attach(
io: avatar_file.tempfile,
filename: avatar_file.original_filename,
content_type: avatar_file.content_type
)
end
def log_http_error(avatar_url, error)
if error.message.start_with?('404')
Rails.logger.info "AvatarFromUrlJob: avatar not found at #{avatar_url}"
else
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{error.class} - #{error.message}"
end
end
def should_sync_avatar?(avatarable, avatar_url) def should_sync_avatar?(avatarable, avatar_url)
# Only Contacts are rate-limited and hash-gated. # Only Contacts are rate-limited and hash-gated.
return true unless avatarable.is_a?(Contact) return true unless avatarable.is_a?(Contact)
+15
View File
@@ -29,6 +29,7 @@ class Macro < ApplicationRecord
enum visibility: { personal: 0, global: 1 } enum visibility: { personal: 0, global: 1 }
validate :json_actions_format validate :json_actions_format
validate :validate_webhook_action_urls
ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_agent ACTIONS_ATTRS = %w[send_message add_label assign_team assign_agent mute_conversation change_status remove_label remove_assigned_agent
remove_assigned_team resolve_conversation snooze_conversation change_priority send_email_transcript remove_assigned_team resolve_conversation snooze_conversation change_priority send_email_transcript
@@ -73,6 +74,20 @@ class Macro < ApplicationRecord
errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any? errors.add(:actions, "Macro execution actions #{actions.join(',')} not supported.") if actions.any?
end end
def validate_webhook_action_urls
return if actions.blank?
actions.each do |action|
action = action.respond_to?(:with_indifferent_access) ? action.with_indifferent_access : {}
next unless action[:action_name] == 'send_webhook_event'
webhook_url = Array(action[:action_params]).first
SafeOutboundUrl.validate!(webhook_url)
rescue SafeOutboundUrl::Error
errors.add(:actions, 'Webhook URL for send_webhook_event must be a public http(s) URL.')
end
end
end end
Macro.include_mod_with('Audit::Macro') Macro.include_mod_with('Audit::Macro')
@@ -0,0 +1,14 @@
module DownloadedFileTracking
private
def with_downloaded_files
@downloaded_files = []
yield
ensure
Array(@downloaded_files).each(&:close!)
end
def track_downloaded_file(downloaded_file)
(@downloaded_files ||= []) << downloaded_file
end
end
+4 -1
View File
@@ -64,7 +64,10 @@ class Macros::ExecutionService < ActionService
end end
def send_webhook_event(webhook_url) def send_webhook_event(webhook_url)
url = Array(webhook_url).first
SafeOutboundUrl.validate!(url)
payload = @conversation.webhook_data.merge(event: 'macro.executed') payload = @conversation.webhook_data.merge(event: 'macro.executed')
WebhookJob.perform_later(webhook_url.first, payload) WebhookJob.perform_later(url, payload, :macro_webhook)
end end
end end
+38 -26
View File
@@ -1,21 +1,24 @@
class Sms::IncomingMessageService class Sms::IncomingMessageService
include ::FileTypeHelper include ::FileTypeHelper
include ::DownloadedFileTracking
pattr_initialize [:inbox!, :params!] pattr_initialize [:inbox!, :params!]
def perform def perform
set_contact with_downloaded_files do
set_conversation set_contact
@message = @conversation.messages.create!( set_conversation
content: params[:text], @message = @conversation.messages.create!(
account_id: @inbox.account_id, content: params[:text],
inbox_id: @inbox.id, account_id: @inbox.account_id,
message_type: :incoming, inbox_id: @inbox.id,
sender: @contact, message_type: :incoming,
source_id: params[:id] sender: @contact,
) source_id: params[:id]
attach_files )
@message.save! attach_files
@message.save!
end
end end
private private
@@ -83,20 +86,29 @@ class Sms::IncomingMessageService
# we don't need to process this files since chatwoot doesn't support it # we don't need to process this files since chatwoot doesn't support it
next if media_url.end_with?('.smil', '.xml') next if media_url.end_with?('.smil', '.xml')
attachment_file = Down.download( download_attachment_file(media_url) do |attachment_file|
media_url, track_downloaded_file(attachment_file)
http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']] @message.attachments.new(
) account_id: @message.account_id,
file_type: file_type(attachment_file.content_type),
@message.attachments.new( file: {
account_id: @message.account_id, io: attachment_file.tempfile,
file_type: file_type(attachment_file.content_type), filename: attachment_file.original_filename,
file: { content_type: attachment_file.content_type
io: attachment_file, }
filename: attachment_file.original_filename, )
content_type: attachment_file.content_type end
}
)
end end
end end
def download_attachment_file(media_url, &)
SafeFetch.fetch(
media_url,
http_basic_authentication: [channel.provider_config['api_key'], channel.provider_config['api_secret']],
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
&
)
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading SMS attachment from #{media_url}: #{e.message}: Skipping"
end
end end
@@ -0,0 +1,78 @@
module Telegram::AttachmentHandling
private
def file_content_type
return :image if image_message?
return :audio if audio_message?
return :video if video_message?
file_type(params[:message][:document][:mime_type])
end
def image_message?
params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
end
def audio_message?
params[:message][:voice].present? || params[:message][:audio].present?
end
def video_message?
params[:message][:video].present? || params[:message][:video_note].present?
end
def attach_files
return unless file
file_download_path = telegram_file_download_path
return unless file_download_path
SafeFetch.fetch(file_download_path, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES) do |attachment_file|
build_file_attachment(attachment_file)
end
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading Telegram attachment from #{file_download_path}: #{e.message}: Skipping"
end
def telegram_file_download_path
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
return file_download_path if file_download_path.present?
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
nil
end
def build_file_attachment(attachment_file)
track_downloaded_file(attachment_file)
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type,
file: {
io: attachment_file.tempfile,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def file
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
end
def location_fallback_title
return '' if venue.blank?
venue[:title] || ''
end
def venue
@venue ||= params.dig(:message, :venue).presence
end
def visual_media_params
params[:message][:photo].presence&.last ||
params.dig(:message, :sticker, :thumb).presence ||
params[:message][:video].presence ||
params[:message][:video_note].presence
end
end
@@ -3,36 +3,40 @@
class Telegram::IncomingMessageService class Telegram::IncomingMessageService
include ::FileTypeHelper include ::FileTypeHelper
include ::DownloadedFileTracking
include ::Telegram::ParamHelpers include ::Telegram::ParamHelpers
include ::Telegram::AttachmentHandling
pattr_initialize [:inbox!, :params!] pattr_initialize [:inbox!, :params!]
def perform def perform
# chatwoot doesn't support group conversations at the moment with_downloaded_files do
transform_business_message! # chatwoot doesn't support group conversations at the moment
return unless private_message? transform_business_message!
return unless private_message?
set_contact set_contact
update_contact_avatar update_contact_avatar
set_conversation set_conversation
# TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request. # TODO: Since the recent Telegram Business update, we need to explicitly mark messages as read using an additional request.
# Otherwise, the client will see their messages as unread. # Otherwise, the client will see their messages as unread.
# Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations. # Chatwoot defines a 'read' status in its enum but does not currently update this status for Telegram conversations.
# We have two options: # We have two options:
# 1. Send the read request to Telegram here, immediately when the message is created. # 1. Send the read request to Telegram here, immediately when the message is created.
# 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message. # 2. Properly update the read status in the Chatwoot UI and trigger the Telegram request when the agent actually reads the message.
# See: https://core.telegram.org/bots/api#readbusinessmessage # See: https://core.telegram.org/bots/api#readbusinessmessage
@message = @conversation.messages.build( @message = @conversation.messages.build(
content: telegram_params_message_content, content: telegram_params_message_content,
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
message_type: message_type, message_type: message_type,
sender: message_sender, sender: message_sender,
content_attributes: telegram_params_content_attributes, content_attributes: telegram_params_content_attributes,
source_id: telegram_params_message_id.to_s source_id: telegram_params_message_id.to_s
) )
process_message_attachments if message_params? process_message_attachments if message_params?
@message.save! @message.save!
end
end end
private private
@@ -121,50 +125,6 @@ class Telegram::IncomingMessageService
business_message_outgoing? ? nil : @contact business_message_outgoing? ? nil : @contact
end end
def file_content_type
return :image if image_message?
return :audio if audio_message?
return :video if video_message?
file_type(params[:message][:document][:mime_type])
end
def image_message?
params[:message][:photo].present? || params.dig(:message, :sticker, :thumb).present?
end
def audio_message?
params[:message][:voice].present? || params[:message][:audio].present?
end
def video_message?
params[:message][:video].present? || params[:message][:video_note].present?
end
def attach_files
return unless file
file_download_path = inbox.channel.get_telegram_file_path(file[:file_id])
if file_download_path.blank?
Rails.logger.info "Telegram file download path is blank for #{file[:file_id]} : inbox_id: #{inbox.id}"
return
end
attachment_file = Down.download(
inbox.channel.get_telegram_file_path(file[:file_id])
)
@message.attachments.new(
account_id: @message.account_id,
file_type: file_content_type,
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def attach_location def attach_location
return unless location return unless location
@@ -191,20 +151,6 @@ class Telegram::IncomingMessageService
) )
end end
def file
@file ||= visual_media_params || params[:message][:voice].presence || params[:message][:audio].presence || params[:message][:document].presence
end
def location_fallback_title
return '' if venue.blank?
venue[:title] || ''
end
def venue
@venue ||= params.dig(:message, :venue).presence
end
def location def location
@location ||= params.dig(:message, :location).presence @location ||= params.dig(:message, :location).presence
end end
@@ -213,13 +159,6 @@ class Telegram::IncomingMessageService
@contact_card ||= params.dig(:message, :contact).presence @contact_card ||= params.dig(:message, :contact).presence
end end
def visual_media_params
params[:message][:photo].presence&.last ||
params.dig(:message, :sticker, :thumb).presence ||
params[:message][:video].presence ||
params[:message][:video_note].presence
end
def transform_business_message! def transform_business_message!
params[:message] = params[:business_message] if params[:business_message] && !params[:message] params[:message] = params[:business_message] if params[:business_message] && !params[:message]
end end
+37 -27
View File
@@ -1,4 +1,5 @@
class Tiktok::MessageService class Tiktok::MessageService
include DownloadedFileTracking
include Tiktok::MessagingHelpers include Tiktok::MessagingHelpers
pattr_initialize [:channel!, :content!, :outgoing_echo] pattr_initialize [:channel!, :content!, :outgoing_echo]
@@ -32,22 +33,12 @@ class Tiktok::MessageService
end end
def create_message def create_message
message = conversation.messages.build( with_downloaded_files do
content: message_content, message = build_message
account_id: channel.inbox.account_id, apply_message_direction(message)
inbox_id: channel.inbox.id, create_message_attachments(message)
message_type: incoming_message? ? :incoming : :outgoing, message.save!
content_attributes: message_content_attributes, end
source_id: tt_message_id,
created_at: tt_message_time,
updated_at: tt_message_time
)
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message?
create_message_attachments(message)
message.save!
end end
def message_content def message_content
@@ -64,17 +55,18 @@ class Tiktok::MessageService
def create_image_message_attachment(message) def create_image_message_attachment(message)
return unless image_message? return unless image_message?
attachment_file = fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id) fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id) do |attachment_file|
track_downloaded_file(attachment_file)
message.attachments.new( message.attachments.new(
account_id: message.account_id, account_id: message.account_id,
file_type: :image, file_type: :image,
file: { file: {
io: attachment_file, io: attachment_file.tempfile,
filename: attachment_file.original_filename, filename: attachment_file.original_filename,
content_type: attachment_file.content_type content_type: attachment_file.content_type
} }
) )
end
end end
def create_share_post_message_attachment(message) def create_share_post_message_attachment(message)
@@ -99,6 +91,24 @@ class Tiktok::MessageService
attributes attributes
end end
def build_message
conversation.messages.build(
content: message_content,
account_id: channel.inbox.account_id,
inbox_id: channel.inbox.id,
message_type: incoming_message? ? :incoming : :outgoing,
content_attributes: message_content_attributes,
source_id: tt_message_id,
created_at: tt_message_time,
updated_at: tt_message_time
)
end
def apply_message_direction(message)
message.sender = contact_inbox.contact if incoming_message? && !outgoing_echo
message.status = :delivered if outgoing_message?
end
def text_message? def text_message?
tt_message_type == 'text' tt_message_type == 'text'
end end
+9 -2
View File
@@ -66,9 +66,16 @@ module Tiktok::MessagingHelpers
message message
end end
def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id) def fetch_attachment(channel, tt_conversation_id, tt_message_id, tt_image_media_id, &)
file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id) file_download_url = tiktok_client(channel).file_download_url(tt_conversation_id, tt_message_id, tt_image_media_id)
Down.download(file_download_url, headers: { 'x-user' => channel.validated_access_token }) SafeFetch.fetch(
file_download_url,
headers: { 'x-user' => channel.validated_access_token },
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
&
)
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading TikTok attachment from #{file_download_url}: #{e.message}: Skipping"
end end
def tiktok_client(channel) def tiktok_client(channel)
@@ -0,0 +1,69 @@
module Twilio::AttachmentHandling
private
def attach_files
num_media = params[:NumMedia].to_i
return if num_media.zero?
num_media.times do |index|
media_url = params[:"MediaUrl#{index}"]
attach_single_file(media_url) if media_url.present?
end
end
def attach_single_file(media_url)
download_attachment_file(media_url) do |attachment_file|
track_downloaded_file(attachment_file)
@message.attachments.new(
account_id: @message.account_id,
file_type: file_type(attachment_file.content_type),
file: {
io: attachment_file.tempfile,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
end
def download_attachment_file(media_url, &)
download_with_auth(media_url, &)
rescue SafeFetch::Error => e
handle_download_attachment_error(e, media_url, &)
end
def download_with_auth(media_url, &)
SafeFetch.fetch(
media_url,
http_basic_authentication: attachment_auth_credentials,
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
&
)
end
def attachment_auth_credentials
return [twilio_channel.api_key_sid, twilio_channel.auth_token] if twilio_channel.api_key_sid.present?
[twilio_channel.account_sid, twilio_channel.auth_token]
end
def handle_download_attachment_error(error, media_url, &)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
SafeFetch.fetch(media_url, allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES, &)
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
end
def location_message?
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
end
def attach_location
@message.attachments.new(
account_id: @message.account_id,
file_type: :location,
coordinates_lat: params[:Latitude].to_f,
coordinates_long: params[:Longitude].to_f
)
end
end
+18 -78
View File
@@ -1,24 +1,28 @@
class Twilio::IncomingMessageService class Twilio::IncomingMessageService
include ::FileTypeHelper include ::FileTypeHelper
include ::DownloadedFileTracking
include ::Twilio::AttachmentHandling
pattr_initialize [:params!] pattr_initialize [:params!]
def perform def perform
return if twilio_channel.blank? with_downloaded_files do
return if twilio_channel.blank?
set_contact set_contact
set_conversation set_conversation
@message = @conversation.messages.build( @message = @conversation.messages.build(
content: message_body, content: message_body,
account_id: @inbox.account_id, account_id: @inbox.account_id,
inbox_id: @inbox.id, inbox_id: @inbox.id,
message_type: :incoming, message_type: :incoming,
sender: @contact, sender: @contact,
source_id: params[:SmsSid] source_id: params[:SmsSid]
) )
attach_files attach_files
attach_location if location_message? attach_location if location_message?
@message.save! @message.save!
end
end end
private private
@@ -132,70 +136,6 @@ class Twilio::IncomingMessageService
end end
end end
def attach_files
num_media = params[:NumMedia].to_i
return if num_media.zero?
num_media.times do |i|
media_url = params[:"MediaUrl#{i}"]
attach_single_file(media_url) if media_url.present?
end
end
def attach_single_file(media_url)
attachment_file = download_attachment_file(media_url)
return if attachment_file.blank?
@message.attachments.new(
account_id: @message.account_id,
file_type: file_type(attachment_file.content_type),
file: {
io: attachment_file,
filename: attachment_file.original_filename,
content_type: attachment_file.content_type
}
)
end
def download_attachment_file(media_url)
download_with_auth(media_url)
rescue Down::Error, Down::ClientError => e
handle_download_attachment_error(e, media_url)
end
def download_with_auth(media_url)
auth_credentials = if twilio_channel.api_key_sid.present?
# When using api_key_sid, the auth token should be the api_secret_key
[twilio_channel.api_key_sid, twilio_channel.auth_token]
else
# When using account_sid, the auth token is the account's auth token
[twilio_channel.account_sid, twilio_channel.auth_token]
end
Down.download(media_url, http_basic_authentication: auth_credentials)
end
def handle_download_attachment_error(error, media_url)
Rails.logger.info "Error downloading attachment from Twilio: #{error.message}: Retrying without auth"
Down.download(media_url)
rescue StandardError => e
Rails.logger.info "Error downloading attachment from Twilio: #{e.message}: Skipping"
nil
end
def location_message?
params[:MessageType] == 'location' && params[:Latitude].present? && params[:Longitude].present?
end
def attach_location
@message.attachments.new(
account_id: @message.account_id,
file_type: :location,
coordinates_lat: params[:Latitude].to_f,
coordinates_long: params[:Longitude].to_f
)
end
def update_contact_name_if_needed def update_contact_name_if_needed
return if params[:ProfileName].blank? return if params[:ProfileName].blank?
return if @contact.name == params[:ProfileName] return if @contact.name == params[:ProfileName]
@@ -2,6 +2,7 @@
# https://docs.360dialog.com/whatsapp-api/whatsapp-api/media # https://docs.360dialog.com/whatsapp-api/whatsapp-api/media
# https://developers.facebook.com/docs/whatsapp/api/media/ # https://developers.facebook.com/docs/whatsapp/api/media/
class Whatsapp::IncomingMessageBaseService class Whatsapp::IncomingMessageBaseService
include ::DownloadedFileTracking
include ::Whatsapp::IncomingMessageServiceHelpers include ::Whatsapp::IncomingMessageServiceHelpers
pattr_initialize [:inbox!, :params!, :outgoing_echo] pattr_initialize [:inbox!, :params!, :outgoing_echo]
@@ -17,31 +18,31 @@ class Whatsapp::IncomingMessageBaseService
end end
# Returns messages array for both regular messages and echo events # Returns messages array for both regular messages and echo events
def messages_data def messages_data = @processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
@processed_params&.dig(:messages) || @processed_params&.dig(:message_echoes)
end
private private
def process_messages def process_messages
# We don't support reactions & ephemeral message now, we need to skip processing the message with_downloaded_files do
# if the webhook event is a reaction or an ephermal message or an unsupported message. # We don't support reactions & ephemeral message now, we need to skip processing the message
return if unprocessable_message_type?(message_type) # if the webhook event is a reaction or an ephermal message or an unsupported message.
return if unprocessable_message_type?(message_type)
# Multiple webhook events can be received for the same message due to # Multiple webhook events can be received for the same message due to
# misconfigurations in the Meta business manager account. # misconfigurations in the Meta business manager account.
# We use an atomic Redis SET NX to prevent concurrent workers from both # We use an atomic Redis SET NX to prevent concurrent workers from both
# processing the same message simultaneously. # processing the same message simultaneously.
return if find_message_by_source_id(messages_data.first[:id]) return if find_message_by_source_id(messages_data.first[:id])
return unless lock_message_source_id! return unless lock_message_source_id!
set_contact set_contact
return unless @contact return unless @contact
return if @contact.blocked? && !outgoing_echo return if @contact.blocked? && !outgoing_echo
ActiveRecord::Base.transaction do ActiveRecord::Base.transaction do
set_conversation set_conversation
create_messages create_messages
end
end end
end end
@@ -148,18 +149,20 @@ class Whatsapp::IncomingMessageBaseService
attachment_payload = messages_data.first[message_type.to_sym] attachment_payload = messages_data.first[message_type.to_sym]
@message.content ||= attachment_payload[:caption] @message.content ||= attachment_payload[:caption]
attachment_file = download_attachment_file(attachment_payload) download_attachment_file(attachment_payload) do |attachment_file|
return if attachment_file.blank? track_downloaded_file(attachment_file)
@message.attachments.new(
@message.attachments.new( account_id: @message.account_id,
account_id: @message.account_id, file_type: file_content_type(message_type),
file_type: file_content_type(message_type), file: {
file: { io: attachment_file.tempfile,
io: attachment_file, filename: attachment_file.original_filename,
filename: attachment_file.original_filename, content_type: attachment_file.content_type
content_type: attachment_file.content_type }
} )
) end
rescue SafeFetch::Error => e
Rails.logger.info "Error downloading WhatsApp attachment: #{e.message}: Skipping"
end end
def attach_location def attach_location
@@ -1,6 +1,11 @@
module Whatsapp::IncomingMessageServiceHelpers module Whatsapp::IncomingMessageServiceHelpers
def download_attachment_file(attachment_payload) def download_attachment_file(attachment_payload, &)
Down.download(inbox.channel.media_url(attachment_payload[:id]), headers: inbox.channel.api_headers) SafeFetch.fetch(
inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers,
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
&
)
end end
def conversation_params def conversation_params
@@ -8,13 +8,20 @@ class Whatsapp::IncomingMessageWhatsappCloudService < Whatsapp::IncomingMessageB
@processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value') @processed_params ||= params[:entry].try(:first).try(:[], 'changes').try(:first).try(:[], 'value')
end end
def download_attachment_file(attachment_payload) def download_attachment_file(attachment_payload, &)
url_response = HTTParty.get( url_response = HTTParty.get(
inbox.channel.media_url(attachment_payload[:id]), inbox.channel.media_url(attachment_payload[:id]),
headers: inbox.channel.api_headers headers: inbox.channel.api_headers
) )
# This url response will be failure if the access token has expired. # This url response will be failure if the access token has expired.
inbox.channel.authorization_error! if url_response.unauthorized? inbox.channel.authorization_error! if url_response.unauthorized?
Down.download(url_response.parsed_response['url'], headers: inbox.channel.api_headers) if url_response.success? return unless url_response.success?
SafeFetch.fetch(
url_response.parsed_response['url'],
headers: inbox.channel.api_headers,
allowed_content_types: Attachment::ACCEPTABLE_FILE_TYPES,
&
)
end end
end end
+26 -80
View File
@@ -1,6 +1,10 @@
require 'agents' require 'agents'
class Captain::Tools::HttpTool < Agents::Tool class Captain::Tools::HttpTool < Agents::Tool
MAX_RESPONSE_SIZE = 1.megabyte
OPEN_TIMEOUT = 10
READ_TIMEOUT = 30
def initialize(assistant, custom_tool) def initialize(assistant, custom_tool)
@assistant = assistant @assistant = assistant
@custom_tool = custom_tool @custom_tool = custom_tool
@@ -15,8 +19,8 @@ class Captain::Tools::HttpTool < Agents::Tool
url = @custom_tool.build_request_url(params) url = @custom_tool.build_request_url(params)
body = @custom_tool.build_request_body(params) body = @custom_tool.build_request_body(params)
response = execute_http_request(url, body, tool_context) response_body = execute_http_request(url, body, tool_context)
@custom_tool.format_response(response.body) @custom_tool.format_response(response_body)
rescue StandardError => e rescue StandardError => e
Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}") Rails.logger.error("HttpTool execution error for #{@custom_tool.slug}: #{e.class} - #{e.message}")
'An error occurred while executing the request' 'An error occurred while executing the request'
@@ -24,89 +28,31 @@ class Captain::Tools::HttpTool < Agents::Tool
private private
PRIVATE_IP_RANGES = [
IPAddr.new('127.0.0.0/8'), # IPv4 Loopback
IPAddr.new('10.0.0.0/8'), # IPv4 Private network
IPAddr.new('172.16.0.0/12'), # IPv4 Private network
IPAddr.new('192.168.0.0/16'), # IPv4 Private network
IPAddr.new('169.254.0.0/16'), # IPv4 Link-local
IPAddr.new('::1'), # IPv6 Loopback
IPAddr.new('fc00::/7'), # IPv6 Unique local addresses
IPAddr.new('fe80::/10') # IPv6 Link-local
].freeze
# Limit response size to prevent memory exhaustion and match LLM token limits
# 1MB of text ≈ 250K tokens, which exceeds most LLM context windows
MAX_RESPONSE_SIZE = 1.megabyte
def execute_http_request(url, body, tool_context) def execute_http_request(url, body, tool_context)
uri = URI.parse(url) response_body = nil
# Check if resolved IP is private SafeFetch.fetch(
check_private_ip!(uri.host) url,
method: @custom_tool.http_method,
http = Net::HTTP.new(uri.host, uri.port) body: body,
http.use_ssl = uri.scheme == 'https' max_bytes: MAX_RESPONSE_SIZE,
http.read_timeout = 30 open_timeout: OPEN_TIMEOUT,
http.open_timeout = 10 read_timeout: READ_TIMEOUT,
http.max_retries = 0 # Disable redirects headers: request_headers(tool_context, body),
http_basic_authentication: @custom_tool.build_basic_auth_credentials,
request = build_http_request(uri, body) validate_content_type: false
apply_authentication(request) ) do |response|
apply_metadata_headers(request, tool_context) response_body = response.tempfile.read
ensure
response = http.request(request) response.close!
raise "HTTP request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
validate_response!(response)
response
end
def check_private_ip!(hostname)
ip_address = IPAddr.new(Resolv.getaddress(hostname))
raise 'Request blocked: hostname resolves to private IP address' if PRIVATE_IP_RANGES.any? { |range| range.include?(ip_address) }
rescue Resolv::ResolvError, SocketError => e
raise "DNS resolution failed: #{e.message}"
end
def validate_response!(response)
content_length = response['content-length']&.to_i
if content_length && content_length > MAX_RESPONSE_SIZE
raise "Response size #{content_length} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
end end
return unless response.body && response.body.bytesize > MAX_RESPONSE_SIZE response_body
raise "Response body size #{response.body.bytesize} bytes exceeds maximum allowed #{MAX_RESPONSE_SIZE} bytes"
end end
def build_http_request(uri, body) def request_headers(tool_context, body)
if @custom_tool.http_method == 'POST' headers = @custom_tool.build_auth_headers.merge(@custom_tool.build_metadata_headers(tool_context&.state || {}))
request = Net::HTTP::Post.new(uri.request_uri) headers['Content-Type'] = 'application/json' if @custom_tool.http_method == 'POST' && body.present?
if body headers
request.body = body
request['Content-Type'] = 'application/json'
end
else
request = Net::HTTP::Get.new(uri.request_uri)
end
request
end
def apply_authentication(request)
headers = @custom_tool.build_auth_headers
headers.each { |key, value| request[key] = value }
credentials = @custom_tool.build_basic_auth_credentials
request.basic_auth(*credentials) if credentials
end
def apply_metadata_headers(request, tool_context)
state = tool_context&.state || {}
metadata_headers = @custom_tool.build_metadata_headers(state)
metadata_headers.each { |key, value| request[key] = value }
end end
end end
+38 -72
View File
@@ -1,12 +1,40 @@
require 'ssrf_filter' require 'ssrf_filter'
module SafeFetch module SafeFetch
DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/].freeze DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES = %w[image/ video/ audio/].freeze
DEFAULT_ALLOWED_CONTENT_TYPES = %w[
text/csv
text/plain
text/rtf
application/json
application/pdf
application/zip
application/x-7z-compressed
application/vnd.rar
application/x-tar
application/msword
application/vnd.ms-excel
application/vnd.ms-powerpoint
application/rtf
application/vnd.oasis.opendocument.text
application/vnd.openxmlformats-officedocument.presentationml.presentation
application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
application/vnd.openxmlformats-officedocument.wordprocessingml.document
].freeze
DEFAULT_SENSITIVE_HEADERS = %w[authorization cookie].freeze
DEFAULT_OPEN_TIMEOUT = 2 DEFAULT_OPEN_TIMEOUT = 2
DEFAULT_READ_TIMEOUT = 20 DEFAULT_READ_TIMEOUT = 20
DEFAULT_MAX_BYTES_FALLBACK_MB = 40 DEFAULT_MAX_BYTES_FALLBACK_MB = 40
Result = Data.define(:tempfile, :filename, :content_type) Result = Data.define(:tempfile, :filename, :content_type) do
def original_filename
filename
end
def close!
tempfile.close! if tempfile.respond_to?(:close!)
end
end
class Error < StandardError; end class Error < StandardError; end
class InvalidUrlError < Error; end class InvalidUrlError < Error; end
@@ -15,84 +43,22 @@ module SafeFetch
class HttpError < Error; end class HttpError < Error; end
class FileTooLargeError < Error; end class FileTooLargeError < Error; end
class UnsupportedContentTypeError < Error; end class UnsupportedContentTypeError < Error; end
class UnsupportedMethodError < Error; end
end
def self.fetch(url, require_relative 'safe_fetch/request_options'
max_bytes: nil, require_relative 'safe_fetch/fetcher'
allowed_content_type_prefixes: DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES)
module SafeFetch
def self.fetch(url, **, &)
raise ArgumentError, 'block required' unless block_given? raise ArgumentError, 'block required' unless block_given?
effective_max_bytes = max_bytes || default_max_bytes Fetcher.new(RequestOptions.new(url: url, **)).fetch(&)
uri = parse_and_validate_url!(url)
filename = filename_for(uri)
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
response = stream_to_tempfile(url, tempfile, effective_max_bytes, allowed_content_type_prefixes)
raise HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
tempfile.rewind
yield Result.new(tempfile: tempfile, filename: filename, content_type: response['content-type'])
rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e rescue SsrfFilter::InvalidUriScheme, URI::InvalidURIError => e
raise InvalidUrlError, e.message raise InvalidUrlError, e.message
rescue SsrfFilter::Error, Resolv::ResolvError => e rescue SsrfFilter::Error, Resolv::ResolvError => e
raise UnsafeUrlError, e.message raise UnsafeUrlError, e.message
rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e rescue Net::OpenTimeout, Net::ReadTimeout, SocketError, OpenSSL::SSL::SSLError => e
raise FetchError, e.message raise FetchError, e.message
ensure
tempfile&.close!
end
class << self
private
def stream_to_tempfile(url, tempfile, max_bytes, allowed_content_type_prefixes)
response = nil
bytes_written = 0
SsrfFilter.get(
url,
http_options: { open_timeout: DEFAULT_OPEN_TIMEOUT, read_timeout: DEFAULT_READ_TIMEOUT }
) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
unless allowed_content_type?(res['content-type'], allowed_content_type_prefixes)
raise UnsupportedContentTypeError, "content-type not allowed: #{res['content-type']}"
end
res.read_body do |chunk|
bytes_written += chunk.bytesize
raise FileTooLargeError, "exceeded #{max_bytes} bytes" if bytes_written > max_bytes
tempfile.write(chunk)
end
end
response
end
def filename_for(uri)
File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
limit_mb.megabytes
end
def parse_and_validate_url!(url)
uri = URI.parse(url)
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
raise InvalidUrlError, 'missing host' if uri.host.blank?
uri
end
def allowed_content_type?(value, prefixes)
mime = value.to_s.split(';').first&.strip&.downcase
return false if mime.blank?
prefixes.any? { |prefix| mime.start_with?(prefix) }
end
end end
end end
+80
View File
@@ -0,0 +1,80 @@
class SafeFetch::Fetcher
def initialize(options)
@options = options
end
def fetch
with_tempfile do |tempfile|
response = stream_response(tempfile)
raise SafeFetch::HttpError, "#{response.code} #{response.message}" unless response.is_a?(Net::HTTPSuccess)
yield SafeFetch::Result.new(
tempfile: duplicate_tempfile(tempfile),
filename: options.filename,
content_type: normalize_content_type(response['content-type'])
)
end
end
private
attr_reader :options
def with_tempfile
tempfile = Tempfile.new('chatwoot-safe-fetch', binmode: true)
yield tempfile
ensure
tempfile&.close!
end
def stream_response(tempfile)
response = nil
bytes_written = 0
SsrfFilter.public_send(options.method, options.url, **options.request_options) do |res|
response = res
next unless res.is_a?(Net::HTTPSuccess)
validate_content_type!(res['content-type'])
bytes_written = write_response_body(res, tempfile, bytes_written)
end
response
end
def validate_content_type!(content_type)
return unless options.validate_content_type?
return if allowed_content_type?(content_type)
raise SafeFetch::UnsupportedContentTypeError, "content-type not allowed: #{content_type}"
end
def write_response_body(response, tempfile, bytes_written)
response.read_body do |chunk|
bytes_written += chunk.bytesize
raise SafeFetch::FileTooLargeError, "exceeded #{options.effective_max_bytes} bytes" if bytes_written > options.effective_max_bytes
tempfile.write(chunk)
end
bytes_written
end
def allowed_content_type?(value)
mime = normalize_content_type(value)
return false if mime.blank?
options.allowed_content_type_prefixes.any? { |prefix| mime.start_with?(prefix) } ||
options.allowed_content_types.include?(mime)
end
def duplicate_tempfile(tempfile)
duplicated = tempfile.dup
duplicated.rewind
duplicated
end
def normalize_content_type(value)
value.to_s.split(';').first&.strip&.downcase
end
end
+88
View File
@@ -0,0 +1,88 @@
class SafeFetch::RequestOptions
DEFAULTS = {
method: :get,
body: nil,
max_bytes: nil,
open_timeout: SafeFetch::DEFAULT_OPEN_TIMEOUT,
read_timeout: SafeFetch::DEFAULT_READ_TIMEOUT,
headers: nil,
http_basic_authentication: nil,
allowed_content_type_prefixes: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPE_PREFIXES,
allowed_content_types: SafeFetch::DEFAULT_ALLOWED_CONTENT_TYPES,
validate_content_type: true
}.freeze
attr_reader :allowed_content_type_prefixes, :allowed_content_types, :body, :headers,
:http_basic_authentication, :method, :open_timeout, :read_timeout, :uri, :url
def initialize(url:, **options)
config = DEFAULTS.merge(options)
@url = url
@uri = parse_and_validate_url!(url)
@method = normalize_method(config[:method])
@body = config[:body]
@max_bytes = config[:max_bytes]
@open_timeout = config[:open_timeout]
@read_timeout = config[:read_timeout]
@headers = config[:headers]
@http_basic_authentication = config[:http_basic_authentication]
@allowed_content_type_prefixes = Array(config[:allowed_content_type_prefixes])
@allowed_content_types = Array(config[:allowed_content_types])
@validate_content_type = config[:validate_content_type]
end
def effective_max_bytes
@effective_max_bytes ||= @max_bytes || default_max_bytes
end
def filename
@filename ||= File.basename(uri.path).presence || "download-#{Time.current.to_i}-#{SecureRandom.hex(4)}"
end
def request_options
{
headers: headers,
body: body,
request_proc: request_proc,
sensitive_headers: sensitive_headers,
http_options: { open_timeout: open_timeout, read_timeout: read_timeout }
}
end
def validate_content_type?
@validate_content_type
end
private
def default_max_bytes
limit_mb = GlobalConfigService.load('MAXIMUM_FILE_UPLOAD_SIZE', SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB).to_i
limit_mb = SafeFetch::DEFAULT_MAX_BYTES_FALLBACK_MB if limit_mb <= 0
limit_mb.megabytes
end
def parse_and_validate_url!(value)
parsed_uri = URI.parse(value)
raise SafeFetch::InvalidUrlError, 'scheme must be http or https' unless parsed_uri.is_a?(URI::HTTP) || parsed_uri.is_a?(URI::HTTPS)
raise SafeFetch::InvalidUrlError, 'missing host' if parsed_uri.host.blank?
parsed_uri
end
def normalize_method(value)
http_method = value.to_s.downcase.to_sym
return http_method if SsrfFilter::VERB_MAP.key?(http_method)
raise SafeFetch::UnsupportedMethodError, "unsupported method: #{value}"
end
def request_proc
return if http_basic_authentication.blank?
proc { |request| request.basic_auth(*http_basic_authentication) }
end
def sensitive_headers
SafeFetch::DEFAULT_SENSITIVE_HEADERS | Array(headers).map { |header, _| header.to_s }
end
end
+43
View File
@@ -0,0 +1,43 @@
require 'ipaddr'
require 'resolv'
require 'ssrf_filter'
require 'uri'
module SafeOutboundUrl
class Error < StandardError; end
class InvalidUrlError < Error; end
class UnsafeUrlError < Error; end
def self.validate!(url, resolver: SsrfFilter::DEFAULT_RESOLVER)
uri = parse_http_url!(url)
ip_addresses = resolve_addresses(uri.hostname, resolver)
raise UnsafeUrlError, "Could not resolve hostname '#{uri.hostname}'" if ip_addresses.empty?
raise UnsafeUrlError, "Hostname '#{uri.hostname}' has no public ip addresses" if ip_addresses.all? { |ip| unsafe_ip_address?(ip) }
uri
rescue URI::InvalidURIError => e
raise InvalidUrlError, e.message
rescue IPAddr::InvalidAddressError, Resolv::ResolvError => e
raise UnsafeUrlError, e.message
end
def self.parse_http_url!(url)
uri = URI.parse(url.to_s)
raise InvalidUrlError, 'scheme must be http or https' unless uri.is_a?(URI::HTTP) || uri.is_a?(URI::HTTPS)
raise InvalidUrlError, 'missing host' if uri.hostname.blank?
uri
end
private_class_method :parse_http_url!
def self.resolve_addresses(hostname, resolver)
Array(resolver.call(hostname)).map { |ip| ip.is_a?(IPAddr) ? ip : IPAddr.new(ip) }
end
private_class_method :resolve_addresses
def self.unsafe_ip_address?(ip_address)
SsrfFilter.send(:unsafe_ip_address?, ip_address)
end
private_class_method :unsafe_ip_address?
end
+28
View File
@@ -1,3 +1,5 @@
require 'ssrf_filter'
class Webhooks::Trigger class Webhooks::Trigger
SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze SUPPORTED_ERROR_HANDLE_EVENTS = %w[message_created message_updated].freeze
@@ -32,6 +34,8 @@ class Webhooks::Trigger
def perform_request def perform_request
body = @payload.to_json body = @payload.to_json
return perform_macro_request(body) if @webhook_type == :macro_webhook
RestClient::Request.execute( RestClient::Request.execute(
method: :post, method: :post,
url: @url, url: @url,
@@ -41,6 +45,19 @@ class Webhooks::Trigger
) )
end end
def perform_macro_request(body)
headers = safe_request_headers(body)
response = SsrfFilter.post(
@url,
body: body,
headers: headers,
sensitive_headers: headers.keys,
http_options: { open_timeout: webhook_timeout, read_timeout: webhook_timeout }
)
raise StandardError, "Webhook request failed with status #{response.code}" unless response.is_a?(Net::HTTPSuccess)
end
def request_headers(body) def request_headers(body)
headers = { content_type: :json, accept: :json } headers = { content_type: :json, accept: :json }
headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present? headers['X-Chatwoot-Delivery'] = @delivery_id if @delivery_id.present?
@@ -52,6 +69,17 @@ class Webhooks::Trigger
headers headers
end end
def safe_request_headers(body)
headers = { 'Content-Type' => 'application/json', 'Accept' => 'application/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) def handle_error(error)
return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event]) return unless SUPPORTED_ERROR_HANDLE_EVENTS.include?(@payload[:event])
return unless message return unless message
@@ -4,9 +4,12 @@ describe Messages::Instagram::MessageBuilder do
subject(:instagram_direct_message_builder) { described_class } subject(:instagram_direct_message_builder) { described_class }
before do before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('www.example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.35'])
stub_request(:post, /graph\.instagram\.com/) stub_request(:post, /graph\.instagram\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/jpeg' })
end end
let!(:account) { create(:account) } let!(:account) { create(:account) }
@@ -120,6 +123,45 @@ describe Messages::Instagram::MessageBuilder do
expect(message.attachments.first.external_url).to eq(story_url) expect(message.attachments.first.external_url).to eq(story_url)
end end
it 'skips story reply attachments for blocked URLs' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
messaging['message']['reply_to']['story']['url'] = 'http://127.0.0.1/blocked.png'
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
expect do
described_class.new(messaging, instagram_inbox).perform
end.not_to raise_error
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.attachments).to be_empty
end
it 'keeps the message when story reply attachment persistence raises a non-fetch error' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
story_url = messaging['message']['reply_to']['story']['url']
builder = described_class.new(messaging, instagram_inbox)
attachment_proxy = instance_double(Attachment)
file_proxy = instance_double(ActiveStorage::Attached::One)
create_instagram_contact_for_sender(messaging['sender']['id'], instagram_inbox)
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
allow(builder).to receive(:build_story_reply_attachment).and_return(attachment_proxy)
allow(attachment_proxy).to receive(:persisted?).and_return(false)
allow(attachment_proxy).to receive(:file).and_return(file_proxy)
allow(file_proxy).to receive(:attach).and_raise(StandardError, 'boom')
expect do
builder.perform
end.not_to raise_error
message = instagram_inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments).to be_empty
end
it 'creates message with reply to mid' do it 'creates message with reply to mid' do
# Create first message to ensure reply to is valid # Create first message to ensure reply to is valid
first_messaging = dm_params[:entry][0]['messaging'][0] first_messaging = dm_params[:entry][0]['messaging'][0]
@@ -4,8 +4,12 @@ describe Messages::Instagram::Messenger::MessageBuilder do
subject(:instagram_message_builder) { described_class } subject(:instagram_message_builder) { described_class }
before do before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('www.example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.35'])
stub_request(:post, /graph\.facebook\.com/) stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/jpeg' })
end end
let!(:account) { create(:account) } let!(:account) { create(:account) }
@@ -132,6 +136,68 @@ describe Messages::Instagram::Messenger::MessageBuilder do
expect(message.attachments.first.external_url).to eq(story_url) expect(message.attachments.first.external_url).to eq(story_url)
end end
it 'skips story reply attachments for blocked URLs' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
messaging['message']['reply_to']['story']['url'] = 'http://127.0.0.1/blocked.png'
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
expect do
described_class.new(messaging, instagram_messenger_inbox).perform
end.not_to raise_error
message = instagram_messenger_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.attachments).to be_empty
end
it 'keeps the message when story reply attachment persistence raises a non-fetch error' do
messaging = instagram_story_reply_event[:entry][0]['messaging'][0]
sender_id = messaging['sender']['id']
story_url = messaging['message']['reply_to']['story']['url']
builder = described_class.new(messaging, instagram_messenger_inbox)
attachment_proxy = instance_double(Attachment)
file_proxy = instance_double(ActiveStorage::Attached::One)
allow(Koala::Facebook::API).to receive(:new).and_return(fb_object)
allow(fb_object).to receive(:get_object).and_return(
{
name: 'Jane',
id: sender_id,
account_id: instagram_messenger_inbox.account_id,
profile_pic: 'https://chatwoot-assets.local/sample.png'
}.with_indifferent_access
)
stub_request(:get, story_url)
.to_return(status: 200, body: 'image_data', headers: { 'Content-Type' => 'image/png' })
allow(builder).to receive(:build_story_reply_attachment).and_return(attachment_proxy)
allow(attachment_proxy).to receive(:persisted?).and_return(false)
allow(attachment_proxy).to receive(:file).and_return(file_proxy)
allow(file_proxy).to receive(:attach).and_raise(StandardError, 'boom')
create_instagram_contact_for_sender(sender_id, instagram_messenger_inbox)
expect do
builder.perform
end.not_to raise_error
message = instagram_messenger_channel.inbox.messages.first
expect(message.content).to eq('This is the story reply')
expect(message.content_attributes[:image_type]).to eq('ig_story_reply')
expect(message.attachments).to be_empty
end
it 'creates message with for reply with mid' do it 'creates message with for reply with mid' do
# create first message to ensure reply to is valid # create first message to ensure reply to is valid
first_message_data = dm_params[:entry][0]['messaging'][0] first_message_data = dm_params[:entry][0]['messaging'][0]
@@ -1,4 +1,5 @@
require 'rails_helper' require 'rails_helper'
require 'stringio'
RSpec.describe Captain::Tools::HttpTool, type: :model do RSpec.describe Captain::Tools::HttpTool, type: :model do
let(:account) { create(:account) } let(:account) { create(:account) }
@@ -7,6 +8,12 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
let(:tool) { described_class.new(assistant, custom_tool) } let(:tool) { described_class.new(assistant, custom_tool) }
let(:tool_context) { Struct.new(:state).new({}) } let(:tool_context) { Struct.new(:state).new({}) }
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('internal.example.com').and_return(['10.0.0.1'])
end
describe '#active?' do describe '#active?' do
it 'returns true when custom tool is enabled' do it 'returns true when custom tool is enabled' do
custom_tool.update!(enabled: true) custom_tool.update!(enabled: true)
@@ -39,6 +46,19 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
expect(result).to eq('{"status": "success"}') expect(result).to eq('{"status": "success"}')
expect(WebMock).to have_requested(:get, 'https://example.com/orders/123') expect(WebMock).to have_requested(:get, 'https://example.com/orders/123')
end end
it 'preserves the previous timeout budget when fetching' do
allow(SafeFetch).to receive(:fetch) do |fetched_url, **options, &block|
expect(fetched_url).to eq('https://example.com/orders/123')
expect(options[:open_timeout]).to eq(10)
expect(options[:read_timeout]).to eq(30)
block.call(OpenStruct.new(tempfile: StringIO.new('{"status": "success"}')))
end
result = tool.perform(tool_context)
expect(result).to eq('{"status": "success"}')
end
end end
context 'with POST request' do context 'with POST request' do
@@ -165,6 +185,15 @@ RSpec.describe Captain::Tools::HttpTool, type: :model do
end end
context 'when handling errors' do context 'when handling errors' do
it 'returns generic error message when hostname resolves to a private IP' do
custom_tool.update!(endpoint_url: 'https://internal.example.com/data')
result = tool.perform(tool_context)
expect(result).to eq('An error occurred while executing the request')
expect(WebMock).not_to have_requested(:any, 'https://internal.example.com/data')
end
it 'returns generic error message on network failure' do it 'returns generic error message on network failure' do
custom_tool.update!(endpoint_url: 'https://example.com/data') custom_tool.update!(endpoint_url: 'https://example.com/data')
stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect')) stub_request(:get, 'https://example.com/data').to_raise(SocketError.new('Failed to connect'))
+70 -29
View File
@@ -1,9 +1,13 @@
require 'rails_helper' require 'rails_helper'
RSpec.describe Avatar::AvatarFromUrlJob do RSpec.describe Avatar::AvatarFromUrlJob do
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
let(:valid_url) { 'https://example.com/avatar.png' } let(:valid_url) { 'https://example.com/avatar.png' }
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
end
it 'enqueues the job' do it 'enqueues the job' do
contact = create(:contact) contact = create(:contact)
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') } expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
@@ -14,7 +18,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
let(:avatarable) { create(:contact) } let(:avatarable) { create(:contact) }
it 'attaches and updates sync attributes' do it 'attaches and updates sync attributes' do
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file) stub_request(:get, valid_url)
.to_return(
status: 200,
body: File.read(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.perform_now(avatarable, valid_url) described_class.perform_now(avatarable, valid_url)
avatarable.reload avatarable.reload
expect(avatarable.avatar).to be_attached expect(avatarable.avatar).to be_attached
@@ -25,7 +35,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
it 'returns early when rate limited' do it 'returns early when rate limited' do
ts = 30.seconds.ago.iso8601 ts = 30.seconds.ago.iso8601
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts }) avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
expect(Down).not_to receive(:download) stub_request(:get, valid_url)
.to_return(
status: 200,
body: File.read(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.perform_now(avatarable, valid_url) described_class.perform_now(avatarable, valid_url)
avatarable.reload avatarable.reload
expect(avatarable.avatar).not_to be_attached expect(avatarable.avatar).not_to be_attached
@@ -33,21 +49,28 @@ RSpec.describe Avatar::AvatarFromUrlJob do
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at'])) expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
.to be > Time.zone.parse(ts) .to be > Time.zone.parse(ts)
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
expect(WebMock).not_to have_requested(:get, valid_url)
end end
it 'returns early when hash unchanged' do it 'returns early when hash unchanged' do
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) }) avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
expect(Down).not_to receive(:download) stub_request(:get, valid_url)
.to_return(
status: 200,
body: File.read(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.perform_now(avatarable, valid_url) described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).not_to be_attached expect(avatarable.avatar).not_to be_attached
avatarable.reload avatarable.reload
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
expect(WebMock).not_to have_requested(:get, valid_url)
end end
it 'updates sync attributes even when URL is invalid' do it 'updates sync attributes even when URL is invalid' do
invalid_url = 'invalid_url' invalid_url = 'invalid_url'
expect(Down).not_to receive(:download)
described_class.perform_now(avatarable, invalid_url) described_class.perform_now(avatarable, invalid_url)
avatarable.reload avatarable.reload
expect(avatarable.avatar).not_to be_attached expect(avatarable.avatar).not_to be_attached
@@ -56,17 +79,12 @@ RSpec.describe Avatar::AvatarFromUrlJob do
end end
it 'updates sync attributes when file download is valid but content type is unsupported' do it 'updates sync attributes when file download is valid but content type is unsupported' do
temp_file = Tempfile.new(['invalid', '.xml']) stub_request(:get, valid_url)
temp_file.write('<invalid>content</invalid>') .to_return(
temp_file.rewind status: 200,
body: '<invalid>content</invalid>',
uploaded = ActionDispatch::Http::UploadedFile.new( headers: { 'Content-Type' => 'application/xml' }
tempfile: temp_file, )
filename: 'invalid.xml',
type: 'application/xml'
)
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(uploaded)
described_class.perform_now(avatarable, valid_url) described_class.perform_now(avatarable, valid_url)
avatarable.reload avatarable.reload
@@ -74,9 +92,19 @@ RSpec.describe Avatar::AvatarFromUrlJob do
expect(avatarable.avatar).not_to be_attached expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url)) expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
end
temp_file.close it 'updates sync attributes when the avatar URL is blocked by SSRF protection' do
temp_file.unlink blocked_url = 'http://127.0.0.1/avatar.png'
expect do
described_class.perform_now(avatarable, blocked_url)
end.not_to raise_error
avatarable.reload
expect(avatarable.avatar).not_to be_attached
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(blocked_url))
end end
end end
@@ -84,7 +112,13 @@ RSpec.describe Avatar::AvatarFromUrlJob do
let(:avatarable) { create(:agent_bot) } let(:avatarable) { create(:agent_bot) }
it 'downloads and attaches avatar' do it 'downloads and attaches avatar' do
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file) stub_request(:get, valid_url)
.to_return(
status: 200,
body: File.read(Rails.root.join('spec/assets/avatar.png')),
headers: { 'Content-Type' => 'image/png' }
)
described_class.perform_now(avatarable, valid_url) described_class.perform_now(avatarable, valid_url)
expect(avatarable.avatar).to be_attached expect(avatarable.avatar).to be_attached
end end
@@ -93,23 +127,30 @@ RSpec.describe Avatar::AvatarFromUrlJob do
# ref: https://github.com/chatwoot/chatwoot/issues/10449 # ref: https://github.com/chatwoot/chatwoot/issues/10449
it 'does not raise error when downloaded file has no filename (invalid content)' do it 'does not raise error when downloaded file has no filename (invalid content)' do
contact = create(:contact) contact = create(:contact)
temp_file = Tempfile.new(['invalid', '.xml']) invalid_file = Tempfile.new('avatar-without-name')
temp_file.write('<invalid>content</invalid>')
temp_file.rewind
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE) allow(SafeFetch).to receive(:fetch)
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml')) .with(
valid_url,
max_bytes: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE,
allowed_content_type_prefixes: [],
allowed_content_types: %w[image/jpeg image/png image/gif]
).and_yield(
SafeFetch::Result.new(
tempfile: invalid_file,
filename: nil,
content_type: 'image/png'
)
)
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
expect(contact.reload.avatar).not_to be_attached
temp_file.close ensure
temp_file.unlink invalid_file.close!
end end
it 'skips sync attribute updates when URL is nil' do it 'skips sync attribute updates when URL is nil' do
contact = create(:contact) contact = create(:contact)
expect(Down).not_to receive(:download)
expect { described_class.perform_now(contact, nil) }.not_to raise_error expect { described_class.perform_now(contact, nil) }.not_to raise_error
contact.reload contact.reload
@@ -4,13 +4,15 @@ describe Webhooks::InstagramEventsJob do
subject(:instagram_webhook) { described_class } subject(:instagram_webhook) { described_class }
before do before do
image_body = File.binread(Rails.root.join('spec/assets/sample.png'))
stub_request(:post, /graph\.facebook\.com/) stub_request(:post, /graph\.facebook\.com/)
stub_request(:get, 'https://www.example.com/test.jpeg') stub_request(:get, 'https://www.example.com/test.jpeg')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/jpeg' })
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test') stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=17949487764033669&signature=test')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/png' })
stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test') stub_request(:get, 'https://lookaside.fbsbx.com/ig_messaging_cdn/?asset_id=18091626484740369&signature=test')
.to_return(status: 200, body: '', headers: {}) .to_return(status: 200, body: image_body, headers: { 'Content-Type' => 'image/png' })
end end
let!(:account) { create(:account) } let!(:account) { create(:account) }
+131 -4
View File
@@ -9,6 +9,8 @@ RSpec.describe SafeFetch do
before do before do
allow(Resolv).to receive(:getaddresses).and_call_original allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34']) allow(Resolv).to receive(:getaddresses).with('example.com').and_return(['93.184.216.34'])
allow(Resolv).to receive(:getaddresses).with('redirect.example.com').and_return(['93.184.216.35'])
allow(Resolv).to receive(:getaddresses).with('cdn.example.com').and_return(['93.184.216.36'])
end end
describe '.fetch' do describe '.fetch' do
@@ -30,13 +32,17 @@ RSpec.describe SafeFetch do
end end
end end
it 'closes the tempfile after the block returns' do it 'keeps the yielded tempfile readable after the block returns' do
captured = nil captured = nil
described_class.fetch(url) { |result| captured = result.tempfile } described_class.fetch(url) { |result| captured = result.tempfile }
expect(captured.closed?).to be true expect(captured.closed?).to be false
captured.rewind
expect(captured.read.bytesize).to be > 0
ensure
captured&.close!
end end
it 'closes the tempfile even when the block raises' do it 'keeps the yielded tempfile readable even when the block raises' do
captured = nil captured = nil
expect do expect do
described_class.fetch(url) do |result| described_class.fetch(url) do |result|
@@ -44,7 +50,11 @@ RSpec.describe SafeFetch do
raise 'boom' raise 'boom'
end end
end.to raise_error('boom') end.to raise_error('boom')
expect(captured.closed?).to be true expect(captured.closed?).to be false
captured.rewind
expect(captured.read.bytesize).to be > 0
ensure
captured&.close!
end end
it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do it 'defaults the filename to a unique "download-<timestamp>-<hex>" when the URL has no path' do
@@ -63,6 +73,38 @@ RSpec.describe SafeFetch do
it 'requires a block' do it 'requires a block' do
expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/) expect { described_class.fetch(url) }.to raise_error(ArgumentError, /block required/)
end end
it 'forwards custom headers to the upstream request' do
described_class.fetch(url, headers: { 'x-user' => 'secret-token' }) { nil }
expect(WebMock).to(have_requested(:get, url)
.with { |request| request.headers['X-User'] == 'secret-token' })
end
it 'supports basic authentication' do
described_class.fetch(url, http_basic_authentication: %w[user password]) { nil }
expect(WebMock).to(have_requested(:get, url)
.with { |request| request.headers['Authorization']&.start_with?('Basic ') })
end
it 'supports POST requests with a request body' do
post_url = 'http://example.com/orders'
stub_request(:post, post_url)
.with(body: '{"order_id":"123"}', headers: { 'Content-Type' => 'application/json' })
.to_return(status: 200, body: '{"created":true}', headers: { 'Content-Type' => 'application/json' })
described_class.fetch(
post_url,
method: :post,
body: '{"order_id":"123"}',
headers: { 'Content-Type' => 'application/json' },
allowed_content_types: ['application/json']
) { nil }
expect(WebMock).to have_requested(:post, post_url)
.with(body: '{"order_id":"123"}', headers: { 'Content-Type' => 'application/json' })
end
end end
context 'with URL validation' do context 'with URL validation' do
@@ -153,6 +195,46 @@ RSpec.describe SafeFetch do
expect { described_class.fetch(url) { nil } }.not_to raise_error expect { described_class.fetch(url) { nil } }.not_to raise_error
end end
it 'allows audio/mpeg responses' do
stub_request(:get, url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/sample.mp3')),
headers: { 'Content-Type' => 'audio/mpeg' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'allows application/pdf responses' do
stub_request(:get, url).to_return(
status: 200,
body: File.new(Rails.root.join('spec/assets/sample.pdf')),
headers: { 'Content-Type' => 'application/pdf' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'allows text/plain responses' do
stub_request(:get, url).to_return(
status: 200,
body: 'plain-text',
headers: { 'Content-Type' => 'text/plain' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'allows text/csv responses' do
stub_request(:get, url).to_return(
status: 200,
body: "id,name\n1,Chatwoot\n",
headers: { 'Content-Type' => 'text/csv' }
)
expect { described_class.fetch(url) { nil } }.not_to raise_error
end
it 'strips charset/boundary parameters before comparing' do it 'strips charset/boundary parameters before comparing' do
stub_request(:get, url).to_return( stub_request(:get, url).to_return(
status: 200, status: 200,
@@ -169,6 +251,51 @@ RSpec.describe SafeFetch do
expect { described_class.fetch(url) { nil } } expect { described_class.fetch(url) { nil } }
.to raise_error(SafeFetch::UnsupportedContentTypeError) .to raise_error(SafeFetch::UnsupportedContentTypeError)
end end
it 'allows exact content-type matches when prefixes are empty' do
pdf_url = 'http://example.com/file.pdf'
stub_request(:get, pdf_url).to_return(
status: 200,
body: 'pdf-data',
headers: { 'Content-Type' => 'application/pdf' }
)
expect do
described_class.fetch(
pdf_url,
allowed_content_type_prefixes: [],
allowed_content_types: ['application/pdf']
) { nil }
end.not_to raise_error
end
it 'skips content-type validation when disabled' do
stub_request(:get, url).to_return(status: 200, body: 'archive-data', headers: {})
expect { described_class.fetch(url, validate_content_type: false) { nil } }.not_to raise_error
end
end
context 'with redirects and sensitive headers' do
it 'strips custom headers on cross-origin redirects' do
redirect_url = 'http://redirect.example.com/image.png'
final_url = 'http://cdn.example.com/image.png'
stub_request(:get, redirect_url)
.with { |request| request.headers['X-User'] == 'secret-token' }
.to_return(status: 302, headers: { 'Location' => final_url })
stub_request(:get, final_url)
.with { |request| request.headers['X-User'].blank? }
.to_return(status: 200, body: 'image-data', headers: { 'Content-Type' => 'image/png' })
expect do
described_class.fetch(redirect_url, headers: { 'x-user' => 'secret-token' }) { nil }
end.not_to raise_error
expect(WebMock).to(have_requested(:get, final_url)
.with { |request| request.headers['X-User'].blank? })
end
end end
context 'with body size cap' do context 'with body size cap' do
+27
View File
@@ -0,0 +1,27 @@
require 'rails_helper'
RSpec.describe SafeOutboundUrl do
describe '.validate!' do
let(:public_resolver) { ->(_hostname) { [IPAddr.new('93.184.216.34')] } }
let(:private_resolver) { ->(_hostname) { [IPAddr.new('127.0.0.1')] } }
it 'accepts public http urls' do
uri = described_class.validate!('https://example.com/webhook', resolver: public_resolver)
expect(uri).to be_a(URI::HTTPS)
expect(uri.host).to eq('example.com')
end
it 'rejects non-http schemes' do
expect do
described_class.validate!('javascript:alert(1)', resolver: public_resolver)
end.to raise_error(described_class::InvalidUrlError, 'scheme must be http or https')
end
it 'rejects hosts that resolve only to private addresses' do
expect do
described_class.validate!('http://internal.example.test/webhook', resolver: private_resolver)
end.to raise_error(described_class::UnsafeUrlError, "Hostname 'internal.example.test' has no public ip addresses")
end
end
end
+19
View File
@@ -43,6 +43,25 @@ describe Webhooks::Trigger do
trigger.execute(url, payload, webhook_type) trigger.execute(url, payload, webhook_type)
end end
context 'when webhook type is macro' do
let(:webhook_type) { :macro_webhook }
it 'uses ssrf-protected delivery for the request' do
payload = { hello: :hello }
response = Net::HTTPOK.new('1.1', '200', 'OK')
expect(SsrfFilter).to receive(:post).with(
url,
body: payload.to_json,
headers: { 'Content-Type' => 'application/json', 'Accept' => 'application/json' },
sensitive_headers: %w[Content-Type Accept],
http_options: { open_timeout: webhook_timeout, read_timeout: webhook_timeout }
).and_return(response)
trigger.execute(url, payload, webhook_type)
end
end
it 'updates message status if webhook fails for message-created event' do it 'updates message status if webhook fails for message-created event' do
payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id } payload = { event: 'message_created', conversation: { id: conversation.id }, id: message.id }
+15
View File
@@ -19,6 +19,21 @@ RSpec.describe Macro do
expect(macro).not_to be_valid expect(macro).not_to be_valid
expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.']) expect(macro.errors.full_messages).to eq(['Actions Macro execution actions update_last_seen not supported.'])
end end
it 'rejects unsafe webhook action urls' do
allow(SafeOutboundUrl).to receive(:validate!).and_raise(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
macro = FactoryBot.build(
:macro,
account: account,
created_by: admin,
updated_by: admin,
actions: [{ action_name: 'send_webhook_event', action_params: ['http://169.254.169.254/latest/meta-data'] }]
)
expect(macro).not_to be_valid
expect(macro.errors.full_messages).to include('Actions Webhook URL for send_webhook_event must be a public http(s) URL.')
end
end end
describe '#set_visibility' do describe '#set_visibility' do
+17 -1
View File
@@ -171,8 +171,24 @@ RSpec.describe Macros::ExecutionService, type: :service do
describe '#send_webhook_event' do describe '#send_webhook_event' do
it 'sends a webhook event' do it 'sends a webhook event' do
expect(WebhookJob).to receive(:perform_later) allow(SafeOutboundUrl).to receive(:validate!).and_return(URI.parse('https://example.com/webhook'))
expect(WebhookJob).to receive(:perform_later).with(
'https://example.com/webhook',
hash_including(event: 'macro.executed'),
:macro_webhook
)
service.send(:send_webhook_event, ['https://example.com/webhook']) service.send(:send_webhook_event, ['https://example.com/webhook'])
end end
it 'raises when the webhook url is unsafe' do
allow(SafeOutboundUrl).to receive(:validate!).and_raise(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
expect(WebhookJob).not_to receive(:perform_later)
expect do
service.send(:send_webhook_event, ['http://169.254.169.254/latest/meta-data'])
end.to raise_error(SafeOutboundUrl::UnsafeUrlError, 'unsafe')
end
end end
end end
@@ -2,6 +2,11 @@ require 'rails_helper'
describe Sms::IncomingMessageService do describe Sms::IncomingMessageService do
describe '#perform' do describe '#perform' do
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('test.com').and_return(['93.184.216.34'])
end
let!(:sms_channel) { create(:channel_sms) } let!(:sms_channel) { create(:channel_sms) }
let(:params) do let(:params) do
{ {
@@ -77,8 +82,10 @@ describe Sms::IncomingMessageService do
end end
it 'creates attachment messages and ignores .smil files' do it 'creates attachment messages and ignores .smil files' do
stub_request(:get, 'http://test.com/test.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {}) stub_request(:get, 'http://test.com/test.png')
stub_request(:get, 'http://test.com/test2.png').to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: {}) .to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
stub_request(:get, 'http://test.com/test2.png')
.to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
media_params = { 'media': [ media_params = { 'media': [
'http://test.com/test.smil', 'http://test.com/test.smil',
@@ -92,6 +99,29 @@ describe Sms::IncomingMessageService do
expect(sms_channel.inbox.messages.first.content).to eq('test message') expect(sms_channel.inbox.messages.first.content).to eq('test message')
expect(sms_channel.inbox.messages.first.attachments.present?).to be true expect(sms_channel.inbox.messages.first.attachments.present?).to be true
end end
it 'creates audio attachment messages' do
stub_request(:get, 'http://test.com/test.mp3')
.to_return(status: 200, body: File.read('spec/assets/sample.mp3'), headers: { 'Content-Type' => 'audio/mpeg' })
media_params = { 'media': ['http://test.com/test.mp3'] }.with_indifferent_access
described_class.new(inbox: sms_channel.inbox, params: params.merge(media_params)).perform
attachment = sms_channel.inbox.messages.first.attachments.first
expect(attachment.file_type).to eq('audio')
end
it 'skips blocked attachment URLs' do
media_params = { 'media': ['http://127.0.0.1/blocked.png'] }.with_indifferent_access
expect do
described_class.new(inbox: sms_channel.inbox, params: params.merge(media_params)).perform
end.not_to raise_error
expect(sms_channel.inbox.messages.first.content).to eq('test message')
expect(sms_channel.inbox.messages.first.attachments).to be_empty
end
end end
end end
end end
@@ -2,31 +2,33 @@ require 'rails_helper'
describe Telegram::IncomingMessageService do describe Telegram::IncomingMessageService do
before do before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
stub_request(:any, /api.telegram.org/).to_return(headers: { content_type: 'application/json' }, body: {}.to_json, status: 200) stub_request(:any, /api.telegram.org/).to_return(headers: { content_type: 'application/json' }, body: {}.to_json, status: 200)
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.png'), body: File.read('spec/assets/sample.png'),
headers: {} headers: { 'Content-Type' => 'image/png' }
) )
stub_request(:get, 'https://chatwoot-assets.local/sample.mov').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.mov').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.mov'), body: File.read('spec/assets/sample.mov'),
headers: {} headers: { 'Content-Type' => 'video/quicktime' }
) )
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.mp3'), body: File.read('spec/assets/sample.mp3'),
headers: {} headers: { 'Content-Type' => 'audio/mpeg' }
) )
stub_request(:get, 'https://chatwoot-assets.local/sample.ogg').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.ogg').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.ogg'), body: File.read('spec/assets/sample.ogg'),
headers: {} headers: { 'Content-Type' => 'audio/ogg' }
) )
stub_request(:get, 'https://chatwoot-assets.local/sample.pdf').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.pdf').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.pdf'), body: File.read('spec/assets/sample.pdf'),
headers: {} headers: { 'Content-Type' => 'application/pdf' }
) )
end end
@@ -323,6 +325,29 @@ describe Telegram::IncomingMessageService do
end end
end end
context 'when the download path is an SSRF target' do
it 'skips the attachment' do
allow(telegram_channel.inbox.channel).to receive(:get_telegram_file_path).and_return('http://127.0.0.1/blocked.pdf')
params = {
'update_id' => 2_342_342_343_242,
'message' => {
'document' => {
'file_id' => 'AwADBAADbXXXXXXXXXXXGBdhD2l6_XX',
'file_name' => 'blocked.pdf',
'mime_type' => 'application/pdf',
'file_size' => 536_392
}
}.merge(message_params)
}.with_indifferent_access
expect do
described_class.new(inbox: telegram_channel.inbox, params: params).perform
end.not_to raise_error
expect(telegram_channel.inbox.messages.first.attachments).to be_empty
end
end
context 'when valid location message params' do context 'when valid location message params' do
it 'creates appropriate conversations, message and contacts' do it 'creates appropriate conversations, message and contacts' do
params = { params = {
+40 -8
View File
@@ -6,6 +6,7 @@ RSpec.describe Tiktok::MessageService do
let(:inbox) { channel.inbox } let(:inbox) { channel.inbox }
let(:contact) { create(:contact, account: account) } let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') } let(:contact_inbox) { create(:contact_inbox, inbox: inbox, contact: contact, source_id: 'tt-conv-1') }
let(:image_download_url) { 'https://chatwoot-assets.local/tiktok.png' }
let(:text_content) do let(:text_content) do
{ {
type: 'text', type: 'text',
@@ -20,6 +21,12 @@ RSpec.describe Tiktok::MessageService do
}.deep_symbolize_keys }.deep_symbolize_keys
end end
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
allow(channel).to receive(:validated_access_token).and_return('valid-access-token')
end
describe '#perform' do describe '#perform' do
subject(:perform_text_message) do subject(:perform_text_message) do
service = described_class.new(channel: channel, content: current_content) service = described_class.new(channel: channel, content: current_content)
@@ -115,15 +122,15 @@ RSpec.describe Tiktok::MessageService do
to_user: { id: 'biz-123' } to_user: { id: 'biz-123' }
}.deep_symbolize_keys }.deep_symbolize_keys
tempfile = Tempfile.new(['tiktok', '.png']) stub_request(:get, image_download_url)
tempfile.write('fake-image') .with(headers: { 'X-User' => 'valid-access-token' })
tempfile.rewind .to_return(status: 200, body: File.read('spec/assets/sample.png'), headers: { 'Content-Type' => 'image/png' })
tempfile.define_singleton_method(:original_filename) { 'tiktok.png' }
tempfile.define_singleton_method(:content_type) { 'image/png' }
service = described_class.new(channel: channel, content: content) service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox) allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
allow(service).to receive(:fetch_attachment).and_return(tempfile) allow(service).to receive(:tiktok_client)
.with(channel)
.and_return(instance_double(Tiktok::Client, file_download_url: image_download_url))
service.perform service.perform
@@ -131,8 +138,33 @@ RSpec.describe Tiktok::MessageService do
expect(message.attachments.count).to eq(1) expect(message.attachments.count).to eq(1)
expect(message.attachments.last.file_type).to eq('image') expect(message.attachments.last.file_type).to eq('image')
expect(message.attachments.last.file).to be_attached expect(message.attachments.last.file).to be_attached
ensure expect(WebMock).to(have_requested(:get, image_download_url)
tempfile.close! .with { |request| request.headers['X-User'] == 'valid-access-token' })
end
it 'skips blocked image attachment URLs' do
content = {
type: 'image',
message_id: 'tt-msg-5',
timestamp: 1_700_000_000_000,
conversation_id: 'tt-conv-1',
image: { media_id: 'media-1' },
from: 'Alice',
from_user: { id: 'user-1' },
to: 'Biz',
to_user: { id: 'biz-123' }
}.deep_symbolize_keys
service = described_class.new(channel: channel, content: content)
allow(service).to receive(:create_contact_inbox).and_return(contact_inbox)
allow(service).to receive(:tiktok_client)
.with(channel)
.and_return(instance_double(Tiktok::Client, file_download_url: 'http://127.0.0.1/blocked.png'))
expect { service.perform }.not_to raise_error
message = Message.last
expect(message.attachments).to be_empty
end end
context 'when lock_to_single_conversation is enabled' do context 'when lock_to_single_conversation is enabled' do
@@ -10,6 +10,11 @@ describe Twilio::IncomingMessageService do
let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) } let(:contact_inbox) { create(:contact_inbox, source_id: '+12345', contact: contact, inbox: twilio_channel.inbox) }
let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) } let!(:conversation) { create(:conversation, contact: contact, inbox: twilio_channel.inbox, contact_inbox: contact_inbox) }
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
end
describe '#perform' do describe '#perform' do
it 'creates a new message in existing conversation' do it 'creates a new message in existing conversation' do
params = { params = {
@@ -174,6 +179,8 @@ describe Twilio::IncomingMessageService do
before do before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png') stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' }) .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3')
.to_return(status: 200, body: File.read('spec/assets/sample.mp3'), headers: { 'Content-Type' => 'audio/mpeg' })
end end
let(:params_with_attachment) do let(:params_with_attachment) do
@@ -195,14 +202,35 @@ describe Twilio::IncomingMessageService do
expect(conversation.reload.messages.last.attachments.count).to eq(1) expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image') expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
end end
it 'downloads the attachment using basic auth' do
described_class.new(params: params_with_attachment).perform
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
.with { |request| request.headers['Authorization']&.start_with?('Basic ') })
end
it 'creates an audio attachment when the provider serves audio media' do
params_with_audio_attachment = params_with_attachment.merge(
MediaContentType0: 'audio/mpeg',
MediaUrl0: 'https://chatwoot-assets.local/sample.mp3'
)
described_class.new(params: params_with_audio_attachment).perform
attachment = conversation.reload.messages.last.attachments.first
expect(attachment.file_type).to eq('audio')
end
end end
context 'when there is an error downloading the attachment' do context 'when there is an error downloading the attachment' do
before do before do
stub_request(:get, 'https://chatwoot-assets.local/sample.png') stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.to_raise(Down::Error.new('Download error')) .with { |request| request.headers['Authorization']&.start_with?('Basic ') }
.to_raise(SocketError.new('Download error'))
stub_request(:get, 'https://chatwoot-assets.local/sample.png') stub_request(:get, 'https://chatwoot-assets.local/sample.png')
.with { |request| request.headers['Authorization'].blank? }
.to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' }) .to_return(status: 200, body: 'image data', headers: { 'Content-Type' => 'image/png' })
end end
@@ -227,6 +255,8 @@ describe Twilio::IncomingMessageService do
expect(conversation.reload.messages.last.content).to eq('testing3') expect(conversation.reload.messages.last.content).to eq('testing3')
expect(conversation.reload.messages.last.attachments.count).to eq(1) expect(conversation.reload.messages.last.attachments.count).to eq(1)
expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image') expect(conversation.reload.messages.last.attachments.first.file_type).to eq('image')
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
.with { |request| request.headers['Authorization'].blank? })
end end
end end
@@ -283,6 +313,31 @@ describe Twilio::IncomingMessageService do
end end
end end
context 'when an attachment URL is an SSRF target' do
let(:params_with_blocked_attachment) do
{
SmsSid: 'SMxx',
From: '+12345',
AccountSid: 'ACxxx',
MessagingServiceSid: twilio_channel.messaging_service_sid,
Body: 'blocked media',
NumMedia: '1',
MediaContentType0: 'image/jpeg',
MediaUrl0: 'http://127.0.0.1/blocked.png'
}
end
it 'creates the message and skips the attachment' do
expect do
described_class.new(params: params_with_blocked_attachment).perform
end.not_to raise_error
message = conversation.reload.messages.last
expect(message.content).to eq('blocked media')
expect(message.attachments.count).to eq(0)
end
end
context 'when ProfileName is provided for WhatsApp' do context 'when ProfileName is provided for WhatsApp' do
it 'uses ProfileName as contact name' do it 'uses ProfileName as contact name' do
params = { params = {
@@ -3,6 +3,8 @@ require 'rails_helper'
describe Whatsapp::IncomingMessageService do describe Whatsapp::IncomingMessageService do
describe '#perform' do describe '#perform' do
before do before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('waba.360dialog.io').and_return(['93.184.216.34'])
stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook') stub_request(:post, 'https://waba.360dialog.io/v1/configs/webhook')
end end
@@ -214,7 +216,7 @@ describe Whatsapp::IncomingMessageService do
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return( stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.png'), body: File.read('spec/assets/sample.png'),
headers: {} headers: { 'Content-Type' => 'image/png' }
) )
params = { params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }], 'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
@@ -231,6 +233,47 @@ describe Whatsapp::IncomingMessageService do
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!') expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true expect(whatsapp_channel.inbox.messages.first.attachments.present?).to be true
end end
it 'creates audio attachments when the provider serves audio media' do
stub_request(:get, whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')).to_return(
status: 200,
body: File.read('spec/assets/sample.mp3'),
headers: { 'Content-Type' => 'audio/mpeg' }
)
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
'audio' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
'mime_type' => 'audio/mpeg',
'sha256' => '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db' },
'timestamp' => '1633034394', 'type' => 'audio' }]
}.with_indifferent_access
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
attachment = whatsapp_channel.inbox.messages.first.attachments.first
expect(attachment.file_type).to eq('audio')
end
it 'skips blocked attachment URLs' do
allow(whatsapp_channel).to receive(:media_url).and_return('http://127.0.0.1/blocked.png')
params = {
'contacts' => [{ 'profile' => { 'name' => 'Sojan Jose' }, 'wa_id' => '2423423243' }],
'messages' => [{ 'from' => '2423423243', 'id' => 'SDFADSf23sfasdafasdfa',
'image' => { 'id' => 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
'mime_type' => 'image/jpeg',
'sha256' => '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db',
'caption' => 'Check out my product!' },
'timestamp' => '1633034394', 'type' => 'image' }]
}.with_indifferent_access
expect do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end.not_to raise_error
expect(whatsapp_channel.inbox.messages.first.content).to eq('Check out my product!')
expect(whatsapp_channel.inbox.messages.first.attachments).to be_empty
end
end end
context 'when valid location message params' do context 'when valid location message params' do
@@ -2,6 +2,11 @@ require 'rails_helper'
describe Whatsapp::IncomingMessageWhatsappCloudService do describe Whatsapp::IncomingMessageWhatsappCloudService do
describe '#perform' do describe '#perform' do
before do
allow(Resolv).to receive(:getaddresses).and_call_original
allow(Resolv).to receive(:getaddresses).with('chatwoot-assets.local').and_return(['93.184.216.34'])
end
after do after do
Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') { |key| Redis::Alfred.delete(key) } Redis::Alfred.scan_each(match: 'MESSAGE_SOURCE_KEY::*') { |key| Redis::Alfred.delete(key) }
end end
@@ -42,6 +47,52 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
expect_message_has_attachment expect_message_has_attachment
end end
it 'creates audio attachments when the provider serves audio media' do
params[:entry][0][:changes][0][:value][:messages][0] = {
from: '2423423243',
audio: {
id: 'b1c68f38-8734-4ad3-b4a1-ef0c10d683',
mime_type: 'audio/mpeg',
sha256: '29ed500fa64eb55fc19dc4124acb300e5dcca0f822a301ae99944db'
},
timestamp: '1664799904',
type: 'audio'
}
stub_media_url_request(url: 'https://chatwoot-assets.local/sample.mp3')
stub_request(:get, 'https://chatwoot-assets.local/sample.mp3').to_return(
status: 200,
body: File.read('spec/assets/sample.mp3'),
headers: { 'Content-Type' => 'audio/mpeg' }
)
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
attachment = whatsapp_channel.inbox.messages.first.attachments.first
expect(attachment.file_type).to eq('audio')
end
it 'sends the provider headers when downloading the resolved media URL' do
stub_media_url_request
stub_sample_png_request
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
expect(WebMock).to(have_requested(:get, 'https://chatwoot-assets.local/sample.png')
.with { |request| request.headers['Authorization']&.start_with?('Bearer ') })
end
it 'skips blocked resolved media URLs' do
stub_media_url_request(url: 'http://127.0.0.1/blocked.png')
expect do
described_class.new(inbox: whatsapp_channel.inbox, params: params).perform
end.not_to raise_error
expect_conversation_created
expect_message_content
expect(whatsapp_channel.inbox.messages.first.attachments).to be_empty
end
it 'increments reauthorization count if fetching attachment fails' do it 'increments reauthorization count if fetching attachment fails' do
stub_request( stub_request(
:get, :get,
@@ -169,7 +220,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
# Métodos auxiliares para reduzir o tamanho do exemplo # Métodos auxiliares para reduzir o tamanho do exemplo
def stub_media_url_request def stub_media_url_request(url: 'https://chatwoot-assets.local/sample.png')
stub_request( stub_request(
:get, :get,
whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683') whatsapp_channel.media_url('b1c68f38-8734-4ad3-b4a1-ef0c10d683')
@@ -177,7 +228,7 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
status: 200, status: 200,
body: { body: {
messaging_product: 'whatsapp', messaging_product: 'whatsapp',
url: 'https://chatwoot-assets.local/sample.png', url: url,
mime_type: 'image/jpeg', mime_type: 'image/jpeg',
sha256: 'sha256', sha256: 'sha256',
file_size: 'SIZE', file_size: 'SIZE',
@@ -190,7 +241,8 @@ describe Whatsapp::IncomingMessageWhatsappCloudService do
def stub_sample_png_request def stub_sample_png_request
stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return( stub_request(:get, 'https://chatwoot-assets.local/sample.png').to_return(
status: 200, status: 200,
body: File.read('spec/assets/sample.png') body: File.read('spec/assets/sample.png'),
headers: { 'Content-Type' => 'image/png' }
) )
end end