Skip to content
Merged
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
5 changes: 5 additions & 0 deletions .changeset/kind-csp-nonce.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chatcops/widget": patch
---

Apply CSP nonces to the widget's injected styles.
1 change: 1 addition & 0 deletions api/widget.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,7 @@ export declare class Widget {

export interface WidgetConfig {
apiUrl: string;
nonce?: string;
timeout?: number;
turnstileSiteKey?: string;
userToken?: string;
Expand Down
15 changes: 15 additions & 0 deletions e2e/fixtures/csp.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ChatCops e2e host page, CSP nonce</title>
</head>
<body>
<main>
<h1>Host page</h1>
<p>The widget styles must carry the page's CSP nonce.</p>
</main>
<script nonce="chatcops-e2e" src="/chatcops.min.js" data-api-url="/api/chat"></script>
</body>
</html>
7 changes: 6 additions & 1 deletion e2e/server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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'],
};

Expand Down Expand Up @@ -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);
Expand Down
27 changes: 27 additions & 0 deletions e2e/tests/loader.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 4 additions & 2 deletions packages/widget/src/dom/prechat-form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export interface PreChatFormOptions {
fields: PreChatField[];
submitLabel: string;
locale: WidgetLocaleStrings;
nonce?: string;
onSubmit: (data: Record<string, string>) => void;
}

Expand Down Expand Up @@ -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();

Expand Down Expand Up @@ -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);
Expand Down
2 changes: 2 additions & 0 deletions packages/widget/src/dom/shadow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']);
Expand Down Expand Up @@ -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);

Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/loader/loader.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
1 change: 1 addition & 0 deletions packages/widget/src/script-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
7 changes: 5 additions & 2 deletions packages/widget/src/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
/**
Expand Down Expand Up @@ -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;

Expand Down Expand Up @@ -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),
});
},
Expand Down
14 changes: 14 additions & 0 deletions packages/widget/tests/loader.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
Expand Down
3 changes: 2 additions & 1 deletion packages/widget/tests/prechat-chunk.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
});
Expand Down
6 changes: 6 additions & 0 deletions packages/widget/tests/script-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
});
12 changes: 12 additions & 0 deletions website/src/content/docs/widget/configuration.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -19,6 +29,7 @@ ChatCops supports two display modes:
```html
<script
src="https://cdn.jsdelivr.net/npm/@chatcops/widget/dist/chatcops.min.js"
nonce="YOUR_RESPONSE_NONCE"
data-api-url="https://your-api.com/chat"
data-accent="#6366f1"
data-text-color="#FAFAF9"
Expand Down Expand Up @@ -213,6 +224,7 @@ const leadForm: PreChatFormConfig = {
// docs-check: skip, a reference listing rather than code to run
interface WidgetConfig {
apiUrl: string;
nonce?: string; // CSP nonce for the widget's injected styles
timeout?: number; // Inactivity timeout in ms, default: 30000
turnstileSiteKey?: string; // Cloudflare Turnstile site key, when the server has `turnstile` set
userToken?: string; // Your server's signed token for a signed-in visitor (server `identity`)
Expand Down
Loading