Skip to content

Commit

Permalink
Merge pull request #191 from codegouvfr/feature/nonce-trusted-types-csp
Browse files Browse the repository at this point in the history
feat(content-security-policies): add nonce and trusted types support
  • Loading branch information
garronej committed Sep 29, 2023
2 parents 30b16b3 + 7b0cb34 commit 48b6405
Show file tree
Hide file tree
Showing 25 changed files with 607 additions and 286 deletions.
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,8 @@ A few projects that use `@codegouvfr/react-dsfr`.

- https://code.gouv.fr/sill
- https://immersion-facile.beta.gouv.fr/
- https://egapro.travail.gouv.fr/
- https://maisondelautisme.gouv.fr/
- https://refugies.info/fr
- https://www.mediateur-public.fr/
- https://signal.conso.gouv.fr/
Expand Down
6 changes: 3 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@codegouvfr/react-dsfr",
"version": "0.76.4",
"version": "0.77.0-rc.1",
"description": "French State Design System React integration library",
"repository": {
"type": "git",
Expand Down Expand Up @@ -97,7 +97,7 @@
"husky": "^4.3.8",
"lint-staged": "^11.0.0",
"memoizee": "^0.4.15",
"next": "13.4.4",
"next": "13.5.1",
"oppa": "^0.4.0",
"parse-numeric-range": "^1.3.0",
"powerhooks": "^0.22.0",
Expand All @@ -107,7 +107,7 @@
"remixicon": "^3.2.0",
"storybook-dark-mode": "^1.1.2",
"ts-node": "^10.9.1",
"tss-react": "^4.9.0",
"tss-react": "^4.9.1",
"type-route": "^1.0.1",
"typescript": "^4.9.1",
"vitest": "^0.24.3"
Expand Down
45 changes: 42 additions & 3 deletions src/next-appdir/DsfrHead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,13 @@ import { getScriptToRunAsap } from "../useIsDark/scriptToRunAsap";
import { fontUrlByFileBasename } from "./zz_internal/fontUrlByFileBasename";
import { getDefaultColorSchemeServerSide } from "./zz_internal/defaultColorScheme";
import { setLink, type RegisteredLinkProps } from "../link";
import { assert } from "tsafe/assert";
//NOTE: As of now there is no way to enforce ordering in Next Appdir
//See: https://github.com/vercel/next.js/issues/16630
// @import url(...) doesn't work. Using Sass and @use is our last resort.
import "../assets/dsfr_plus_icons.scss";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
import { type startReactDsfr } from "./zz_internal/start";

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

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

export function DsfrHead(props: DsfrHeadProps) {
const { preloadFonts = [], Link } = props;
const { preloadFonts = [], Link, nonce, trustedTypesPolicyName = "react-dsfr" } = props;

assert(nonce !== "", "nonce cannot be an empty string");

const defaultColorScheme = getDefaultColorSchemeServerSide();

Expand Down Expand Up @@ -53,9 +76,25 @@ export function DsfrHead(props: DsfrHeadProps) {
<link rel="apple-touch-icon" href={getAssetUrl(AppleTouchIcon)} />
<link rel="icon" href={getAssetUrl(FaviconSvg)} type="image/svg+xml" />
<link rel="shortcut icon" href={getAssetUrl(FaviconIco)} type="image/x-icon" />
{isProduction && (
<script
suppressHydrationWarning
nonce={nonce}
dangerouslySetInnerHTML={{
"__html": getScriptToRunAsap({
defaultColorScheme,
nonce,
trustedTypesPolicyName
})
}}
/>
{nonce !== undefined && (
<script
dangerouslySetInnerHTML={{ "__html": getScriptToRunAsap(defaultColorScheme) }}
suppressHydrationWarning
key="nonce-setter"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ssrNonce = "${nonce}";`
}}
/>
)}
</>
Expand Down
39 changes: 38 additions & 1 deletion src/next-appdir/zz_internal/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import type { RegisteredLinkProps } from "../../link";
import { setLink } from "../../link";
import { type DefaultColorScheme, setDefaultColorSchemeClientSide } from "./defaultColorScheme";
import { isBrowser } from "../../tools/isBrowser";
// eslint-disable-next-line @typescript-eslint/no-unused-vars -- used in doc
import { type DsfrHead } from "../DsfrHead";

let isAfterFirstEffect = false;
const actions: (() => void)[] = [];
Expand All @@ -14,8 +16,41 @@ export function startReactDsfr(params: {
verbose?: boolean;
/** Default: <a /> */
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType<React.FC>;
/**
* When true, the nonce of the script tag will be checked, fetched from {@link DsfrHead} component and injected in react-dsfr scripts.
*
* @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
* @default false
*/
doCheckNonce?: boolean;
/**
* Enable Trusted Types with a custom policy name.
*
* Don't forget to also add the policy name in {@link DsfrHead} component.
*
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
*
* For example:
* ```txt
* With a policy name of "react-dsfr":
* Content-Security-Policy:
* require-trusted-types-for 'script';
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
* ```
*
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
* @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
* @default "react-dsfr"
*/
trustedTypesPolicyName?: string;
}) {
const { defaultColorScheme, verbose = false, Link } = params;
const {
defaultColorScheme,
verbose = false,
Link,
doCheckNonce = false,
trustedTypesPolicyName = "react-dsfr"
} = params;

setDefaultColorSchemeClientSide({ defaultColorScheme });

Expand All @@ -27,6 +62,8 @@ export function startReactDsfr(params: {
start({
defaultColorScheme,
verbose,
doCheckNonce,
trustedTypesPolicyName,
"nextParams": {
"doPersistDarkModePreferenceWithCookie": false,
"registerEffectAction": action => {
Expand Down
30 changes: 27 additions & 3 deletions src/next-pagesdir.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,23 @@ export type CreateNextDsfrIntegrationApiParams = {
doPersistDarkModePreferenceWithCookie?: boolean;
/** Default: ()=> "fr" */
useLang?: () => string;
/**
* Enable Trusted Types with a custom policy name.
*
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
*
* For example:
* ```txt
* With a policy name of "react-dsfr":
* Content-Security-Policy:
* require-trusted-types-for 'script';
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
* ```
*
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
* @default "react-dsfr"
*/
trustedTypesPolicyName?: string;
};

function readIsDarkInCookie(cookie: string) {
Expand Down Expand Up @@ -88,7 +105,8 @@ export function createNextDsfrIntegrationApi(
Link,
preloadFonts = [],
doPersistDarkModePreferenceWithCookie = false,
useLang
useLang,
trustedTypesPolicyName = "react-dsfr"
} = params;

let isAfterFirstEffect = false;
Expand All @@ -106,6 +124,8 @@ export function createNextDsfrIntegrationApi(
start({
defaultColorScheme,
verbose,
"doCheckNonce": false,
trustedTypesPolicyName,
"nextParams": {
doPersistDarkModePreferenceWithCookie,
"registerEffectAction": action => {
Expand Down Expand Up @@ -177,10 +197,14 @@ export function createNextDsfrIntegrationApi(
/>
</>
)}
{isProduction && (
{isProduction && !isBrowser && (
<script
dangerouslySetInnerHTML={{
"__html": getScriptToRunAsap(defaultColorScheme)
"__html": getScriptToRunAsap({
defaultColorScheme,
trustedTypesPolicyName,
"nonce": undefined
})
}}
/>
)}
Expand Down
43 changes: 42 additions & 1 deletion src/spa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RegisterLink, RegisteredLinkProps } from "./link";
import { setLink } from "./link";
import { setUseLang } from "./i18n";
import type { ColorScheme } from "./useIsDark";
import { assert } from "tsafe/assert";

export type { RegisterLink, RegisteredLinkProps };

Expand All @@ -15,8 +16,39 @@ export function startReactDsfr(params: {
Link?: (props: RegisteredLinkProps & { children: ReactNode }) => ReturnType<React.FC>;
/** Default: ()=> "fr" */
useLang?: () => string;
/**
* When set, the value will be used as the nonce attribute of subsequent script tags.
*
* @see https://developer.mozilla.org/fr/docs/Web/HTML/Global_attributes/nonce
*/
nonce?: string;
/**
* Enable Trusted Types with a custom policy name.
*
* `<trustedTypesPolicyName>` and `<trustedTypesPolicyName>-asap` should be set in your Content-Security-Policy header.
*
* For example:
* ```txt
* With a policy name of "react-dsfr":
* Content-Security-Policy:
* require-trusted-types-for 'script';
* trusted-types react-dsfr react-dsfr-asap nextjs nextjs#bundler;
* ```
*
* @see https://developer.mozilla.org/fr/docs/Web/HTTP/Headers/Content-Security-Policy/trusted-types
* @see {@link DEFAULT_TRUSTED_TYPES_POLICY_NAME}
* @default "react-dsfr"
*/
trustedTypesPolicyName?: string;
}) {
const { defaultColorScheme, verbose = false, Link, useLang } = params;
const {
defaultColorScheme,
verbose = false,
Link,
useLang,
nonce,
trustedTypesPolicyName = "react-dsfr"
} = params;

if (Link !== undefined) {
setLink({ Link });
Expand All @@ -26,9 +58,18 @@ export function startReactDsfr(params: {
setUseLang({ useLang });
}

assert(nonce !== "", "nonce cannot be an empty string");

const doCheckNonce = nonce !== undefined;
if (doCheckNonce) {
window.ssrNonce = nonce;
}

start({
defaultColorScheme,
verbose,
doCheckNonce,
trustedTypesPolicyName,
"nextParams": undefined
});
}
Expand Down
9 changes: 7 additions & 2 deletions src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,15 @@ type Params = {
registerEffectAction: (effect: () => void) => void;
}
| undefined;
doCheckNonce: boolean;
trustedTypesPolicyName: string;
};

let isStarted = false;

export async function start(params: Params) {
const { defaultColorScheme, verbose, nextParams } = params;
const { defaultColorScheme, verbose, nextParams, doCheckNonce, trustedTypesPolicyName } =
params;

assert(isBrowser);

Expand All @@ -35,7 +38,9 @@ export async function start(params: Params) {
"colorSchemeExplicitlyProvidedAsParameter": defaultColorScheme,
"doPersistDarkModePreferenceWithCookie":
nextParams === undefined ? false : nextParams.doPersistDarkModePreferenceWithCookie,
registerEffectAction
registerEffectAction,
doCheckNonce,
trustedTypesPolicyName
});

// @ts-expect-error
Expand Down
29 changes: 25 additions & 4 deletions src/useIsDark/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -98,11 +98,15 @@ export function startClientSideIsDarkLogic(params: {
registerEffectAction: (action: () => void) => void;
doPersistDarkModePreferenceWithCookie: boolean;
colorSchemeExplicitlyProvidedAsParameter: ColorScheme | "system";
doCheckNonce: boolean;
trustedTypesPolicyName: string;
}) {
const {
doPersistDarkModePreferenceWithCookie,
registerEffectAction,
colorSchemeExplicitlyProvidedAsParameter
colorSchemeExplicitlyProvidedAsParameter,
doCheckNonce = false,
trustedTypesPolicyName
} = params;

const { clientSideIsDark, ssrWasPerformedWithIsDark: ssrWasPerformedWithIsDark_ } = ((): {
Expand All @@ -115,8 +119,7 @@ export function startClientSideIsDarkLogic(params: {
return {
"clientSideIsDark": isDarkFromHtmlAttribute,
"ssrWasPerformedWithIsDark":
((window as any).ssrWasPerformedWithIsDark as boolean | undefined) ??
isDarkFromHtmlAttribute
window.ssrWasPerformedWithIsDark ?? isDarkFromHtmlAttribute
};
}

Expand Down Expand Up @@ -174,6 +177,14 @@ export function startClientSideIsDarkLogic(params: {

ssrWasPerformedWithIsDark = ssrWasPerformedWithIsDark_;

const trustedTypes = (window as any).trustedTypes;
const sanitizer =
typeof trustedTypes !== "undefined"
? trustedTypes.createPolicy(trustedTypesPolicyName, { createHTML: (s: string) => s })
: {
createHTML: (s: string) => s
};

$clientSideIsDark.current = clientSideIsDark;

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

{
const setRootColorScheme = (isDark: boolean) => {
const nonce = window.ssrNonce;
if (doCheckNonce && !nonce) {
return;
}
document.getElementById(rootColorSchemeStyleTagId)?.remove();

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

element.id = rootColorSchemeStyleTagId;

element.innerHTML = `:root { color-scheme: ${isDark ? "dark" : "light"}; }`;
if (nonce) {
element.setAttribute("nonce", nonce);
}

element.innerHTML = sanitizer.createHTML(
`:root { color-scheme: ${isDark ? "dark" : "light"}; }`
);

document.head.appendChild(element);
};
Expand Down

0 comments on commit 48b6405

Please sign in to comment.