Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions app.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions assets/styles/main.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
10 changes: 10 additions & 0 deletions nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -53,6 +58,11 @@ export default defineNuxtConfig({
tagPosition: 'head',
tagPriority: 'critical',
},
{
id: 'freshdesk-widget',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This hides the Freshdesk frame on onboarding only after the global loader has already run. In the PR preview, /onboarding?network=1 had support-widget-hidden === true and #fd_frame { display: none }, but the page still loaded widget.js, created the Freshdesk iframe, and called Freshdesk visitor/ticket/token/event endpoints before the user left the connect-wallet onboarding screen.

That makes the onboarding hide visual rather than operational: a hidden third-party support widget still sends the page/referrer and creates a Freshdesk visitor session. For this surface I think we should gate initialization itself — e.g. defer injecting/loading Freshdesk until onboarding is complete or the current route is not onboarding — rather than loading globally and hiding with CSS. If the intended policy is explicitly “load Freshdesk on onboarding but hide the button,” that privacy/UX decision should be documented, but the safer invariant is no third-party widget traffic while the widget is hidden on onboarding.

innerHTML: freshdeskWidgetScript,
tagPosition: 'bodyClose',
},
],
meta: [
{
Expand Down
16 changes: 12 additions & 4 deletions server/plugins/csp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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']),
Expand Down
8 changes: 8 additions & 0 deletions tests/server/security.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Loading