Compare commits

...
3 changed files with 183 additions and 5 deletions
+70
View File
@@ -6,6 +6,7 @@ class WidgetsController < ActionController::Base
before_action :set_web_widget
before_action :ensure_account_is_active
before_action :ensure_location_is_supported
before_action :validate_origin_domain
before_action :set_token
before_action :set_contact
before_action :build_contact
@@ -24,6 +25,75 @@ class WidgetsController < ActionController::Base
render json: { error: 'web widget does not exist' }, status: :not_found
end
def validate_origin_domain
# Only validate origin if website_url is configured
return if @web_widget.website_url.blank?
origin_domain = extract_domain_from_origin
return if domain_allowed?(origin_domain, @web_widget.website_url)
Rails.logger.warn('Widget access denied.')
render json: { error: 'Widget access denied.' }, status: :forbidden
end
def domain_allowed?(origin_domain, website_url)
return false if origin_domain.blank?
# Split website_url by comma to handle multiple domains/wildcards
allowed_domains = website_url.split(',').map(&:strip).reject(&:blank?)
allowed_domains.any? do |allowed_domain|
# Extract domain and protocol from the allowed domain
allowed_domain_without_protocol = extract_domain_from_url(allowed_domain)
allowed_protocol = extract_protocol_from_url(allowed_domain)
origin_protocol = extract_protocol_from_origin
# Check if the extracted domain starts with wildcard
if allowed_domain_without_protocol&.start_with?('*.')
# Handle wildcard domains (e.g., "*.example.com")
wildcard_suffix = allowed_domain_without_protocol[2..-1] # Remove "*.", keep "example.com"
origin_domain.end_with?(wildcard_suffix) && origin_protocol == allowed_protocol
else
# Handle exact domain and protocol match
origin_domain == allowed_domain_without_protocol && origin_protocol == allowed_protocol
end
end
end
def extract_domain_from_origin
origin = request.headers['Origin']
return nil if origin.blank?
extract_domain_from_url(origin)
end
def extract_protocol_from_origin
origin = request.headers['Origin']
return nil if origin.blank?
extract_protocol_from_url(origin)
end
def extract_domain_from_url(url)
return nil if url.blank?
# Parse the URL and extract the domain
uri = URI.parse(url)
uri.host&.downcase
rescue URI::InvalidURIError
nil
end
def extract_protocol_from_url(url)
return nil if url.blank?
# Parse the URL and extract the protocol
uri = URI.parse(url)
uri.scheme&.downcase
rescue URI::InvalidURIError
nil
end
def set_token
@token = permitted_params[:cw_conversation]
@auth_token_params = if @token.present?
+1
View File
@@ -2,6 +2,7 @@ module WidgetHelper
def build_contact_inbox_with_token(web_widget, additional_attributes = {})
contact_inbox = web_widget.create_contact_inbox(additional_attributes)
payload = { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id }
token = ::Widget::TokenService.new(payload: payload).generate_token
[contact_inbox, token]
+112 -5
View File
@@ -2,7 +2,7 @@ require 'rails_helper'
describe '/widget', type: :request do
let(:account) { create(:account) }
let(:web_widget) { create(:channel_widget, account: account) }
let(:web_widget) { create(:channel_widget, account: account, website_url: 'https://example.com') }
let(:contact) { create(:contact, account: account) }
let(:contact_inbox) { create(:contact_inbox, contact: contact, inbox: web_widget.inbox) }
let(:payload) { { source_id: contact_inbox.source_id, inbox_id: web_widget.inbox.id } }
@@ -10,13 +10,13 @@ describe '/widget', type: :request do
describe 'GET /widget' do
it 'renders the page correctly when called with website_token' do
get widget_url(website_token: web_widget.website_token)
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to be_successful
expect(response.body).not_to include(token)
end
it 'renders the page correctly when called with website_token and cw_conversation' do
get widget_url(website_token: web_widget.website_token, cw_conversation: token)
get widget_url(website_token: web_widget.website_token, cw_conversation: token), headers: { 'Origin' => 'https://example.com' }
expect(response).to be_successful
expect(response.body).to include(token)
end
@@ -29,7 +29,7 @@ describe '/widget', type: :request do
it 'returns 401 if the account is suspended' do
account.update!(status: :suspended)
get widget_url(website_token: web_widget.website_token)
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to have_http_status(:unauthorized)
expect(response.body).to include('Account is suspended')
end
@@ -37,9 +37,116 @@ describe '/widget', type: :request do
it 'returns 404 if the webwidget is deleted' do
web_widget.delete
get widget_url(website_token: web_widget.website_token)
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to have_http_status(:not_found)
expect(response.body).to include('web widget does not exist')
end
describe 'domain validation' do
context 'when website_url is blank' do
before { web_widget.update_column(:website_url, '') }
it 'allows access without origin validation' do
get widget_url(website_token: web_widget.website_token)
expect(response).to be_successful
end
end
context 'when website_url is configured' do
it 'allows access when origin matches exact domain' do
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to be_successful
end
it 'allows access when origin matches domain with same protocol' do
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to be_successful
end
it 'denies access when origin matches domain but has different protocol' do
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'http://example.com' }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
it 'denies access when origin does not match domain' do
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://malicious-site.com' }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
it 'denies access when no origin is provided' do
get widget_url(website_token: web_widget.website_token)
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
# Tests for wildcard domains with protocols (the fix we implemented)
context 'when website_url contains wildcard domains with protocols' do
it 'allows access when origin matches wildcard domain with same protocol' do
web_widget.update!(website_url: 'https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://client1.testsite.com' }
expect(response).to be_successful
end
it 'allows access when origin matches nested subdomain of wildcard with same protocol' do
web_widget.update!(website_url: 'https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://tenant.portal.testsite.com' }
expect(response).to be_successful
end
it 'denies access when origin matches wildcard domain but has different protocol' do
web_widget.update!(website_url: 'https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'http://client1.testsite.com' }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
it 'denies access when origin does not match wildcard pattern with protocol' do
web_widget.update!(website_url: 'https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://otherportal.com' }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
it 'allows access when origin matches wildcard domain with http protocol' do
web_widget.update!(website_url: 'http://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'http://client1.testsite.com' }
expect(response).to be_successful
end
it 'denies access when origin matches wildcard domain but has https instead of http' do
web_widget.update!(website_url: 'http://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://client1.testsite.com' }
expect(response).to have_http_status(:forbidden)
expect(response.body).to include('Widget access denied.')
end
it 'allows access when origin matches any domain in comma-separated list with mixed protocols' do
web_widget.update!(website_url: 'https://example.com, https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://client1.testsite.com' }
expect(response).to be_successful
end
it 'allows access when origin matches exact domain in mixed list with protocols' do
web_widget.update!(website_url: 'https://example.com, https://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://example.com' }
expect(response).to be_successful
end
it 'handles mixed wildcard and exact domains with different protocols' do
web_widget.update!(website_url: 'https://example.com, http://*.testsite.com')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'http://client1.testsite.com' }
expect(response).to be_successful
end
it 'handles whitespace around comma-separated domains with protocols' do
web_widget.update!(website_url: ' https://example.com , https://*.testsite.com ')
get widget_url(website_token: web_widget.website_token), headers: { 'Origin' => 'https://client1.testsite.com' }
expect(response).to be_successful
end
end
end
end
end
end