mirror of
https://github.com/lingble/chatwoot.git
synced 2025-11-01 03:27:52 +00:00
fix: Add URL validation and rate limiting for contact avatar sync (#11979)
- Implement 1-minute rate limiting for contacts to prevent bombardment - Add URL hash comparison to sync only when avatar URL changes
This commit is contained in:
@@ -1,4 +1,5 @@
|
|||||||
module PortalHelper
|
module PortalHelper
|
||||||
|
include UrlHelper
|
||||||
def set_og_image_url(portal_name, title)
|
def set_og_image_url(portal_name, title)
|
||||||
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
|
cdn_url = GlobalConfig.get('OG_IMAGE_CDN_URL')['OG_IMAGE_CDN_URL']
|
||||||
return if cdn_url.blank?
|
return if cdn_url.blank?
|
||||||
@@ -79,7 +80,7 @@ module PortalHelper
|
|||||||
query_params = Rack::Utils.parse_query(url.query)
|
query_params = Rack::Utils.parse_query(url.query)
|
||||||
query_params['utm_medium'] = 'helpcenter'
|
query_params['utm_medium'] = 'helpcenter'
|
||||||
query_params['utm_campaign'] = 'branding'
|
query_params['utm_campaign'] = 'branding'
|
||||||
query_params['utm_source'] = URI.parse(referer).host if referer.present? && referer.match?(URI::DEFAULT_PARSER.make_regexp)
|
query_params['utm_source'] = URI.parse(referer).host if url_valid?(referer)
|
||||||
|
|
||||||
url.query = query_params.to_query
|
url.query = query_params.to_query
|
||||||
url.to_s
|
url.to_s
|
||||||
|
|||||||
@@ -1,27 +1,83 @@
|
|||||||
|
# Downloads and attaches avatar images from a URL.
|
||||||
|
# Notes:
|
||||||
|
# - For contact objects, we use `additional_attributes` to rate limit the
|
||||||
|
# job and track state.
|
||||||
|
# - We save the hash of the synced URL to retrigger downloads only when
|
||||||
|
# there is a change in the underlying asset.
|
||||||
|
# - A 1 minute rate limit window is enforced via `last_avatar_sync_at`.
|
||||||
class Avatar::AvatarFromUrlJob < ApplicationJob
|
class Avatar::AvatarFromUrlJob < ApplicationJob
|
||||||
|
include UrlHelper
|
||||||
queue_as :purgable
|
queue_as :purgable
|
||||||
|
|
||||||
|
MAX_DOWNLOAD_SIZE = 15 * 1024 * 1024
|
||||||
|
RATE_LIMIT_WINDOW = 1.minute
|
||||||
|
|
||||||
def perform(avatarable, avatar_url)
|
def perform(avatarable, avatar_url)
|
||||||
return unless avatarable.respond_to?(:avatar)
|
return unless avatarable.respond_to?(:avatar)
|
||||||
|
return unless url_valid?(avatar_url)
|
||||||
|
|
||||||
avatar_file = Down.download(
|
return unless should_sync_avatar?(avatarable, avatar_url)
|
||||||
avatar_url,
|
|
||||||
max_size: 15 * 1024 * 1024
|
avatar_file = Down.download(avatar_url, max_size: MAX_DOWNLOAD_SIZE)
|
||||||
|
raise Down::Error, 'Invalid file' unless valid_file?(avatar_file)
|
||||||
|
|
||||||
|
avatarable.avatar.attach(
|
||||||
|
io: avatar_file,
|
||||||
|
filename: avatar_file.original_filename,
|
||||||
|
content_type: avatar_file.content_type
|
||||||
)
|
)
|
||||||
if valid_image?(avatar_file)
|
|
||||||
avatarable.avatar.attach(io: avatar_file, filename: avatar_file.original_filename,
|
|
||||||
content_type: avatar_file.content_type)
|
|
||||||
end
|
|
||||||
rescue Down::NotFound, Down::Error => e
|
rescue Down::NotFound, Down::Error => e
|
||||||
Rails.logger.error "Exception: invalid avatar url #{avatar_url} : #{e.message}"
|
Rails.logger.error "AvatarFromUrlJob error for #{avatar_url}: #{e.class} - #{e.message}"
|
||||||
|
ensure
|
||||||
|
update_avatar_sync_attributes(avatarable, avatar_url)
|
||||||
end
|
end
|
||||||
|
|
||||||
private
|
private
|
||||||
|
|
||||||
def valid_image?(file)
|
def should_sync_avatar?(avatarable, avatar_url)
|
||||||
return false if file.original_filename.blank?
|
# Only Contacts are rate-limited and hash-gated.
|
||||||
|
return true unless avatarable.is_a?(Contact)
|
||||||
|
|
||||||
# TODO: check if the file is an actual image
|
attrs = avatarable.additional_attributes || {}
|
||||||
|
|
||||||
|
return false if within_rate_limit?(attrs)
|
||||||
|
return false if duplicate_url?(attrs, avatar_url)
|
||||||
|
|
||||||
|
true
|
||||||
|
end
|
||||||
|
|
||||||
|
def within_rate_limit?(attrs)
|
||||||
|
ts = attrs['last_avatar_sync_at']
|
||||||
|
return false if ts.blank?
|
||||||
|
|
||||||
|
Time.zone.parse(ts) > RATE_LIMIT_WINDOW.ago
|
||||||
|
end
|
||||||
|
|
||||||
|
def duplicate_url?(attrs, avatar_url)
|
||||||
|
stored_hash = attrs['avatar_url_hash']
|
||||||
|
stored_hash.present? && stored_hash == generate_url_hash(avatar_url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def generate_url_hash(url)
|
||||||
|
Digest::SHA256.hexdigest(url)
|
||||||
|
end
|
||||||
|
|
||||||
|
def update_avatar_sync_attributes(avatarable, avatar_url)
|
||||||
|
# Only Contacts have sync attributes persisted
|
||||||
|
return unless avatarable.is_a?(Contact)
|
||||||
|
return if avatar_url.blank?
|
||||||
|
|
||||||
|
additional_attributes = avatarable.additional_attributes || {}
|
||||||
|
additional_attributes['last_avatar_sync_at'] = Time.current.iso8601
|
||||||
|
additional_attributes['avatar_url_hash'] = generate_url_hash(avatar_url)
|
||||||
|
|
||||||
|
# Persist without triggering validations that may fail due to avatar file checks
|
||||||
|
avatarable.update_columns(additional_attributes: additional_attributes) # rubocop:disable Rails/SkipsModelValidations
|
||||||
|
end
|
||||||
|
|
||||||
|
def valid_file?(file)
|
||||||
|
return false if file.original_filename.blank?
|
||||||
|
|
||||||
true
|
true
|
||||||
end
|
end
|
||||||
|
|||||||
@@ -1,36 +1,119 @@
|
|||||||
require 'rails_helper'
|
require 'rails_helper'
|
||||||
|
|
||||||
RSpec.describe Avatar::AvatarFromUrlJob do
|
RSpec.describe Avatar::AvatarFromUrlJob do
|
||||||
let(:avatarable) { create(:contact) }
|
let(:file) { fixture_file_upload(Rails.root.join('spec/assets/avatar.png'), 'image/png') }
|
||||||
let(:avatar_url) { 'https://example.com/avatar.png' }
|
let(:valid_url) { 'https://example.com/avatar.png' }
|
||||||
|
|
||||||
it 'enqueues the job' do
|
it 'enqueues the job' do
|
||||||
expect { described_class.perform_later(avatarable, avatar_url) }.to have_enqueued_job(described_class)
|
contact = create(:contact)
|
||||||
.on_queue('purgable')
|
expect { described_class.perform_later(contact, 'https://example.com/avatar.png') }
|
||||||
|
.to have_enqueued_job(described_class).on_queue('purgable')
|
||||||
end
|
end
|
||||||
|
|
||||||
it 'will attach avatar from url' do
|
context 'with rate-limited avatarable (Contact)' do
|
||||||
expect(avatarable.avatar).not_to be_attached
|
let(:avatarable) { create(:contact) }
|
||||||
expect(Down).to receive(:download).with(avatar_url,
|
|
||||||
max_size: 15 * 1024 * 1024).and_return(fixture_file_upload(Rails.root.join('spec/assets/avatar.png'),
|
it 'attaches and updates sync attributes' do
|
||||||
'image/png'))
|
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
|
||||||
described_class.perform_now(avatarable, avatar_url)
|
described_class.perform_now(avatarable, valid_url)
|
||||||
|
avatarable.reload
|
||||||
expect(avatarable.avatar).to be_attached
|
expect(avatarable.avatar).to be_attached
|
||||||
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
||||||
|
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
||||||
end
|
end
|
||||||
|
|
||||||
# ref: https://github.com/chatwoot/chatwoot/issues/10449
|
it 'returns early when rate limited' do
|
||||||
it 'will not throw error if the avatar url is not valid and the file does not have a filename' do
|
ts = 30.seconds.ago.iso8601
|
||||||
# Create a temporary file with no filename and content type application/xml
|
avatarable.update(additional_attributes: { 'last_avatar_sync_at' => ts })
|
||||||
|
expect(Down).not_to receive(:download)
|
||||||
|
described_class.perform_now(avatarable, valid_url)
|
||||||
|
avatarable.reload
|
||||||
|
expect(avatarable.avatar).not_to be_attached
|
||||||
|
expect(avatarable.additional_attributes['last_avatar_sync_at']).to be_present
|
||||||
|
expect(Time.zone.parse(avatarable.additional_attributes['last_avatar_sync_at']))
|
||||||
|
.to be > Time.zone.parse(ts)
|
||||||
|
expect(avatarable.additional_attributes['avatar_url_hash']).to eq(Digest::SHA256.hexdigest(valid_url))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'returns early when hash unchanged' do
|
||||||
|
avatarable.update(additional_attributes: { 'avatar_url_hash' => Digest::SHA256.hexdigest(valid_url) })
|
||||||
|
expect(Down).not_to receive(:download)
|
||||||
|
described_class.perform_now(avatarable, valid_url)
|
||||||
|
expect(avatarable.avatar).not_to be_attached
|
||||||
|
avatarable.reload
|
||||||
|
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))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates sync attributes even when URL is invalid' do
|
||||||
|
invalid_url = 'invalid_url'
|
||||||
|
expect(Down).not_to receive(:download)
|
||||||
|
described_class.perform_now(avatarable, invalid_url)
|
||||||
|
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(invalid_url))
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'updates sync attributes when file download is valid but content type is unsupported' do
|
||||||
temp_file = Tempfile.new(['invalid', '.xml'])
|
temp_file = Tempfile.new(['invalid', '.xml'])
|
||||||
temp_file.write('<invalid>content</invalid>')
|
temp_file.write('<invalid>content</invalid>')
|
||||||
temp_file.rewind
|
temp_file.rewind
|
||||||
|
|
||||||
expect(Down).to receive(:download).with(avatar_url, max_size: 15 * 1024 * 1024)
|
uploaded = ActionDispatch::Http::UploadedFile.new(
|
||||||
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
|
tempfile: temp_file,
|
||||||
|
filename: 'invalid.xml',
|
||||||
|
type: 'application/xml'
|
||||||
|
)
|
||||||
|
|
||||||
expect { described_class.perform_now(avatarable, avatar_url) }.not_to raise_error
|
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)
|
||||||
|
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(valid_url))
|
||||||
|
|
||||||
temp_file.close
|
temp_file.close
|
||||||
temp_file.unlink # deletes the temp file
|
temp_file.unlink
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
context 'with regular avatarable' do
|
||||||
|
let(:avatarable) { create(:agent_bot) }
|
||||||
|
|
||||||
|
it 'downloads and attaches avatar' do
|
||||||
|
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE).and_return(file)
|
||||||
|
described_class.perform_now(avatarable, valid_url)
|
||||||
|
expect(avatarable.avatar).to be_attached
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
|
# ref: https://github.com/chatwoot/chatwoot/issues/10449
|
||||||
|
it 'does not raise error when downloaded file has no filename (invalid content)' do
|
||||||
|
contact = create(:contact)
|
||||||
|
temp_file = Tempfile.new(['invalid', '.xml'])
|
||||||
|
temp_file.write('<invalid>content</invalid>')
|
||||||
|
temp_file.rewind
|
||||||
|
|
||||||
|
expect(Down).to receive(:download).with(valid_url, max_size: Avatar::AvatarFromUrlJob::MAX_DOWNLOAD_SIZE)
|
||||||
|
.and_return(ActionDispatch::Http::UploadedFile.new(tempfile: temp_file, type: 'application/xml'))
|
||||||
|
|
||||||
|
expect { described_class.perform_now(contact, valid_url) }.not_to raise_error
|
||||||
|
|
||||||
|
temp_file.close
|
||||||
|
temp_file.unlink
|
||||||
|
end
|
||||||
|
|
||||||
|
it 'skips sync attribute updates when URL is nil' do
|
||||||
|
contact = create(:contact)
|
||||||
|
expect(Down).not_to receive(:download)
|
||||||
|
|
||||||
|
expect { described_class.perform_now(contact, nil) }.not_to raise_error
|
||||||
|
|
||||||
|
contact.reload
|
||||||
|
expect(contact.additional_attributes['last_avatar_sync_at']).to be_nil
|
||||||
|
expect(contact.additional_attributes['avatar_url_hash']).to be_nil
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|||||||
Reference in New Issue
Block a user