When `allowed_domains` is configured on a web widget inbox, the server responds with Content-Security-Policy: frame-ancestors <domains>, which blocks the widget iframe in mobile app WebViews. This happens because WebViews load content from file:// or null origins, which cannot match any domain in the frame-ancestors directive. This adds a per-inbox toggle — "Enable widget in mobile apps" — that skips the frame-ancestors header when the request has no valid Origin (i.e., it comes from a mobile WebView). Web browsers with a real origin still get domain restrictions enforced as usual. <img width="2330" height="1490" alt="CleanShot 2026-03-11 at 10 13 01@2x" src="https://github.com/user-attachments/assets/d9326fac-020d-4ce7-9ced-0c185468c8fc" /> Fixes https://linear.app/chatwoot/issue/CW-6560/widget-is-not-loading-from-iosandroid-widgets How to test 1. Go to Settings → Inboxes → (Web Widget) → Configuration 2. Set allowed_domains to a specific domain (e.g., *.example.com) 3. Try loading the widget in a mobile app WebView — it should be blocked 4. Enable "Enable widget in mobile apps" checkbox 5. Reload the widget in the WebView — it should now load successfully 6. Verify the widget on a website not in the allowed domains list is still blocked --------- Co-authored-by: iamsivin <iamsivin@gmail.com>
100 lines
2.9 KiB
Ruby
100 lines
2.9 KiB
Ruby
# TODO : Delete this and associated spec once 'api/widget/config' end point is merged
|
|
class WidgetsController < ActionController::Base
|
|
include WidgetHelper
|
|
|
|
before_action :set_global_config
|
|
before_action :set_web_widget
|
|
before_action :ensure_account_is_active
|
|
before_action :ensure_location_is_supported
|
|
before_action :set_token
|
|
before_action :set_contact
|
|
before_action :build_contact
|
|
after_action :allow_iframe_requests
|
|
|
|
private
|
|
|
|
def set_global_config
|
|
@global_config = GlobalConfig.get(
|
|
'LOGO_THUMBNAIL',
|
|
'BRAND_NAME',
|
|
'WIDGET_BRAND_URL',
|
|
'DIRECT_UPLOADS_ENABLED',
|
|
'MAXIMUM_FILE_UPLOAD_SIZE',
|
|
'INSTALLATION_NAME'
|
|
)
|
|
end
|
|
|
|
def set_web_widget
|
|
@web_widget = ::Channel::WebWidget.find_by!(website_token: permitted_params[:website_token])
|
|
rescue ActiveRecord::RecordNotFound
|
|
Rails.logger.error('web widget does not exist')
|
|
render json: { error: 'web widget does not exist' }, status: :not_found
|
|
end
|
|
|
|
def set_token
|
|
@token = permitted_params[:cw_conversation]
|
|
@auth_token_params = if @token.present?
|
|
::Widget::TokenService.new(token: @token).decode_token
|
|
else
|
|
{}
|
|
end
|
|
end
|
|
|
|
def set_contact
|
|
return if @auth_token_params[:source_id].nil?
|
|
|
|
@contact_inbox = ::ContactInbox.find_by(
|
|
inbox_id: @web_widget.inbox.id,
|
|
source_id: @auth_token_params[:source_id]
|
|
)
|
|
|
|
@contact = @contact_inbox&.contact
|
|
end
|
|
|
|
def build_contact
|
|
return if @contact.present?
|
|
|
|
@contact_inbox, @token = build_contact_inbox_with_token(@web_widget, additional_attributes)
|
|
@contact = @contact_inbox.contact
|
|
end
|
|
|
|
def ensure_account_is_active
|
|
render json: { error: 'Account is suspended' }, status: :unauthorized unless @web_widget.inbox.account.active?
|
|
end
|
|
|
|
def ensure_location_is_supported; end
|
|
|
|
def additional_attributes
|
|
if @web_widget.inbox.account.feature_enabled?('ip_lookup')
|
|
{ created_at_ip: request.remote_ip }
|
|
else
|
|
{}
|
|
end
|
|
end
|
|
|
|
def permitted_params
|
|
params.permit(:website_token, :cw_conversation)
|
|
end
|
|
|
|
def allow_iframe_requests
|
|
if @web_widget.allowed_domains.blank? || embedded_from_non_web_origin?
|
|
response.headers.delete('X-Frame-Options')
|
|
else
|
|
domains = @web_widget.allowed_domains.split(',').map(&:strip).join(' ')
|
|
response.headers['Content-Security-Policy'] = "frame-ancestors #{domains}"
|
|
end
|
|
end
|
|
|
|
# Mobile WebViews (iOS/Android) load content from file:// or null origins,
|
|
# which cannot match any domain in frame-ancestors. When the per-inbox flag
|
|
# is enabled, skip frame-ancestors for these requests.
|
|
def embedded_from_non_web_origin?
|
|
return false unless @web_widget.allow_mobile_webview?
|
|
|
|
origin = request.headers['Origin']
|
|
origin.blank? || origin == 'null' || origin&.start_with?('file://')
|
|
end
|
|
end
|
|
|
|
WidgetsController.prepend_mod_with('WidgetsController')
|