Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(content-security-policies): add nonce and trusted types support #191

Merged
merged 12 commits into from
Sep 29, 2023
Merged
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 && (
lsagetlethias marked this conversation as resolved.
Show resolved Hide resolved
<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