Skip to content

Commit b97188a

Browse files
ECHOES-1025 Add CSP nonce support
1 parent f65f177 commit b97188a

File tree

7 files changed

+195
-9
lines changed

7 files changed

+195
-9
lines changed

README.md

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,21 @@ Make sure to setup the following Providers at the root of your app:
4242

4343
The `EchoesProvider` is required to provide theming and configuration context for all Echoes components.
4444

45+
If your application uses a strict Content Security Policy (CSP), you can provide a nonce to the `EchoesProvider` to enable inline styles required by Echoes components:
46+
47+
```tsx
48+
const nonce = document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content');
49+
50+
<EchoesProvider nonce={nonce}>{/* your app */}</EchoesProvider>;
51+
```
52+
53+
This nonce will be automatically applied to:
54+
55+
- Emotion's CSS-in-JS style tags
56+
- react-joyride inline styles used in the Spotlight component
57+
58+
For more information about CSP configuration, see the [Content Security Policy](#content-security-policy) section.
59+
4560
#### Intl Provider
4661

4762
The `IntlProvider` from `react-intl` is necessary for translations. See [this page](https://formatjs.github.io/docs/react-intl/components#intlprovider) for more information.
@@ -205,6 +220,60 @@ If tooltips or other overlay components don't appear correctly, ensure you have
205220

206221
If you encounter errors about missing router context when using Link components or other routing-related components, make sure you have wrapped your app with a Router provider from `react-router-dom` (e.g., `BrowserRouter`, `HashRouter`, or `MemoryRouter`).
207222

223+
## Content Security Policy
224+
225+
Echoes React supports strict Content Security Policy (CSP) configurations. To use Echoes with a CSP that restricts inline styles, you need to configure nonces.
226+
227+
### CSP Configuration
228+
229+
Configure your CSP header as follows:
230+
231+
```http
232+
Content-Security-Policy:
233+
style-src
234+
'self'
235+
'nonce-RANDOM_VALUE'
236+
'unsafe-hashes'
237+
'sha256-47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU='
238+
'sha256-qixoDh78J8vISHKC3rLI7qSXmTShr8mhsUgjJL7W7aU='
239+
'sha256-3gJFr3n77fnX5qwQpGju/zCOsoHW5RMqQd5XOb9WFcA=';
240+
```
241+
242+
Where:
243+
244+
- `'nonce-RANDOM_VALUE'` - A cryptographically secure random nonce generated per request (required for Emotion CSS-in-JS and Spotlight/react-joyride inline styles)
245+
- `'unsafe-hashes'` with three SHA-256 hashes - Required only for sonner toast notification animations
246+
247+
### Application Setup
248+
249+
Pass the nonce to the `EchoesProvider`:
250+
251+
```tsx
252+
import { EchoesProvider } from '@sonarsource/echoes-react';
253+
254+
function App() {
255+
// Get nonce from meta tag or server-rendered context
256+
const nonce = document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content');
257+
258+
return (
259+
<EchoesProvider nonce={nonce}>
260+
<YourApp />
261+
</EchoesProvider>
262+
);
263+
}
264+
```
265+
266+
The nonce will be automatically applied to:
267+
268+
- All Emotion CSS-in-JS style tags
269+
- Spotlight component's inline styles (via react-joyride)
270+
271+
### Notes
272+
273+
- The three SHA-256 hashes are specific to sonner v2.0.7 (the toast notification library). If you upgrade sonner, these hashes may need to be updated.
274+
- Without the nonce configuration, applications with strict CSP will block Echoes components from rendering correctly.
275+
- For development environments without CSP, the nonce prop is optional and can be omitted.
276+
208277
## License
209278

210279
Copyright 2023-2025 SonarSource.

src/common/helpers/test-utils.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,8 @@
1717
* along with this program; if not, write to the Free Software Foundation,
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
20+
import createCache from '@emotion/cache';
21+
import { CacheProvider } from '@emotion/react';
2022
import { RenderOptions, RenderResult, render as rtlRender } from '@testing-library/react';
2123
import userEvent, { UserEvent, Options as UserEventsOptions } from '@testing-library/user-event';
2224
import React, { ComponentProps, PropsWithChildren } from 'react';
@@ -25,6 +27,11 @@ import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
2527
import { PropsWithLabels, PropsWithLabelsAndHelpText } from '~types/utils';
2628
import { EchoesProvider } from '../../components/echoes-provider';
2729

30+
// Create a singleton Emotion cache for all tests to ensure consistent class name generation
31+
// This matches the key used in EchoesProvider when a nonce is provided
32+
const testEmotionCache = createCache({ key: 'echoes' });
33+
testEmotionCache.compat = true;
34+
2835
type RenderResultWithUser = RenderResult & { user: UserEvent };
2936

3037
export function render(
@@ -87,8 +94,10 @@ function ShowPath() {
8794

8895
function ContextWrapper({ children }: PropsWithChildren<{}>) {
8996
return (
90-
<IntlProvider defaultLocale="en-us" locale="en-us">
91-
<EchoesProvider tooltipsDelayDuration={0}>{children}</EchoesProvider>
92-
</IntlProvider>
97+
<CacheProvider value={testEmotionCache}>
98+
<IntlProvider defaultLocale="en-us" locale="en-us">
99+
<EchoesProvider tooltipsDelayDuration={0}>{children}</EchoesProvider>
100+
</IntlProvider>
101+
</CacheProvider>
93102
);
94103
}

src/components/echoes-provider/EchoesProvider.tsx

Lines changed: 59 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,15 +18,29 @@
1818
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
1919
*/
2020

21+
import createCache from '@emotion/cache';
22+
import { CacheProvider } from '@emotion/react';
2123
import { HeadlessMantineProvider } from '@mantine/core';
22-
import { PropsWithChildren } from 'react';
24+
import { PropsWithChildren, useMemo } from 'react';
2325
import { useIntl } from 'react-intl';
2426
import { Toaster as ToastContainer } from 'sonner';
2527
import { ToastGlobalStyles } from '~common/components/Toast';
2628
import { TooltipProvider, TooltipProviderProps, TypographyGlobalStyles } from '..';
2729
import { SelectGlobalStyles } from '../select/SelectCommons';
30+
import { NonceContext } from './NonceContext';
2831

2932
export interface EchoesProviderProps {
33+
/**
34+
* A nonce value for inline styles (Content Security Policy - CSP) (optional).
35+
* When provided, this nonce will be:
36+
* - Applied to Emotion's CSS-in-JS style tags
37+
* - Made available to components like Spotlight that need it for inline styles
38+
* - Used to comply with strict Content Security Policy requirements
39+
*
40+
* This should be set once at the application root and will automatically
41+
* propagate to all Echoes components that require it.
42+
*/
43+
nonce?: string;
3044
/**
3145
* Custom class name for all the toasts (optional).
3246
*/
@@ -55,7 +69,8 @@ export interface EchoesProviderProps {
5569
* It must be placed at the root of your application (or at least wrap all
5670
* components that use the Echoes design system). To ensure all Echoes components work properly,
5771
* the EchoesProvider should be placed inside the react-intl provider and react-router provider.
58-
* Ideally, you should also wrap your application with a div that reset the [Stacking Context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
72+
* Ideally, you should also wrap your application with a div that reset the
73+
* [Stacking Context](https://developer.mozilla.org/en-US/docs/Web/CSS/CSS_positioned_layout/Understanding_z-index/Stacking_context)
5974
* for your app to ensure that tooltips and toasts from Echoes appear above the rest of the UI.
6075
*
6176
* **Usage**
@@ -85,13 +100,44 @@ export interface EchoesProviderProps {
85100
* );
86101
* }
87102
* ```
103+
*
104+
* **Content Security Policy (CSP) Support**
105+
*
106+
* If your application uses a strict Content Security Policy, you can provide a nonce
107+
* to enable inline styles required by Echoes components:
108+
*
109+
* ```tsx
110+
* function App() {
111+
* // Get nonce from meta tag or server context
112+
* const nonce = document.querySelector('meta[name="csp-nonce"]')?.getAttribute('content');
113+
*
114+
* return (
115+
* <EchoesProvider nonce={nonce}>
116+
* {children}
117+
* </EchoesProvider>
118+
* );
119+
* }
120+
* ```
88121
*/
89122
export function EchoesProvider(props: PropsWithChildren<EchoesProviderProps>) {
90-
const { children, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 5 } = props;
123+
const { children, nonce, tooltipsDelayDuration, toastsClassName, toastsVisibleNb = 5 } = props;
91124
const intl = useIntl();
92125

93-
return (
94-
<>
126+
// Create Emotion cache with nonce support for CSP compliance
127+
// Use 'echoes' as the key for better namespace isolation and debugging
128+
const emotionCache = useMemo(() => {
129+
if (!nonce) {
130+
return undefined;
131+
}
132+
133+
const cache = createCache({ key: 'echoes', nonce });
134+
cache.compat = true;
135+
136+
return cache;
137+
}, [nonce]);
138+
139+
const providerContent = (
140+
<NonceContext.Provider value={nonce}>
95141
<TypographyGlobalStyles />
96142
<SelectGlobalStyles />
97143
<ToastGlobalStyles />
@@ -108,7 +154,14 @@ export function EchoesProvider(props: PropsWithChildren<EchoesProviderProps>) {
108154
visibleToasts={toastsVisibleNb}
109155
/>
110156
</TooltipProvider>
111-
</>
157+
</NonceContext.Provider>
158+
);
159+
160+
// Only wrap with CacheProvider if a nonce is provided
161+
return emotionCache ? (
162+
<CacheProvider value={emotionCache}>{providerContent}</CacheProvider>
163+
) : (
164+
providerContent
112165
);
113166
}
114167

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/*
2+
* Echoes React
3+
* Copyright (C) 2023-2025 SonarSource Sàrl
4+
* mailto:info AT sonarsource DOT com
5+
*
6+
* This program is free software; you can redistribute it and/or
7+
* modify it under the terms of the GNU Lesser General Public
8+
* License as published by the Free Software Foundation; either
9+
* version 3 of the License, or (at your option) any later version.
10+
*
11+
* This program is distributed in the hope that it will be useful,
12+
* but WITHOUT ANY WARRANTY; without even the implied warranty of
13+
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
14+
* Lesser General Public License for more details.
15+
*
16+
* You should have received a copy of the GNU Lesser General Public License
17+
* along with this program; if not, write to the Free Software Foundation,
18+
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
19+
*/
20+
21+
import { createContext, useContext } from 'react';
22+
23+
/**
24+
* Context for providing CSP nonce value to components that need it.
25+
* This is used internally by Echoes components to access the nonce
26+
* configured in the EchoesProvider.
27+
*/
28+
export const NonceContext = createContext<string | undefined>(undefined);
29+
30+
/**
31+
* Hook to access the CSP nonce from the EchoesProvider.
32+
* Components that need to apply nonces to inline styles for CSP compliance
33+
* can use this hook to retrieve the globally configured nonce value.
34+
*
35+
* @returns The nonce string if configured, undefined otherwise
36+
*/
37+
export function useNonce(): string | undefined {
38+
return useContext(NonceContext);
39+
}

src/components/echoes-provider/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,3 +19,4 @@
1919
*/
2020

2121
export { EchoesProvider, type EchoesProviderProps } from './EchoesProvider';
22+
export { useNonce } from './NonceContext';

src/components/spotlight/Spotlight.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import { PropsWithChildren } from 'react';
2222
import { useIntl } from 'react-intl';
2323
import ReactJoyride, { TooltipRenderProps } from 'react-joyride';
24+
import { useNonce } from '../echoes-provider/NonceContext';
2425
import { SpotlightModalForStep } from './SpotlightModalForStep';
2526
import { SpotlightModalPlacement, SpotlightProps, SpotlightStep } from './SpotlightTypes';
2627

@@ -97,6 +98,7 @@ export function Spotlight(props: Readonly<SpotlightProps>) {
9798
image,
9899
isRunning = true,
99100
nextLabel,
101+
nonce: nonceProp,
100102
shouldDisableOverlayClose,
101103
skipLabel,
102104
stepIndex,
@@ -105,6 +107,10 @@ export function Spotlight(props: Readonly<SpotlightProps>) {
105107
} = props;
106108

107109
const intl = useIntl();
110+
const nonceFromContext = useNonce();
111+
112+
// Use prop nonce if provided, otherwise fall back to context nonce
113+
const nonce = nonceProp ?? nonceFromContext;
108114

109115
const labels = {
110116
back: intl.formatMessage({
@@ -152,6 +158,7 @@ export function Spotlight(props: Readonly<SpotlightProps>) {
152158
nextLabelWithProgress: nextLabel ?? labels.next,
153159
skip: skipLabel ?? labels.skip,
154160
}}
161+
nonce={nonce}
155162
run={isRunning}
156163
stepIndex={stepIndex}
157164
/*--------------------------------------------------------------------------------------------

src/components/spotlight/SpotlightTypes.tsx

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -122,6 +122,14 @@ export interface SpotlightProps {
122122
*/
123123
nextLabel?: string;
124124

125+
/**
126+
* A nonce value for inline styles (Content Security Policy - CSP) (optional).
127+
* In most cases, you should configure the nonce once in the EchoesProvider
128+
* instead of passing it to individual components. This prop is only needed
129+
* if you need to override the global nonce for this specific Spotlight instance.
130+
*/
131+
nonce?: string;
132+
125133
/**
126134
* Shoud we disable closing the spotlight when clicking the overlay?
127135
* Defaults to false when there's only one step, true otherwise (optional)

0 commit comments

Comments
 (0)