Skip to content

Commit 48b6405

Browse files
authored
Merge pull request #191 from codegouvfr/feature/nonce-trusted-types-csp
feat(content-security-policies): add nonce and trusted types support
2 parents 30b16b3 + 7b0cb34 commit 48b6405

File tree

25 files changed

+607
-286
lines changed

25 files changed

+607
-286
lines changed

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ A few projects that use `@codegouvfr/react-dsfr`.
110110

111111
- https://code.gouv.fr/sill
112112
- https://immersion-facile.beta.gouv.fr/
113+
- https://egapro.travail.gouv.fr/
114+
- https://maisondelautisme.gouv.fr/
113115
- https://refugies.info/fr
114116
- https://www.mediateur-public.fr/
115117
- https://signal.conso.gouv.fr/

package.json

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "@codegouvfr/react-dsfr",
3-
"version": "0.76.4",
3+
"version": "0.77.0-rc.1",
44
"description": "French State Design System React integration library",
55
"repository": {
66
"type": "git",
@@ -97,7 +97,7 @@
9797
"husky": "^4.3.8",
9898
"lint-staged": "^11.0.0",
9999
"memoizee": "^0.4.15",
100-
"next": "13.4.4",
100+
"next": "13.5.1",
101101
"oppa": "^0.4.0",
102102
"parse-numeric-range": "^1.3.0",
103103
"powerhooks": "^0.22.0",
@@ -107,7 +107,7 @@
107107
"remixicon": "^3.2.0",
108108
"storybook-dark-mode": "^1.1.2",
109109
"ts-node": "^10.9.1",
110-
"tss-react": "^4.9.0",
110+
"tss-react": "^4.9.1",
111111
"type-route": "^1.0.1",
112112
"typescript": "^4.9.1",
113113
"vitest": "^0.24.3"

src/next-appdir/DsfrHead.tsx

Lines changed: 42 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,13 @@ import { getScriptToRunAsap } from "../useIsDark/scriptToRunAsap";
88
import { fontUrlByFileBasename } from "./zz_internal/fontUrlByFileBasename";
99
import { getDefaultColorSchemeServerSide } from "./zz_internal/defaultColorScheme";
1010
import { setLink, type RegisteredLinkProps } from "../link";
11+
import { assert } from "tsafe/assert";
1112
//NOTE: As of now there is no way to enforce ordering in Next Appdir
1213
//See: https://github.com/vercel/next.js/issues/16630
1314
// @import url(...) doesn't work. Using Sass and @use is our last resort.
1415
import "../assets/dsfr_plus_icons.scss";
16+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
17+
import { type startReactDsfr } from "./zz_internal/start";
1518

1619
export type DsfrHeadProps = {
1720
/** If not provided no fonts are preloaded.
@@ -20,12 +23,32 @@ export type DsfrHeadProps = {
2023
preloadFonts?: (keyof typeof fontUrlByFileBasename)[];
2124
/** Default: <a /> */
2225
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType<React.FC>;
26+
/**
27+
* When set, the value will be used as the nonce attribute of subsequent script tags.
28+
*
29+
* Don't forget to add `doCheckNonce: true` in {@link startReactDsfr} options.
30+
*
31+
* @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
32+
*/
33+
nonce?: string;
34+
/**
35+
* Enable Trusted Types with a custom policy name.
36+
*
37+
* Don't forget to add `trustedTypesPolicyName` in {@link startReactDsfr} options.
38+
*
39+
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
40+
* @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
41+
* @default "react-dsfr"
42+
*/
43+
trustedTypesPolicyName?: string;
2344
};
2445

2546
const isProduction = process.env.NODE_ENV !== "development";
2647

2748
export function DsfrHead(props: DsfrHeadProps) {
28-
const { preloadFonts = [], Link } = props;
49+
const { preloadFonts = [], Link, nonce, trustedTypesPolicyName = "react-dsfr" } = props;
50+
51+
assert(nonce !== "", "nonce cannot be an empty string");
2952

3053
const defaultColorScheme = getDefaultColorSchemeServerSide();
3154

@@ -53,9 +76,25 @@ export function DsfrHead(props: DsfrHeadProps) {
5376
<link rel="apple-touch-icon" href={getAssetUrl(AppleTouchIcon)} />
5477
<link rel="icon" href={getAssetUrl(FaviconSvg)} type="image/svg+xml" />
5578
<link rel="shortcut icon" href={getAssetUrl(FaviconIco)} type="image/x-icon" />
56-
{isProduction && (
79+
<script
80+
suppressHydrationWarning
81+
nonce={nonce}
82+
dangerouslySetInnerHTML={{
83+
"__html": getScriptToRunAsap({
84+
defaultColorScheme,
85+
nonce,
86+
trustedTypesPolicyName
87+
})
88+
}}
89+
/>
90+
{nonce !== undefined && (
5791
<script
58-
dangerouslySetInnerHTML={{ "__html": getScriptToRunAsap(defaultColorScheme) }}
92+
suppressHydrationWarning
93+
key="nonce-setter"
94+
nonce={nonce}
95+
dangerouslySetInnerHTML={{
96+
__html: `window.ssrNonce = "${nonce}";`
97+
}}
5998
/>
6099
)}
61100
</>

src/next-appdir/zz_internal/start.ts

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import type { RegisteredLinkProps } from "../../link";
44
import { setLink } from "../../link";
55
import { type DefaultColorScheme, setDefaultColorSchemeClientSide } from "./defaultColorScheme";
66
import { isBrowser } from "../../tools/isBrowser";
7+
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
8+
import { type DsfrHead } from "../DsfrHead";
79

810
let isAfterFirstEffect = false;
911
const actions: (() => void)[] = [];
@@ -14,8 +16,41 @@ export function startReactDsfr(params: {
1416
verbose?: boolean;
1517
/** Default: <a /> */
1618
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType<React.FC>;
19+
/**
20+
* When true, the nonce of the script tag will be checked, fetched from {@link DsfrHead} component and injected in react-dsfr scripts.
21+
*
22+
* @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
23+
* @default false
24+
*/
25+
doCheckNonce?: boolean;
26+
/**
27+
* Enable Trusted Types with a custom policy name.
28+
*
29+
* Don't forget to also add the policy name in {@link DsfrHead} component.
30+
*
31+
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
32+
*
33+
* For example:
34+
* ```txt
35+
* With a policy name of "react-dsfr":
36+
* Content-Security-Policy:
37+
* require-trusted-types-for 'script';
38+
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
39+
* ```
40+
*
41+
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
42+
* @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
43+
* @default "react-dsfr"
44+
*/
45+
trustedTypesPolicyName?: string;
1746
}) {
18-
const { defaultColorScheme, verbose = false, Link } = params;
47+
const {
48+
defaultColorScheme,
49+
verbose = false,
50+
Link,
51+
doCheckNonce = false,
52+
trustedTypesPolicyName = "react-dsfr"
53+
} = params;
1954

2055
setDefaultColorSchemeClientSide({ defaultColorScheme });
2156

@@ -27,6 +62,8 @@ export function startReactDsfr(params: {
2762
start({
2863
defaultColorScheme,
2964
verbose,
65+
doCheckNonce,
66+
trustedTypesPolicyName,
3067
"nextParams": {
3168
"doPersistDarkModePreferenceWithCookie": false,
3269
"registerEffectAction": action => {

src/next-pagesdir.tsx

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,23 @@ export type CreateNextDsfrIntegrationApiParams = {
4141
doPersistDarkModePreferenceWithCookie?: boolean;
4242
/** Default: ()=> "fr" */
4343
useLang?: () => string;
44+
/**
45+
* Enable Trusted Types with a custom policy name.
46+
*
47+
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
48+
*
49+
* For example:
50+
* ```txt
51+
* With a policy name of "react-dsfr":
52+
* Content-Security-Policy:
53+
* require-trusted-types-for 'script';
54+
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
55+
* ```
56+
*
57+
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
58+
* @default "react-dsfr"
59+
*/
60+
trustedTypesPolicyName?: string;
4461
};
4562

4663
function readIsDarkInCookie(cookie: string) {
@@ -88,7 +105,8 @@ export function createNextDsfrIntegrationApi(
88105
Link,
89106
preloadFonts = [],
90107
doPersistDarkModePreferenceWithCookie = false,
91-
useLang
108+
useLang,
109+
trustedTypesPolicyName = "react-dsfr"
92110
} = params;
93111

94112
let isAfterFirstEffect = false;
@@ -106,6 +124,8 @@ export function createNextDsfrIntegrationApi(
106124
start({
107125
defaultColorScheme,
108126
verbose,
127+
"doCheckNonce": false,
128+
trustedTypesPolicyName,
109129
"nextParams": {
110130
doPersistDarkModePreferenceWithCookie,
111131
"registerEffectAction": action => {
@@ -177,10 +197,14 @@ export function createNextDsfrIntegrationApi(
177197
/>
178198
</>
179199
)}
180-
{isProduction && (
200+
{isProduction && !isBrowser && (
181201
<script
182202
dangerouslySetInnerHTML={{
183-
"__html": getScriptToRunAsap(defaultColorScheme)
203+
"__html": getScriptToRunAsap({
204+
defaultColorScheme,
205+
trustedTypesPolicyName,
206+
"nonce": undefined
207+
})
184208
}}
185209
/>
186210
)}

src/spa.ts

Lines changed: 42 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import type { RegisterLink, RegisteredLinkProps } from "./link";
44
import { setLink } from "./link";
55
import { setUseLang } from "./i18n";
66
import type { ColorScheme } from "./useIsDark";
7+
import { assert } from "tsafe/assert";
78

89
export type { RegisterLink, RegisteredLinkProps };
910

@@ -15,8 +16,39 @@ export function startReactDsfr(params: {
1516
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType<React.FC>;
1617
/** Default: ()=> "fr" */
1718
useLang?: () => string;
19+
/**
20+
* When set, the value will be used as the nonce attribute of subsequent script tags.
21+
*
22+
* @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
23+
*/
24+
nonce?: string;
25+
/**
26+
* Enable Trusted Types with a custom policy name.
27+
*
28+
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
29+
*
30+
* For example:
31+
* ```txt
32+
* With a policy name of "react-dsfr":
33+
* Content-Security-Policy:
34+
* require-trusted-types-for 'script';
35+
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
36+
* ```
37+
*
38+
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
39+
* @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
40+
* @default "react-dsfr"
41+
*/
42+
trustedTypesPolicyName?: string;
1843
}) {
19-
const { defaultColorScheme, verbose = false, Link, useLang } = params;
44+
const {
45+
defaultColorScheme,
46+
verbose = false,
47+
Link,
48+
useLang,
49+
nonce,
50+
trustedTypesPolicyName = "react-dsfr"
51+
} = params;
2052

2153
if (Link !== undefined) {
2254
setLink({ Link });
@@ -26,9 +58,18 @@ export function startReactDsfr(params: {
2658
setUseLang({ useLang });
2759
}
2860

61+
assert(nonce !== "", "nonce cannot be an empty string");
62+
63+
const doCheckNonce = nonce !== undefined;
64+
if (doCheckNonce) {
65+
window.ssrNonce = nonce;
66+
}
67+
2968
start({
3069
defaultColorScheme,
3170
verbose,
71+
doCheckNonce,
72+
trustedTypesPolicyName,
3273
"nextParams": undefined
3374
});
3475
}

src/start.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,12 +13,15 @@ type Params = {
1313
registerEffectAction: (effect: () => void) => void;
1414
}
1515
| undefined;
16+
doCheckNonce: boolean;
17+
trustedTypesPolicyName: string;
1618
};
1719

1820
let isStarted = false;
1921

2022
export async function start(params: Params) {
21-
const { defaultColorScheme, verbose, nextParams } = params;
23+
const { defaultColorScheme, verbose, nextParams, doCheckNonce, trustedTypesPolicyName } =
24+
params;
2225

2326
assert(isBrowser);
2427

@@ -35,7 +38,9 @@ export async function start(params: Params) {
3538
"colorSchemeExplicitlyProvidedAsParameter": defaultColorScheme,
3639
"doPersistDarkModePreferenceWithCookie":
3740
nextParams === undefined ? false : nextParams.doPersistDarkModePreferenceWithCookie,
38-
registerEffectAction
41+
registerEffectAction,
42+
doCheckNonce,
43+
trustedTypesPolicyName
3944
});
4045

4146
// @ts-expect-error

src/useIsDark/client.ts

Lines changed: 25 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -98,11 +98,15 @@ export function startClientSideIsDarkLogic(params: {
9898
registerEffectAction: (action: () => void) => void;
9999
doPersistDarkModePreferenceWithCookie: boolean;
100100
colorSchemeExplicitlyProvidedAsParameter: ColorScheme | "system";
101+
doCheckNonce: boolean;
102+
trustedTypesPolicyName: string;
101103
}) {
102104
const {
103105
doPersistDarkModePreferenceWithCookie,
104106
registerEffectAction,
105-
colorSchemeExplicitlyProvidedAsParameter
107+
colorSchemeExplicitlyProvidedAsParameter,
108+
doCheckNonce = false,
109+
trustedTypesPolicyName
106110
} = params;
107111

108112
const { clientSideIsDark, ssrWasPerformedWithIsDark: ssrWasPerformedWithIsDark_ } = ((): {
@@ -115,8 +119,7 @@ export function startClientSideIsDarkLogic(params: {
115119
return {
116120
"clientSideIsDark": isDarkFromHtmlAttribute,
117121
"ssrWasPerformedWithIsDark":
118-
((window as any).ssrWasPerformedWithIsDark as boolean | undefined) ??
119-
isDarkFromHtmlAttribute
122+
window.ssrWasPerformedWithIsDark ?? isDarkFromHtmlAttribute
120123
};
121124
}
122125

@@ -174,6 +177,14 @@ export function startClientSideIsDarkLogic(params: {
174177

175178
ssrWasPerformedWithIsDark = ssrWasPerformedWithIsDark_;
176179

180+
const trustedTypes = (window as any).trustedTypes;
181+
const sanitizer =
182+
typeof trustedTypes !== "undefined"
183+
? trustedTypes.createPolicy(trustedTypesPolicyName, { createHTML: (s: string) => s })
184+
: {
185+
createHTML: (s: string) => s
186+
};
187+
177188
$clientSideIsDark.current = clientSideIsDark;
178189

179190
[data_fr_scheme, data_fr_theme].forEach(attr =>
@@ -222,13 +233,23 @@ export function startClientSideIsDarkLogic(params: {
222233

223234
{
224235
const setRootColorScheme = (isDark: boolean) => {
236+
const nonce = window.ssrNonce;
237+
if (doCheckNonce && !nonce) {
238+
return;
239+
}
225240
document.getElementById(rootColorSchemeStyleTagId)?.remove();
226241

227242
const element = document.createElement("style");
228243

229244
element.id = rootColorSchemeStyleTagId;
230245

231-
element.innerHTML = `:root { color-scheme: ${isDark ? "dark" : "light"}; }`;
246+
if (nonce) {
247+
element.setAttribute("nonce", nonce);
248+
}
249+
250+
element.innerHTML = sanitizer.createHTML(
251+
`:root { color-scheme: ${isDark ? "dark" : "light"}; }`
252+
);
232253

233254
document.head.appendChild(element);
234255
};

0 commit comments

Comments
 (0)