Compare commits
6
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a7f4e7a243 | ||
|
|
08ab851a01 | ||
|
|
fee4975c59 | ||
|
|
85dd0baf0f | ||
|
|
d1080c22cf | ||
|
|
819699307c |
@@ -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?
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user