diff --git a/app.vue b/app.vue index f909d8026..c1d22d6ab 100644 --- a/app.vue +++ b/app.vue @@ -115,6 +115,15 @@ watch(route, () => { }) }, { immediate: true }) +// Keep the Freshdesk support widget off the onboarding (connect wallet) +// screen. The widget container mounts asynchronously after window load, so +// visibility is toggled via a root class (styled in assets/styles/main.scss) +// rather than on the container element itself. +watch(() => route.name, (name) => { + if (!import.meta.client) return + document.documentElement.classList.toggle('support-widget-hidden', name === 'onboarding') +}, { immediate: true }) + const checkAnnouncement = () => { if (!announcement.enabled || !announcement.token) return if (announcementSeenToken.value === announcement.token) return diff --git a/assets/styles/main.scss b/assets/styles/main.scss index 0c13165b9..a0ac7e157 100644 --- a/assets/styles/main.scss +++ b/assets/styles/main.scss @@ -69,3 +69,19 @@ body { .auto-link strong { font-weight: 600; } + +// The Freshdesk support widget is injected asynchronously after window load +// (see the freshdesk-widget head script in nuxt.config.ts), so per-route +// visibility is driven by this root class (toggled in app.vue) instead of +// the element. +html.support-widget-hidden #fd_frame { + display: none; +} + +// The page declares color-scheme: dark while the Freshdesk iframe's document +// declares none, and browsers paint an opaque white canvas behind cross-origin +// iframes on a color-scheme mismatch. Declaring the iframe light restores its +// transparency, removing the white box around the launcher. +#fd_frame iframe { + color-scheme: light; +} diff --git a/nuxt.config.ts b/nuxt.config.ts index f243a6e30..fc859a249 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -4,6 +4,11 @@ import { lstatSync } from 'node:fs' import { resolve } from 'node:path' const themeBootstrapScript = '(function(){var theme="dark";try{theme=localStorage.getItem("theme")==="light"?"light":"dark"}catch(e){}document.documentElement.setAttribute("data-theme",theme);document.documentElement.style.colorScheme=theme})()' +// Freshdesk support widget loader (official embed snippet) + init call. +// Defers the widget.js download until window load. Executes under CSP via the +// per-request nonce injected by server/plugins/csp.ts; 'strict-dynamic' then +// trusts the widget script it inserts. +const freshdeskWidgetScript = '(function(){function initFreshdesk(){window.fdWidget.init({token:"01KX0V6EFZFYTKVGZG1A774JD8",host:"https://euler.freshdesk.com",widgetId:"01KX0V6GZE7R415HA349C50NPX"})}function initialize(i,t){var e;i.getElementById(t)?initFreshdesk():((e=i.createElement("script")).id=t,e.async=!0,e.src="https://euler.freshdesk.com/webchat/js/widget.js",e.onload=initFreshdesk,i.head.appendChild(e))}function initiateCall(){initialize(document,"Freshdesk-js-sdk")}window.addEventListener?window.addEventListener("load",initiateCall,!1):window.attachEvent("load",initiateCall,!1)})()' const eulerSdkPackage = '@eulerxyz/euler-v2-sdk' const isLinkedEulerSdk = (() => { try { @@ -53,6 +58,11 @@ export default defineNuxtConfig({ tagPosition: 'head', tagPriority: 'critical', }, + { + id: 'freshdesk-widget', + innerHTML: freshdeskWidgetScript, + tagPosition: 'bodyClose', + }, ], meta: [ { diff --git a/server/plugins/csp.ts b/server/plugins/csp.ts index 0961be594..d01106ea2 100644 --- a/server/plugins/csp.ts +++ b/server/plugins/csp.ts @@ -160,6 +160,11 @@ const CONNECT_SRC_BASE = [ 'wss://www.walletlink.org', 'wss://relay.walletconnect.com', 'wss://relay.walletconnect.org', + // Freshdesk support widget (loaded from euler.freshdesk.com, see + // freshdesk-widget script in nuxt.config.ts) + 'https://euler.freshdesk.com', + 'https://*.freshdesk.com', + 'wss://*.freshdesk.com', ] export function buildCsp( @@ -179,18 +184,21 @@ export function buildCsp( const directives = [ 'default-src \'self\'', `script-src 'self' 'nonce-${nonce}' 'strict-dynamic' 'wasm-unsafe-eval' https://static.cloudflareinsights.com`, - 'style-src \'unsafe-inline\' \'self\'', + // euler.freshdesk.com: the Freshdesk widget loads its stylesheet, fonts, + // and chat iframe from there. object-src stays 'none' — the widget iframe + // is covered by frame-src. + 'style-src \'unsafe-inline\' \'self\' https://euler.freshdesk.com', 'object-src \'none\'', 'base-uri \'self\'', `connect-src ${connectSrc.join(' ')}`, - 'font-src \'self\' https://fonts.reown.com', - 'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com', + 'font-src \'self\' data: https://fonts.reown.com https://euler.freshdesk.com', + 'frame-src \'self\' https://verify.walletconnect.org https://verify.walletconnect.com https://euler.freshdesk.com', 'frame-ancestors \'none\'', // Token logos come from arbitrary CDNs (CoinGecko, DefiLlama, Uniswap, etc.) // that cannot be whitelisted upfront. Images are passive content — no script execution risk. 'img-src \'self\' data: blob: https:', 'manifest-src \'self\'', - 'media-src \'self\'', + 'media-src \'self\' https://euler.freshdesk.com', 'worker-src \'self\' blob:', 'form-action \'self\'', ...(isDev ? [] : ['upgrade-insecure-requests']), diff --git a/tests/server/security.test.ts b/tests/server/security.test.ts index 46aafa7f9..b4b356db2 100644 --- a/tests/server/security.test.ts +++ b/tests/server/security.test.ts @@ -66,6 +66,14 @@ describe('buildCsp', () => { .find(d => d.startsWith('connect-src')) expect(connectSrc).toContain('https://api.4byte.sourcify.dev') }) + + it('allows Freshdesk support widget traffic', () => { + const connectSrc = csp + .split(';') + .map(d => d.trim()) + .find(d => d.startsWith('connect-src')) + expect(connectSrc).toContain('https://euler.freshdesk.com') + }) }) describe('applySecurityHeaders', () => {