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
58 changes: 58 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,29 @@ Add the following lines to your `package.json`:

and run `yarn build`

### Required Server Configuration

This addon requires a small modification to your customized `server.jsx` file to properly inject the CSP nonce into the Redux store.

After creating the Redux store in your route handler, add the following lines:

```javascript
// Create a new Redux store instance
const store = configureStore(initialState, history, api);

persistAuthToken(store, req);

// Set CSP nonce and header (from volto-csp addon)
const { setCspHeader } = require('@plone-collective/volto-csp/middleware');
const readCriticalCss =
config.settings.serverConfig.readCriticalCss || defaultReadCriticalCss;
setCspHeader(req, res, store, readCriticalCss(req));
```

**Why is this needed?**

The addon uses express middleware to generate a unique nonce for each request, but the nonce must be added to the Redux store *after* the store is created in the route handler. This ensures the nonce is available to the `Html` component during server-side rendering.

## Basic Usage

This addon is enabled by setting one or more of the `RAZZLE_CSP_...` headers.
Expand Down Expand Up @@ -52,6 +75,41 @@ Volto includes a number of inline scripts. Good practice is to not use the `unsa

If `RAZZLE_CSP_DEFAULT_SRC` is set and no value for `RAZZLE_CSP_SCRIPT_SRC` is set. Then the addon will add the `script-src` directive as a duplicate of the `default-src` with the hashed inline scripts added.

## Report URI Support

This addon supports sending CSP violation reports to a configured endpoint such as Sentry. See https://docs.sentry.io/platforms/javascript/security-policy-reporting/ and https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Content-Security-Policy#reporting_directives for more information.

To configure a reporting endpoint set `RAZZLE_CSP_REPORT` to the endpoint you want to send reports to.

The addon will automatically configure the necesary CSP headers to send error reports.

Note that at the time of writing `Content-Security-Policy-Report-Only` support is not available in the addon.

## Nonce Support

This addon automatically generates a unique cryptographic nonce for each request and includes it in the CSP header. The nonce is available through:

### Redux Store

The nonce is added to the Redux store via the `SET_CSP_NONCE` action and can be accessed at `state.csp.nonce`. This allows components to access the nonce during server-side rendering.

### Environment Variable

To enable nonce attributes on script and style tags, set:

```
RAZZLE_CSP_SET_NONCE=true
```

When enabled, the nonce will be:
- Automatically added to the CSP header as `'nonce-<value>'`
- Available via `window.__webpack_nonce__` for webpack's style-loader
- Included in script and style tags during server-side rendering

### Development Mode Support

In development mode, this addon includes a patched version of webpack's style-loader runtime that properly reads the nonce from `window.__webpack_nonce__`, ensuring dynamically injected `<style>` tags include the nonce attribute.

## Critical CSS

Volto allows you to automatically include [critical css](https://2022.training.plone.org/effective-volto/development/criticalCSS.html) as inline styles.
Expand Down
7 changes: 5 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,9 +42,12 @@
"stylelint-prettier": "4.0.2"
},
"peerDependencies": {
"@plone/volto": "^17.0.0"
"@plone/volto": "^16.0.0 || ^17.0.0"
},
"browser": {
"crypto": false
"./src/helpers/ContentSecurityPolicy.jsx": false,
"./src/middleware.js": false,
"crypto": false,
"fs": false
}
}
26 changes: 26 additions & 0 deletions razzle.extend.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
const path = require('path');
const webpack = require('webpack');

const modify = (config, { target, dev }) => {

// Only modify client-side builds in development
if (target === 'web' && dev) {
console.log('[volto-csp] Adding CSP nonce support for style-loader');

// Use NormalModuleReplacementPlugin to replace style-loader's runtime
// with our patched version that checks window.__webpack_nonce__
config.plugins.push(
new webpack.NormalModuleReplacementPlugin(
/style-loader\/dist\/runtime\/injectStylesIntoStyleTag\.js$/,
path.resolve(__dirname, './src/injectStylesIntoStyleTag.js')
)
);
}

return config;
};

module.exports = {
plugins: (plugs) => plugs,
modify,
};
106 changes: 106 additions & 0 deletions src/customizations/volto/components/theme/Icon/Icon.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/**
* Icon component - Customized for CSP compliance
* @module components/theme/Icon/Icon
*/
import React from 'react';
import PropTypes from 'prop-types';
import { useSelector } from 'react-redux';

const defaultSize = '36px';

// Counter for generating unique class names
let iconCounter = 0;

/**
* Component to display an SVG as Icon.
* CSP-compliant version that uses nonce-based inline styles.
*/
const Icon = ({
name,
size,
color,
className,
title,
onClick,
style = {},
id,
ariaHidden,
}) => {
// Get nonce from Redux store
const nonce = useSelector((state) => state.csp?.nonce);

// Generate unique class name for this icon instance (stable across server/client)
const uniqueClass = React.useRef(`icon-${++iconCounter}`).current;

// Build dynamic styles
const dynamicStyles = {
height: size || defaultSize,
width: 'auto',
fill: color || 'currentColor',
...style,
};

// Convert styles object to CSS string
const styleString = Object.entries(dynamicStyles)
.map(([key, value]) => {
// Convert camelCase to kebab-case
const cssKey = key.replace(/[A-Z]/g, (match) => `-${match.toLowerCase()}`);
return `${cssKey}: ${value};`;
})
.join(' ');

const cssRule = `.${uniqueClass} { ${styleString} }`;

// Build final className
const iconClassName = [
'icon',
uniqueClass,
className,
].filter(Boolean).join(' ');

return (
<>
<style nonce={nonce} dangerouslySetInnerHTML={{ __html: cssRule }} />
<svg
xmlns={name.attributes && name.attributes.xmlns}
viewBox={name.attributes && name.attributes.viewBox}
className={iconClassName}
onClick={onClick}
id={id}
aria-hidden={ariaHidden}
dangerouslySetInnerHTML={{
__html: title ? `<title>${title}</title>${name.content}` : name.content,
}}
/>
</>
);
};

Icon.propTypes = {
name: PropTypes.shape({
xmlns: PropTypes.string,
viewBox: PropTypes.string,
content: PropTypes.string,
}).isRequired,
size: PropTypes.string,
color: PropTypes.string,
className: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func,
style: PropTypes.object,
id: PropTypes.string,
ariaHidden: PropTypes.bool,
};

Icon.defaultProps = {
size: defaultSize,
color: null,
className: null,
title: null,
onClick: null,
style: {},
id: null,
ariaHidden: undefined,
};

export default Icon;
96 changes: 96 additions & 0 deletions src/customizations/volto/components/theme/Icon/Icon.original.jsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
/**
* Icon component.
* @module components/theme/Icon/Icon
*/
import React from 'react';
import PropTypes from 'prop-types';

const defaultSize = '36px';

/**
* Component to display an SVG as Icon.
* @function Field
* @param {Object} props Component properties.
* @param {string} props.name Name source object.
* @param {string} props.size Size of the Icon (in px).
* @param {string} props.color Color of the Icon.
* @param {string} props.className className to add to the component.
* @param {string} props.title Title (a11y).
* @returns {string} Markup of the component.
*
* Use:
* drop icon to the icons folder ("src/icons")
* import svg into the file
* import this Icon component
* add icon component with name = your imported svg
*
* Reasoning:
* add a11y title to SVGs
* load svg via webpack for optimization
* Zero conf Inlined SVGs, as it is the best option when working with SVG
* see razzle.config.js
*
* for further reference see {@link https://kitconcept.com/blog/pastanaga-icon-system/ | here}
*/
const Icon = ({
name,
size,
color,
className,
title,
onClick,
style = {},
id,
ariaHidden,
}) => (
<svg
xmlns={name.attributes && name.attributes.xmlns}
viewBox={name.attributes && name.attributes.viewBox}
style={{
height: size,
width: 'auto',
fill: color || 'currentColor',
...style,
}}
className={className ? `icon ${className}` : 'icon'}
onClick={onClick}
id={id}
aria-hidden={ariaHidden}
dangerouslySetInnerHTML={{
__html: title ? `<title>${title}</title>${name.content}` : name.content,
}}
/>
);

/**
* Property types.
* @property {Object} propTypes Property types.
* @static
*/
Icon.propTypes = {
name: PropTypes.shape({
xmlns: PropTypes.string,
viewBox: PropTypes.string,
content: PropTypes.string,
}).isRequired,
size: PropTypes.string,
color: PropTypes.string,
className: PropTypes.string,
title: PropTypes.string,
onClick: PropTypes.func,
};

/**
* Default properties.
* @property {Object} defaultProps Default properties.
* @static
*/
Icon.defaultProps = {
size: defaultSize,
color: null,
className: null,
title: null,
onClick: null,
};

export default Icon;
Loading
Loading