diff --git a/app/controllers/widgets_controller.rb b/app/controllers/widgets_controller.rb index 70e4c967b..1c3d55577 100644 --- a/app/controllers/widgets_controller.rb +++ b/app/controllers/widgets_controller.rb @@ -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? diff --git a/app/helpers/widget_helper.rb b/app/helpers/widget_helper.rb index 0798789eb..4dc53cf99 100644 --- a/app/helpers/widget_helper.rb +++ b/app/helpers/widget_helper.rb @@ -1,7 +1,13 @@ 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 } + payload = { + source_id: contact_inbox.source_id, + inbox_id: web_widget.inbox.id, + iat: Time.current.to_i, + exp: 1.day.from_now.to_i + } + token = ::Widget::TokenService.new(payload: payload).generate_token [contact_inbox, token] diff --git a/spec/controllers/widgets_controller_spec.rb b/spec/controllers/widgets_controller_spec.rb index 7789bb478..9fb4bd1ff 100644 --- a/spec/controllers/widgets_controller_spec.rb +++ b/spec/controllers/widgets_controller_spec.rb @@ -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!(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