diff --git a/.changeset/kind-csp-nonce.md b/.changeset/kind-csp-nonce.md new file mode 100644 index 00000000..71f42b7f --- /dev/null +++ b/.changeset/kind-csp-nonce.md @@ -0,0 +1,5 @@ +--- +"@chatcops/widget": patch +--- + +Apply CSP nonces to the widget's injected styles. diff --git a/api/widget.api.md b/api/widget.api.md index 5567b20b..15e3c0d4 100644 --- a/api/widget.api.md +++ b/api/widget.api.md @@ -109,6 +109,7 @@ export declare class Widget { export interface WidgetConfig { apiUrl: string; + nonce?: string; timeout?: number; turnstileSiteKey?: string; userToken?: string; diff --git a/e2e/fixtures/csp.html b/e2e/fixtures/csp.html new file mode 100644 index 00000000..dcac726f --- /dev/null +++ b/e2e/fixtures/csp.html @@ -0,0 +1,15 @@ + + + + + + ChatCops e2e host page, CSP nonce + + +
+

Host page

+

The widget styles must carry the page's CSP nonce.

+
+ + + diff --git a/e2e/server.mjs b/e2e/server.mjs index b0698bd9..697fe342 100644 --- a/e2e/server.mjs +++ b/e2e/server.mjs @@ -28,6 +28,7 @@ const files = { '/theme.html': ['fixtures/theme.html', 'text/html'], '/prechat.html': ['fixtures/prechat.html', 'text/html'], '/locale.html': ['fixtures/locale.html', 'text/html'], + '/csp.html': ['fixtures/csp.html', 'text/html'], '/locales/zz.js': ['fixtures/locales/zz.js', 'text/javascript'], }; @@ -273,7 +274,11 @@ const server = createServer(async (req, res) => { const file = files[url.pathname]; if (file) { - res.writeHead(200, { 'Content-Type': file[1] }); + const headers = { 'Content-Type': file[1] }; + if (url.pathname === '/csp.html') { + headers['Content-Security-Policy'] = "default-src 'self'; script-src 'self' 'nonce-chatcops-e2e'; style-src 'nonce-chatcops-e2e'; connect-src 'self'; img-src 'self' data:"; + } + res.writeHead(200, headers); let content = await readFile(join(here, file[0]), 'utf8'); content = content.replaceAll('__E2E_API_PORT__', String(apiPort)); return res.end(content); diff --git a/e2e/tests/loader.spec.ts b/e2e/tests/loader.spec.ts index 26a6984b..2527ed0e 100644 --- a/e2e/tests/loader.spec.ts +++ b/e2e/tests/loader.spec.ts @@ -25,6 +25,33 @@ test('the panel downloads on hover, not on page load, and opens on click', async expect(panelRequests(requested)).toHaveLength(1); }); +test('the script nonce allows launcher, panel and lazy pre-chat styles under CSP', async ({ page }) => { + await page.addInitScript(() => { + (window as unknown as { cspViolations: string[] }).cspViolations = []; + document.addEventListener('securitypolicyviolation', (event) => { + (window as unknown as { cspViolations: string[] }).cspViolations.push(event.violatedDirective); + }); + }); + await page.goto('/csp.html'); + await expect(page.locator(fab)).toBeVisible(); + expect(await page.locator('#chatcops-root').evaluate((host) => host.shadowRoot?.querySelector('style')?.nonce)).toBe('chatcops-e2e'); + + await page.evaluate(() => { + (window as unknown as { ChatCops: { init(config: object): void } }).ChatCops.init({ + apiUrl: '/api/chat', + nonce: 'chatcops-e2e', + preChatForm: { enabled: true, fields: [{ name: 'name', label: 'Name', type: 'text' }] }, + }); + }); + await page.locator(fab).click(); + await expect(page.locator('.cc-prechat')).toBeVisible(); + const nonces = await page.locator('#chatcops-root').evaluate((host) => + Array.from(host.shadowRoot?.querySelectorAll('style') ?? [], (style) => style.nonce) + ); + expect(nonces).toEqual(['chatcops-e2e', 'chatcops-e2e']); + expect(await page.evaluate(() => (window as unknown as { cspViolations: string[] }).cspViolations)).toEqual([]); +}); + for (const [build, path] of [['loader', '/'], ['single-file build', '/full.html']] as const) { test(`nothing reads or writes storage before the visitor interacts (${build})`, async ({ page }) => { await countStorageCalls(page); diff --git a/packages/widget/src/dom/prechat-form.ts b/packages/widget/src/dom/prechat-form.ts index ca7408d2..5b1a5d90 100644 --- a/packages/widget/src/dom/prechat-form.ts +++ b/packages/widget/src/dom/prechat-form.ts @@ -15,6 +15,7 @@ export interface PreChatFormOptions { fields: PreChatField[]; submitLabel: string; locale: WidgetLocaleStrings; + nonce?: string; onSubmit: (data: Record) => void; } @@ -64,7 +65,7 @@ export class PreChatForm { this.container.appendChild(form); parent.appendChild(this.container); - addStyles(parent.getRootNode()); + addStyles(parent.getRootNode(), options.nonce); this.updateSubmitState(); @@ -227,9 +228,10 @@ export class PreChatForm { } /** Adds the form's styles to the widget's shadow root, once. */ -function addStyles(root: Node): void { +function addStyles(root: Node, nonce?: string): void { if (!(root instanceof ShadowRoot) || root.querySelector('style[data-cc-prechat]')) return; const style = document.createElement('style'); + if (nonce) style.nonce = nonce; style.dataset.ccPrechat = ''; style.textContent = prechatStyles; root.appendChild(style); diff --git a/packages/widget/src/dom/shadow.ts b/packages/widget/src/dom/shadow.ts index 789355fc..2e8bd30e 100644 --- a/packages/widget/src/dom/shadow.ts +++ b/packages/widget/src/dom/shadow.ts @@ -12,6 +12,7 @@ export const HOST_ID = 'chatcops-root'; export interface ShadowOptions { dir?: 'ltr' | 'rtl' | 'auto'; locale?: string; + nonce?: string; } const RTL_LANGUAGES = new Set(['ar', 'he', 'fa', 'ur']); @@ -46,6 +47,7 @@ export function createShadowRoot(container?: HTMLElement, options: ShadowOptions const shadow = host.attachShadow({ mode: 'open' }); const style = document.createElement('style'); + if (options.nonce) style.nonce = options.nonce; style.textContent = launcherStyles + widgetStyles; shadow.appendChild(style); diff --git a/packages/widget/src/loader/loader.ts b/packages/widget/src/loader/loader.ts index fabcab1d..6eaf7ab4 100644 --- a/packages/widget/src/loader/loader.ts +++ b/packages/widget/src/loader/loader.ts @@ -185,6 +185,7 @@ export function createLoader( document.body.appendChild(launcher); const shadow = launcher.attachShadow({ mode: 'open' }); const style = document.createElement('style'); + if (current.nonce) style.nonce = current.nonce; style.textContent = launcherStyles; shadow.appendChild(style); if (current.theme) applyTheme(shadow, current.theme); diff --git a/packages/widget/src/script-config.ts b/packages/widget/src/script-config.ts index 55f0c08e..3e5fe78e 100644 --- a/packages/widget/src/script-config.ts +++ b/packages/widget/src/script-config.ts @@ -18,6 +18,7 @@ export function configFromScript(script: HTMLScriptElement): WidgetConfig | unde return { apiUrl, + nonce: script.nonce || undefined, turnstileSiteKey: script.dataset.turnstileSiteKey, userToken: script.dataset.userToken, mode: mode || undefined, diff --git a/packages/widget/src/widget.ts b/packages/widget/src/widget.ts index 5c61eea2..e06b54b9 100644 --- a/packages/widget/src/widget.ts +++ b/packages/widget/src/widget.ts @@ -41,6 +41,8 @@ export interface PreChatFormConfig { export interface WidgetConfig { apiUrl: string; + /** CSP nonce applied to the widget's injected style elements. */ + nonce?: string; /** Request/stream inactivity timeout in milliseconds. Defaults to 30000. */ timeout?: number; /** @@ -227,9 +229,9 @@ export class Widget { prefetch(this.config); if (this.isInline) { this.containerEl = this.resolveContainer(); - this.shadow = createShadowRoot(this.containerEl, { dir: this.config.dir, locale: this.config.locale }); + this.shadow = createShadowRoot(this.containerEl, { dir: this.config.dir, locale: this.config.locale, nonce: this.config.nonce }); } else { - this.shadow = createShadowRoot(undefined, { dir: this.config.dir, locale: this.config.locale }); + this.shadow = createShadowRoot(undefined, { dir: this.config.dir, locale: this.config.locale, nonce: this.config.nonce }); } this.mounted = true; @@ -591,6 +593,7 @@ export class Widget { fields: formConfig.fields, submitLabel: formConfig.submitLabel ?? this.locale.preChatSubmit, locale: this.locale, + nonce: this.config.nonce, onSubmit: (data) => this.handlePreChatSubmit(data), }); }, diff --git a/packages/widget/tests/loader.test.ts b/packages/widget/tests/loader.test.ts index bcf5348e..6c7380ef 100644 --- a/packages/widget/tests/loader.test.ts +++ b/packages/widget/tests/loader.test.ts @@ -67,6 +67,7 @@ describe('the script tag loader', () => { expect(launcherButton()).not.toBeNull(); expect(launcherButton()?.getAttribute('aria-label')).toBe('Open chat'); expect(launcherButton()?.classList.contains('cc-left')).toBe(true); + expect(shadow()?.querySelector('style')?.hasAttribute('nonce')).toBe(false); expect(host()?.style.getPropertyValue('--cc-theme-accent')).toBe('#16a34a'); expect(shadow()?.querySelector('.cc-panel')).toBeNull(); expect(load).not.toHaveBeenCalled(); @@ -76,6 +77,19 @@ describe('the script tag loader', () => { loader.destroy(); }); + it('applies the configured nonce to launcher and panel styles', async () => { + const { load } = panel(); + const loader = createLoader(load); + loader.init({ apiUrl: 'https://api.test/chat', nonce: 'request-nonce' }); + + expect(shadow()?.querySelector('style')?.nonce).toBe('request-nonce'); + loader.open(); + await settle(); + expect(shadow()?.querySelector('style')?.nonce).toBe('request-nonce'); + + loader.destroy(); + }); + it('starts loading the chat when the launcher is hovered or focused', () => { const { load } = panel(); const loader = createLoader(load); diff --git a/packages/widget/tests/prechat-chunk.test.ts b/packages/widget/tests/prechat-chunk.test.ts index 75c23f11..962aa449 100644 --- a/packages/widget/tests/prechat-chunk.test.ts +++ b/packages/widget/tests/prechat-chunk.test.ts @@ -20,13 +20,14 @@ afterEach(() => { describe('the pre-chat form chunk', () => { it('shows the form once its chunk has loaded, with its styles added once', async () => { - widget = new Widget({ apiUrl: 'https://api.test/chat', preChatForm }); + widget = new Widget({ apiUrl: 'https://api.test/chat', preChatForm, nonce: 'request-nonce' }); widget.init(); widget.open(); await vi.dynamicImportSettled(); expect(shadow()?.querySelector('.cc-prechat')).not.toBeNull(); expect(shadow()?.querySelectorAll('style[data-cc-prechat]')).toHaveLength(1); + expect(Array.from(shadow()?.querySelectorAll('style') ?? [], (style) => style.nonce)).toEqual(['request-nonce', 'request-nonce']); // The input stays hidden until the form is sent. expect(shadow()?.querySelector('.cc-input-area')?.classList.contains('cc-hidden')).toBe(true); }); diff --git a/packages/widget/tests/script-config.test.ts b/packages/widget/tests/script-config.test.ts index 977f194c..57afb520 100644 --- a/packages/widget/tests/script-config.test.ts +++ b/packages/widget/tests/script-config.test.ts @@ -88,4 +88,10 @@ describe('configFromScript', () => { const config = configFromScript(script({ apiUrl: '/api/chat' })); expect(config).toMatchObject({ apiUrl: '/api/chat', mode: undefined, container: undefined, welcomeBubble: undefined, autoOpen: undefined }); }); + + it('reads the nonce property from a script tag', () => { + const tag = script({ apiUrl: '/api/chat' }); + tag.nonce = 'request-nonce'; + expect(configFromScript(tag)?.nonce).toBe('request-nonce'); + }); }); diff --git a/website/src/content/docs/widget/configuration.mdx b/website/src/content/docs/widget/configuration.mdx index b9100a9c..b327baa2 100644 --- a/website/src/content/docs/widget/configuration.mdx +++ b/website/src/content/docs/widget/configuration.mdx @@ -5,6 +5,16 @@ description: All configuration options for the ChatCops widget. The widget can be configured via `data-*` attributes on the script tag or programmatically via `ChatCops.init()`. +## Content Security Policy + +If your site's Content Security Policy requires a nonce for inline styles, add the same per-response nonce to the widget script tag. Script-tag installs, including `chatcops.full.min.js`, read their script element's `nonce` and apply it to the launcher, chat panel, and lazy pre-chat form styles. For ESM and other programmatic `ChatCops.init()` calls, pass it as `nonce`: + +```typescript +ChatCops.init({ apiUrl: '/api/chat', nonce: 'YOUR_RESPONSE_NONCE' }); +``` + +Include that nonce in your response's `style-src` policy. Generate a fresh, unpredictable nonce for each response and use it for both the policy and widget script. + ## Display Modes ChatCops supports two display modes: @@ -19,6 +29,7 @@ ChatCops supports two display modes: ```html