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
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
18
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
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
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: 39 additions & 6 deletions src/next-appdir/DsfrHead.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { setLink, type RegisteredLinkProps } from "../link";
//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 +22,29 @@ 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 `checkNonce: 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
* @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 } = props;

const defaultColorScheme = getDefaultColorSchemeServerSide();

Expand Down Expand Up @@ -53,11 +72,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
dangerouslySetInnerHTML={{ "__html": getScriptToRunAsap(defaultColorScheme) }}
/>
)}
<script
suppressHydrationWarning
nonce={nonce}
dangerouslySetInnerHTML={{
"__html": getScriptToRunAsap({
defaultColorScheme,
nonce,
trustedTypesPolicyName
})
}}
/>
<script
suppressHydrationWarning
key="nonce-setter"
nonce={nonce}
dangerouslySetInnerHTML={{
__html: `window.ssrNonce = "${nonce}";`
}}
/>
</>
);
}
39 changes: 37 additions & 2 deletions 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,39 @@ 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
*/
checkNonce?: boolean;
lsagetlethias marked this conversation as resolved.
Show resolved Hide resolved
/**
* 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
* @default "react-dsfr"
*/
trustedTypesPolicyName?: string;
}) {
const { defaultColorScheme, verbose = false, Link } = params;
const {
defaultColorScheme,
verbose = false,
Link,
checkNonce,
lsagetlethias marked this conversation as resolved.
Show resolved Hide resolved
trustedTypesPolicyName
} = params;

setDefaultColorSchemeClientSide({ defaultColorScheme });

Expand All @@ -36,7 +69,9 @@ export function startReactDsfr(params: {
actions.push(action);
}
}
}
},
checkNonce,
trustedTypesPolicyName
});
}
}
Expand Down
27 changes: 24 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
} = params;

let isAfterFirstEffect = false;
Expand Down Expand Up @@ -177,10 +195,13 @@ 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
})
}}
/>
)}
Expand Down
41 changes: 39 additions & 2 deletions src/spa.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,8 +15,38 @@ 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
* @default "react-dsfr"
*/
trustedTypesPolicyName?: string;
}) {
const { defaultColorScheme, verbose = false, Link, useLang } = params;
const {
defaultColorScheme,
verbose = false,
Link,
useLang,
nonce,
trustedTypesPolicyName
} = params;

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

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

start({
defaultColorScheme,
verbose,
"nextParams": undefined
"nextParams": undefined,
checkNonce,
trustedTypesPolicyName
});
}

Expand Down
8 changes: 6 additions & 2 deletions src/start.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,14 @@ type Params = {
registerEffectAction: (effect: () => void) => void;
}
| undefined;
checkNonce?: boolean;
trustedTypesPolicyName?: string;
};

let isStarted = false;

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

assert(isBrowser);

Expand All @@ -35,7 +37,9 @@ export async function start(params: Params) {
"colorSchemeExplicitlyProvidedAsParameter": defaultColorScheme,
"doPersistDarkModePreferenceWithCookie":
nextParams === undefined ? false : nextParams.doPersistDarkModePreferenceWithCookie,
registerEffectAction
registerEffectAction,
checkNonce,
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";
checkNonce?: boolean;
trustedTypesPolicyName?: string;
}) {
const {
doPersistDarkModePreferenceWithCookie,
registerEffectAction,
colorSchemeExplicitlyProvidedAsParameter
colorSchemeExplicitlyProvidedAsParameter,
checkNonce,
trustedTypesPolicyName = "react-dsfr"
} = 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 (checkNonce && !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