From 1f8acd7b6ab0aa78e2a882e59cf69887109504be Mon Sep 17 00:00:00 2001 From: Bret Little Date: Wed, 30 Aug 2023 14:32:41 -0400 Subject: [PATCH] Add a content security policy implementation and update templates (#1235) --- .changeset/two-carrots-relax.md | 7 + examples/customer-api/app/entry.server.tsx | 9 +- examples/customer-api/app/root.tsx | 8 +- examples/express/app/entry.server.tsx | 33 ++- examples/express/app/root.tsx | 8 +- package-lock.json | 15 ++ .../docs/generated/generated_docs_data.json | 210 ++++++++++++++++++ packages/hydrogen/package.json | 3 +- packages/hydrogen/src/csp/Script.doc.ts | 48 ++++ packages/hydrogen/src/csp/Script.example.jsx | 32 +++ packages/hydrogen/src/csp/Script.example.tsx | 32 +++ packages/hydrogen/src/csp/Script.test.ts | 29 +++ packages/hydrogen/src/csp/Script.tsx | 11 + .../csp/createContentSecurityPolicy.doc.ts | 48 ++++ .../createContentSecurityPolicy.example.jsx | 46 ++++ .../createContentSecurityPolicy.example.tsx | 47 ++++ packages/hydrogen/src/csp/csp.test.ts | 80 +++++++ packages/hydrogen/src/csp/csp.ts | 88 ++++++++ packages/hydrogen/src/csp/nonce.ts | 17 ++ packages/hydrogen/src/csp/useNonce.doc.ts | 48 ++++ .../hydrogen/src/csp/useNonce.example.jsx | 30 +++ .../hydrogen/src/csp/useNonce.example.tsx | 30 +++ packages/hydrogen/src/index.ts | 3 + templates/demo-store/app/entry.server.tsx | 8 +- templates/demo-store/app/root.tsx | 15 +- templates/hello-world/app/entry.server.tsx | 9 +- templates/hello-world/app/root.tsx | 8 +- templates/skeleton/app/entry.server.tsx | 10 +- templates/skeleton/app/root.tsx | 15 +- 29 files changed, 911 insertions(+), 36 deletions(-) create mode 100644 .changeset/two-carrots-relax.md create mode 100644 packages/hydrogen/src/csp/Script.doc.ts create mode 100644 packages/hydrogen/src/csp/Script.example.jsx create mode 100644 packages/hydrogen/src/csp/Script.example.tsx create mode 100644 packages/hydrogen/src/csp/Script.test.ts create mode 100644 packages/hydrogen/src/csp/Script.tsx create mode 100644 packages/hydrogen/src/csp/createContentSecurityPolicy.doc.ts create mode 100644 packages/hydrogen/src/csp/createContentSecurityPolicy.example.jsx create mode 100644 packages/hydrogen/src/csp/createContentSecurityPolicy.example.tsx create mode 100644 packages/hydrogen/src/csp/csp.test.ts create mode 100644 packages/hydrogen/src/csp/csp.ts create mode 100644 packages/hydrogen/src/csp/nonce.ts create mode 100644 packages/hydrogen/src/csp/useNonce.doc.ts create mode 100644 packages/hydrogen/src/csp/useNonce.example.jsx create mode 100644 packages/hydrogen/src/csp/useNonce.example.tsx diff --git a/.changeset/two-carrots-relax.md b/.changeset/two-carrots-relax.md new file mode 100644 index 0000000000..baa8149769 --- /dev/null +++ b/.changeset/two-carrots-relax.md @@ -0,0 +1,7 @@ +--- +'@shopify/cli-hydrogen': patch +'@shopify/create-hydrogen': patch +'@shopify/hydrogen': patch +--- + +Add functionality for creating a Content Security Policy. See the [guide on Content Security Policies](https://shopify.dev/docs/custom-storefronts/hydrogen/content-security-policy) for more details. diff --git a/examples/customer-api/app/entry.server.tsx b/examples/customer-api/app/entry.server.tsx index 05b0c9a587..61db2b9507 100644 --- a/examples/customer-api/app/entry.server.tsx +++ b/examples/customer-api/app/entry.server.tsx @@ -2,6 +2,7 @@ import type {EntryContext} from '@shopify/remix-oxygen'; import {RemixServer} from '@remix-run/react'; import isbot from 'isbot'; import {renderToReadableStream} from 'react-dom/server'; +import {createContentSecurityPolicy} from '@shopify/hydrogen'; export default async function handleRequest( request: Request, @@ -9,9 +10,14 @@ export default async function handleRequest( responseHeaders: Headers, remixContext: EntryContext, ) { + const {nonce, header, NonceProvider} = createContentSecurityPolicy(); + const body = await renderToReadableStream( - , + + + , { + nonce, signal: request.signal, onError(error) { // eslint-disable-next-line no-console @@ -26,6 +32,7 @@ export default async function handleRequest( } responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); return new Response(body, { headers: responseHeaders, status: responseStatusCode, diff --git a/examples/customer-api/app/root.tsx b/examples/customer-api/app/root.tsx index 5c29ed1b97..d1a90880ec 100644 --- a/examples/customer-api/app/root.tsx +++ b/examples/customer-api/app/root.tsx @@ -11,6 +11,7 @@ import { import type {Shop} from '@shopify/hydrogen/storefront-api-types'; import styles from './styles/app.css'; import favicon from '../public/favicon.svg'; +import {useNonce} from '@shopify/hydrogen'; export const links: LinksFunction = () => { return [ @@ -34,6 +35,7 @@ export async function loader({context}: LoaderArgs) { export default function App() { const data = useLoaderData(); + const nonce = useNonce(); const {name} = data.layout.shop; @@ -51,9 +53,9 @@ export default function App() { This is an example of Hydrogen using the Customer API

- - - + + + ); diff --git a/examples/express/app/entry.server.tsx b/examples/express/app/entry.server.tsx index a0b5629d53..b71bf93c44 100644 --- a/examples/express/app/entry.server.tsx +++ b/examples/express/app/entry.server.tsx @@ -11,6 +11,7 @@ import {Response} from '@remix-run/node'; import {RemixServer} from '@remix-run/react'; import isbot from 'isbot'; import {renderToPipeableStream} from 'react-dom/server'; +import {createContentSecurityPolicy} from '@shopify/hydrogen'; const ABORT_DELAY = 5_000; @@ -43,17 +44,23 @@ function handleBotRequest( remixContext: EntryContext, ) { return new Promise((resolve, reject) => { + const {nonce, header, NonceProvider} = createContentSecurityPolicy(); + const {pipe, abort} = renderToPipeableStream( - , + + + , { + nonce, onAllReady() { const body = new PassThrough(); responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); resolve( new Response(body, { @@ -84,18 +91,24 @@ function handleBrowserRequest( responseHeaders: Headers, remixContext: EntryContext, ) { + const {nonce, header, NonceProvider} = createContentSecurityPolicy(); + return new Promise((resolve, reject) => { const {pipe, abort} = renderToPipeableStream( - , + + + , { + nonce, onShellReady() { const body = new PassThrough(); responseHeaders.set('Content-Type', 'text/html'); + responseHeaders.set('Content-Security-Policy', header); resolve( new Response(body, { diff --git a/examples/express/app/root.tsx b/examples/express/app/root.tsx index 0609dfae1b..0ecbc22946 100644 --- a/examples/express/app/root.tsx +++ b/examples/express/app/root.tsx @@ -12,6 +12,7 @@ import type {Cart, Shop} from '@shopify/hydrogen/storefront-api-types'; import {Layout} from '~/components/Layout'; import styles from './styles/app.css'; import favicon from '../public/favicon.svg'; +import {useNonce} from '@shopify/hydrogen'; export const links: LinksFunction = () => { return [ @@ -64,6 +65,7 @@ export async function loader({context}: LoaderArgs) { export default function App() { const data = useLoaderData(); + const nonce = useNonce(); const {name, description} = data.layout.shop; @@ -79,9 +81,9 @@ export default function App() { - - - + + + ); diff --git a/package-lock.json b/package-lock.json index 3a26e523d0..48811503c7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10767,6 +10767,14 @@ ], "license": "MIT" }, + "node_modules/content-security-policy-builder": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.1.tgz", + "integrity": "sha512-Bga6d4W37VMAeu3QQOorIbfEr16CIUuC8ZzKz+GecFfnBUWoU2RUdk8DTeb+ihe2BeDCs6T4PRAxFKU7lLh0mA==", + "engines": { + "node": ">=4.0.0" + } + }, "node_modules/content-type": { "version": "1.0.5", "license": "MIT", @@ -25983,6 +25991,7 @@ "license": "MIT", "dependencies": { "@shopify/hydrogen-react": "2023.7.2", + "content-security-policy-builder": "^2.1.1", "react": "^18.2.0" }, "devDependencies": { @@ -31018,6 +31027,7 @@ "@shopify/generate-docs": "0.10.7", "@shopify/hydrogen-react": "2023.7.2", "@testing-library/react": "^14.0.0", + "content-security-policy-builder": "^2.1.1", "happy-dom": "^8.9.0", "react": "^18.2.0", "schema-dts": "^1.1.0", @@ -33600,6 +33610,11 @@ } } }, + "content-security-policy-builder": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/content-security-policy-builder/-/content-security-policy-builder-2.1.1.tgz", + "integrity": "sha512-Bga6d4W37VMAeu3QQOorIbfEr16CIUuC8ZzKz+GecFfnBUWoU2RUdk8DTeb+ihe2BeDCs6T4PRAxFKU7lLh0mA==" + }, "content-type": { "version": "1.0.5" }, diff --git a/packages/hydrogen/docs/generated/generated_docs_data.json b/packages/hydrogen/docs/generated/generated_docs_data.json index 85e90e20e1..a55896e3e8 100644 --- a/packages/hydrogen/docs/generated/generated_docs_data.json +++ b/packages/hydrogen/docs/generated/generated_docs_data.json @@ -8317,6 +8317,216 @@ } ] }, + { + "name": "Script", + "category": "components", + "isVisualComponent": false, + "related": [ + { + "name": "createContentSecurityPolicy", + "type": "utilities", + "url": "/docs/api/hydrogen/2023-07/utilities/createcontentsecuritypolicy" + }, + { + "name": "useNonce", + "type": "hooks", + "url": "/docs/api/hydrogen/2023-07/hooks/usenonce" + } + ], + "description": "Use the `Script` component to add third-party scripts to your app. It automatically adds a nonce attribute from your [content security policy](/docs/custom-storefronts/hydrogen/content-security-policy).", + "type": "component", + "defaultExample": { + "description": "I am the default example", + "codeblock": { + "tabs": [ + { + "title": "JavaScript", + "code": "import {\n Links,\n LiveReload,\n Meta,\n Outlet,\n Scripts,\n ScrollRestoration,\n} from '@remix-run/react';\nimport {useNonce, Script} from '@shopify/hydrogen';\nexport default function App() {\n const nonce = useNonce();\n\n return (\n \n \n \n \n \n \n \n \n \n {/* Note you don't need to pass a nonce to the script component \n because it's automatically added */}\n