feat(widget): Allow widget loading in mobile app WebViews when domain restrictions are set (#13763)

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>
This commit is contained in:
Muhsin Keloth
2026-03-17 14:29:41 +04:00
committed by GitHub
parent 688218de0a
commit a4c3d3d8c0
6 changed files with 159 additions and 77 deletions

View File

@@ -77,13 +77,23 @@ class WidgetsController < ActionController::Base
end
def allow_iframe_requests
if @web_widget.allowed_domains.blank?
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')

View File

@@ -14,6 +14,10 @@ defineProps({
type: Boolean,
default: false,
},
hideToggle: {
type: Boolean,
default: false,
},
});
const modelValue = defineModel({ type: Boolean, default: false });
@@ -28,7 +32,8 @@ const modelValue = defineModel({ type: Boolean, default: false });
<span class="text-heading-3 text-n-slate-12">
{{ header }}
</span>
<ToggleSwitch v-model="modelValue" />
<div v-if="hideToggle" class="size-2" />
<ToggleSwitch v-else v-model="modelValue" />
</div>
<span v-if="description" class="text-body-main text-n-slate-11">
{{ description }}

View File

@@ -710,8 +710,20 @@
"MESSENGER_SUB_HEAD": "Place this button inside your body tag",
"ALLOWED_DOMAINS": {
"TITLE": "Allowed Domains",
"SUBTITLE": "Add wildcard or regular domains separated by commas (leave blank to allow all), e.g. *.chatwoot.dev, chatwoot.com.",
"PLACEHOLDER": "Enter domains separated by commas (eg: *.chatwoot.dev, chatwoot.com)"
"DESCRIPTION": "Restrict which websites can embed your chat widget. For security, only add domains you own and trust. Add one or more domains separated by commas. Leave blank to allow all domains (not recommended for production).",
"PLACEHOLDER": "example.com, www.example.com, app.example.com"
},
"ALLOW_MOBILE_WEBVIEW": {
"LABEL": "Enable widget in mobile apps",
"SUBTITLE": "Check this if you embed the widget in iOS or Android apps. Mobile apps don't send domain information, so they would be blocked by domain restrictions unless this is enabled."
},
"IDENTITY_VALIDATION": {
"TITLE": "Identity Validation",
"DESCRIPTION": "Verify user authenticity by generating secure tokens. This prevents unauthorized users from impersonating others in your chat.",
"SECRET_KEY": "Secret Key",
"VIEW_DOCS": "View documentation",
"REQUIRE_LABEL": "Require identity validation for all conversations",
"REQUIRE_DESCRIPTION": "When enabled, users must provide a valid identity token to start conversations. Requests without valid tokens will be rejected."
},
"INBOX_AGENTS": "Agents",
"INBOX_AGENTS_SUB_TEXT": "Add or remove agents from this inbox",

View File

@@ -218,13 +218,10 @@ export default {
return getInboxIconByType(type, medium, 'line');
},
bannerMaxWidth() {
const narrowTabs = [
'collaborators',
'configuration',
'bot-configuration',
];
const narrowTabs = ['collaborators', 'bot-configuration'];
const wideIfWebWidget = ['configuration', 'inbox-settings'];
if (narrowTabs.includes(this.selectedTabKey)) return 'max-w-4xl';
if (this.selectedTabKey === 'inbox-settings') {
if (wideIfWebWidget.includes(this.selectedTabKey)) {
return this.isAWebWidgetInbox ? 'max-w-7xl' : 'max-w-4xl';
}
return 'max-w-7xl';
@@ -354,6 +351,8 @@ export default {
this.$nextTick(() => {
this.setTabFromRouteParam();
});
} else {
this.selectedFeatureFlags = newInbox?.selected_feature_flags || [];
}
},
immediate: true,
@@ -1164,7 +1163,11 @@ export default {
<div v-if="selectedTabKey === 'collaborators'" class="mx-6 max-w-4xl">
<CollaboratorsPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'configuration'" class="mx-6 max-w-4xl">
<div
v-if="selectedTabKey === 'configuration'"
class="mx-6"
:class="isAWebWidgetInbox ? 'max-w-7xl' : 'max-w-4xl'"
>
<ConfigurationPage :inbox="inbox" />
</div>
<div v-if="selectedTabKey === 'csat'">

View File

@@ -2,6 +2,8 @@
import { useAlert } from 'dashboard/composables';
import inboxMixin from 'shared/mixins/inboxMixin';
import SettingsFieldSection from 'dashboard/components-next/Settings/SettingsFieldSection.vue';
import SettingsToggleSection from 'dashboard/components-next/Settings/SettingsToggleSection.vue';
import SettingsAccordion from 'dashboard/components-next/Settings/SettingsAccordion.vue';
import ImapSettings from '../ImapSettings.vue';
import SmtpSettings from '../SmtpSettings.vue';
import { useVuelidate } from '@vuelidate/core';
@@ -14,6 +16,8 @@ import { sanitizeAllowedDomains } from 'dashboard/helper/URLHelper';
export default {
components: {
SettingsFieldSection,
SettingsToggleSection,
SettingsAccordion,
ImapSettings,
SmtpSettings,
NextButton,
@@ -33,11 +37,13 @@ export default {
data() {
return {
hmacMandatory: false,
allowMobileWebview: false,
whatsAppInboxAPIKey: '',
isRequestingReauthorization: false,
isSyncingTemplates: false,
allowedDomains: '',
isUpdatingAllowedDomains: false,
isSettingDefaults: false,
};
},
validations: {
@@ -58,14 +64,28 @@ export default {
inbox() {
this.setDefaults();
},
allowMobileWebview() {
if (!this.isSettingDefaults) this.handleMobileWebviewFlag();
},
hmacMandatory() {
if (!this.isSettingDefaults && this.isAWebWidgetInbox)
this.handleHmacFlag();
},
},
mounted() {
this.setDefaults();
},
methods: {
setDefaults() {
this.isSettingDefaults = true;
this.hmacMandatory = this.inbox.hmac_mandatory || false;
this.allowMobileWebview = (
this.inbox.selected_feature_flags || []
).includes('allow_mobile_webview');
this.allowedDomains = this.inbox.allowed_domains || '';
this.$nextTick(() => {
this.isSettingDefaults = false;
});
},
handleHmacFlag() {
this.updateInbox();
@@ -85,6 +105,26 @@ export default {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async handleMobileWebviewFlag() {
try {
const currentFlags = this.inbox.selected_feature_flags || [];
const selectedFlags = this.allowMobileWebview
? [...currentFlags, 'allow_mobile_webview']
: currentFlags.filter(f => f !== 'allow_mobile_webview');
const payload = {
id: this.inbox.id,
formData: false,
channel: {
selected_feature_flags: selectedFlags,
},
};
await this.$store.dispatch('inboxes/updateInbox', payload);
useAlert(this.$t('INBOX_MGMT.EDIT.API.SUCCESS_MESSAGE'));
} catch (error) {
useAlert(this.$t('INBOX_MGMT.EDIT.API.ERROR_MESSAGE'));
}
},
async updateAllowedDomains() {
this.isUpdatingAllowedDomains = true;
const sanitizedAllowedDomains = sanitizeAllowedDomains(
@@ -196,75 +236,86 @@ export default {
</SettingsFieldSection>
</div>
<div v-else-if="isAWebWidgetInbox">
<div>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.SUBTITLE')"
class="[&>div]:!items-start"
<div class="space-y-4">
<SettingsToggleSection
:header="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.TITLE')"
:description="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.DESCRIPTION')
"
hide-toggle
>
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
min-height="8rem"
class="w-full"
/>
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<div class="col-span-1 lg:col-span-6 mt-4 justify-self-end">
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</div>
</template>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_VERIFICATION')"
>
<woot-code :script="inbox.hmac_token" />
<template #extra>
<div class="grid grid-cols-1 lg:grid-cols-8">
<div class="col-span-1 lg:col-span-2 invisible" />
<p
class="col-span-1 lg:col-span-6 mt-1.5 text-label-small text-n-slate-11 ltr:ml-1 rtl:mr-1"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
<a
target="_blank"
rel="noopener noreferrer"
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
class="text-n-blue-11 hover:underline text-label-small"
>
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_LINK_TO_DOCS') }}
</a>
</p>
</div>
</template>
</SettingsFieldSection>
<SettingsFieldSection
:label="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_VERIFICATION')"
:help-text="$t('INBOX_MGMT.SETTINGS_POPUP.HMAC_MANDATORY_DESCRIPTION')"
>
<div class="flex gap-2 items-center">
<input
id="hmacMandatory"
v-model="hmacMandatory"
type="checkbox"
@change="handleHmacFlag"
<template #editor>
<TextArea
v-model="allowedDomains"
:placeholder="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOWED_DOMAINS.PLACEHOLDER')
"
auto-height
resize
class="w-full [&>div]:!bg-transparent [&>div]:!border-none [&>div]:!border-0 [&>div]:px-0 [&>div]:pb-0 [&>div]:pt-0"
/>
<label for="hmacMandatory" class="text-body-main text-n-slate-12">
{{ $t('INBOX_MGMT.EDIT.ENABLE_HMAC.LABEL') }}
</label>
</div>
</SettingsFieldSection>
<div class="mt-3 flex justify-end">
<NextButton
:label="$t('INBOX_MGMT.SETTINGS_POPUP.UPDATE')"
:is-loading="isUpdatingAllowedDomains"
@click="updateAllowedDomains"
/>
</div>
</template>
</SettingsToggleSection>
<SettingsToggleSection
v-model="allowMobileWebview"
:header="$t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MOBILE_WEBVIEW.LABEL')"
:description="
$t('INBOX_MGMT.SETTINGS_POPUP.ALLOW_MOBILE_WEBVIEW.SUBTITLE')
"
/>
</div>
<SettingsAccordion
:title="$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.TITLE')"
class="mt-6"
>
<SettingsToggleSection
:header="$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.TITLE')"
:description="
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.DESCRIPTION')
"
hide-toggle
>
<template #editor>
<p class="mb-1 text-sm font-medium text-n-slate-12">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.SECRET_KEY') }}
</p>
<woot-code :script="inbox.hmac_token" />
<p class="mt-1.5 text-label-small text-n-slate-11">
{{ $t('INBOX_MGMT.SETTINGS_POPUP.HMAC_DESCRIPTION') }}
<a
target="_blank"
rel="noopener noreferrer"
href="https://www.chatwoot.com/docs/product/channels/live-chat/sdk/identity-validation/"
class="text-n-blue-11 hover:underline text-label-small"
>
{{
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.VIEW_DOCS')
}}
</a>
</p>
</template>
</SettingsToggleSection>
<SettingsToggleSection
v-model="hmacMandatory"
:header="
$t('INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.REQUIRE_LABEL')
"
:description="
$t(
'INBOX_MGMT.SETTINGS_POPUP.IDENTITY_VALIDATION.REQUIRE_DESCRIPTION'
)
"
/>
</SettingsAccordion>
</div>
<div v-else-if="isAPIInbox">
<SettingsFieldSection

View File

@@ -51,6 +51,7 @@ class Channel::WebWidget < ApplicationRecord
2 => :emoji_picker,
3 => :end_conversation,
4 => :use_inbox_avatar_for_bot,
5 => :allow_mobile_webview,
:column => 'feature_flags',
:check_for_column => false