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