diff --git a/details.md b/details.md new file mode 100644 index 00000000..fed259bb --- /dev/null +++ b/details.md @@ -0,0 +1,76 @@ +Use a [nonce](https://content-security-policy.com/nonce/) for the `script-src` directive of your Content Security Policy (CSP) to help prevent [cross-site scripting (XSS)](https://developer.mozilla.org/en-US/docs/Web/Security/Types_of_attacks#cross-site_scripting_xss) attacks. + +This extension deploys an edge function that adds a response header and transforms the HTML response body to contain a unique nonce on every request, along with an optional function to log CSP violations. + +Scripts that do not contain a matching `nonce` attribute, or that were not created from a trusted script (see [strict-dynamic](https://content-security-policy.com/strict-dynamic/)), will not be allowed to run. + +You can use this extension whether or not your site already has a CSP in place. If your site already has a CSP, the nonce will merge with your existing directives. + +🧩 This extension is installed and configured in the Netlify UI. If you prefer a configuration-as-code approach, check out the [@netlify/plugin-csp-nonce](https://www.npmjs.com/package/@netlify/plugin-csp-nonce) npm package. + +## Configuration options + +- #### `reportOnly` + + _Default: `true`_. + + When true, uses the `Content-Security-Policy-Report-Only` header instead of the `Content-Security-Policy` header. Setting `reportOnly` to `true` is useful for testing the CSP with real production traffic without actually blocking resources. Be sure to monitor your logging function to observe potential violations. + +- #### `reportUri` + + _Default: `undefined`_. + + The relative or absolute URL to report any violations. If left undefined, violations are reported to the `__csp-violations` function, which this extension deploys. If your site already has a `report-uri` directive defined in its CSP header, then that value will take precedence. + +- #### `unsafeEval` + + _Default: `true`._ + + When true, adds `'unsafe-eval'` to the CSP for easier adoption. Set to `false` to have a safer policy if your code and code dependencies does not use `eval()`. + +- #### `path` + + _Default: `/*`._ + + The glob expressions of path(s) that should invoke the CSP nonce edge function. Can be a string or array of strings. + +- #### `excludedPath` + + _Default: `[]`_ + + The glob expressions of path(s) that _should not_ invoke the CSP nonce edge function. Must be an array of strings. This value gets spread with common non-html filetype extensions (`*.css`, `*.js`, `*.svg`, etc). + +## Debugging + +### Limiting edge function invocations + +By default, the edge function that inserts the nonce will be invoked on all requests whose path + +- does not begin with `/.netlify/` +- does not end with common non-HTML filetype extensions + +To further limit invocations, add globs to the `excludedPath` configuration option that are specific to your site. + +Requests that invoke the nonce edge function will contain a `x-debug-csp-nonce: invoked` response header. Use this to determine if unwanted paths are invoking the edge function, and add those paths to the `excludedPath` array. + +Also, monitor the edge function logs in the Netlify UI. If the edge function is invoked but the response is not transformed, the request's path will be logged. + +### Not transforming as expected + +If your HTML does not contain the `nonce` attribute on the ` + + + + +
+ diff --git a/src/ui/index.ts b/src/ui/index.ts deleted file mode 100644 index dcef182c..00000000 --- a/src/ui/index.ts +++ /dev/null @@ -1,226 +0,0 @@ -import { NetlifyIntegrationUI } from "@netlify/sdk"; - -const integrationUI = new NetlifyIntegrationUI("dynamic-csp"); - -const surface = integrationUI.addSurface("integrations-settings"); - -const root = surface.addRoute("/"); - -root.onLoad( - async ({ picker, surfaceInputsData, surfaceRouteConfig, fetch }) => { - const res = await fetch(`get-config`); - - const { has_build_hook_enabled, cspConfig } = await res.json(); - - picker.getElementById("enable-build-hooks").display = has_build_hook_enabled - ? "hidden" - : "visible"; - picker.getElementById("disable-build-hooks").display = - has_build_hook_enabled ? "visible" : "hidden"; - - picker.getElementById("csp-configuration").display = has_build_hook_enabled - ? "visible" - : "hidden"; - - const reportOnly = - typeof cspConfig.reportOnly !== "undefined" - ? cspConfig.reportOnly.toString() - : "true"; - - const unsafeEval = - typeof cspConfig.unsafeEval !== "undefined" - ? cspConfig.unsafeEval.toString() - : "true"; - - surfaceInputsData["csp-configuration_reportOnly"] = reportOnly; - surfaceInputsData["csp-configuration_reportUri"] = - cspConfig.reportUri ?? ""; - surfaceInputsData["csp-configuration_unsafeEval"] = unsafeEval; - surfaceInputsData["csp-configuration_path"] = - cspConfig.path?.join("\n") ?? "/*"; - surfaceInputsData["csp-configuration_excludedPath"] = - cspConfig.excludedPath?.join("\n") ?? ""; - - return { - surfaceInputsData, - surfaceRouteConfig, - }; - }, -); - -const mapConfig = (surfaceInputsData: Record) => { - const { - "csp-configuration_reportOnly": configReportOnly = "true", - "csp-configuration_reportUri": reportUri = "", - "csp-configuration_unsafeEval": configUnsafeEval = "true", - "csp-configuration_path": configPath = "/*", - "csp-configuration_excludedPath": configExcludedPath = "", - } = surfaceInputsData; - - if ( - typeof configPath !== "string" || - typeof configExcludedPath !== "string" - ) { - throw new Error("Invalid config"); - } - - const path = configPath === "" ? [] : configPath.split("\n"); - const excludedPath = - configExcludedPath === "" ? [] : configExcludedPath.split("\n"); - - const reportOnly = configReportOnly === "true"; - const unsafeEval = configUnsafeEval === "true"; - - const config = { - reportOnly, - reportUri, - unsafeEval, - path: path, - excludedPath, - }; - - return config; -}; - -root.addCard( - { - id: "enable-build-hooks-card", - title: "Dynamic Content Security Policy", - }, - (card) => { - card.addText({ - value: - "Enabling or disabling this integration affects the Content-Security-Policy header of future deploys.", - }); - - card.addLink({ - text: "Learn more in the integration readme", - href: "https://github.com/netlify/integration-csp", - target: "_blank", - block: true, - }); - - card.addButton({ - id: "enable-build-hooks", - title: "Enable", - callback: async ({ picker, fetch }) => { - const res = await fetch(`enable-build`, { - method: "POST", - }); - - if (res.ok) { - picker.getElementById("enable-build-hooks").display = "hidden"; - picker.getElementById("disable-build-hooks").display = "visible"; - - picker.getElementById("csp-configuration").display = "visible"; - } - }, - }); - - card.addButton({ - id: "disable-build-hooks", - title: "Disable", - display: "hidden", - callback: async ({ picker, fetch }) => { - const res = await fetch(`disable-build`, { - method: "POST", - }); - - if (res.ok) { - picker.getElementById("enable-build-hooks").display = "visible"; - picker.getElementById("disable-build-hooks").display = "hidden"; - - picker.getElementById("csp-configuration").display = "hidden"; - } - }, - }); - }, -); - -root.addForm( - { - id: "csp-configuration", - title: "Configuration", - display: "hidden", - onSubmit: async ({ surfaceInputsData, fetch }) => { - const config = mapConfig(surfaceInputsData); - - await fetch(`save-config`, { - method: "POST", - body: JSON.stringify(config), - }); - }, - }, - (form) => { - form.addInputSelect({ - id: "reportOnly", - label: "Report Only", - helpText: - "When true, the Content-Security-Policy-Report-Only header is used instead of the Content-Security-Policy header.", - options: [ - { value: "true", label: "True" }, - { value: "false", label: "False" }, - ], - }); - - form.addInputText({ - id: "reportUri", - label: "Report URI", - helpText: - "The relative or absolute URL to report any violations. If not defined, violations are reported to the __csp-violations function, which is deployed by this integration.", - }); - - form.addInputSelect({ - id: "unsafeEval", - label: "Unsafe Eval", - helpText: - "When true, adds the 'unsafe-eval' source to the CSP for easier adoption. Set to false to have a safer policy if your code and code dependencies do not use eval().", - options: [ - { value: "true", label: "True" }, - { value: "false", label: "False" }, - ], - }); - - form.addInputText({ - id: "path", - label: "Path", - fieldType: "textarea", - helpText: - "The glob expressions of path(s) that should invoke the integration's edge function, separated by newlines.", - }); - - form.addInputText({ - id: "excludedPath", - label: "Excluded Path", - fieldType: "textarea", - helpText: - "The glob expressions of path(s) that *should not* invoke the integration's edge function, separated by newlines. Common non-html filetype extensions (*.css, *.js, *.svg, etc) are already excluded.", - }); - - form.addText({ - value: - "Test your configuration on a draft Deploy Preview to inspect your CSP before going live. This deploy will not publish to production.", - }); - - form.addButton({ - id: "test", - title: "Test on Deploy Preview", - callback: async ({ surfaceInputsData, fetch }) => { - const config = mapConfig(surfaceInputsData); - - await fetch(`trigger-config-test`, { - method: "POST", - body: JSON.stringify({ - ...config, - isTestBuild: true, - }), - }); - }, - }); - - form.addText({ - value: "After saving, your configuration will apply to future deploys.", - }); - }, -); -export { integrationUI }; diff --git a/src/ui/index.tsx b/src/ui/index.tsx new file mode 100644 index 00000000..b8579d68 --- /dev/null +++ b/src/ui/index.tsx @@ -0,0 +1,18 @@ +import "./index.css"; +import { createRoot } from "react-dom/client"; +import { NetlifyExtensionUI } from "@netlify/sdk/ui/react/components"; +import { App } from "./App.jsx"; + +const rootNodeId = "root"; +let rootNode = document.getElementById(rootNodeId); +if (rootNode === null) { + rootNode = document.createElement("div"); + rootNode.id = rootNodeId; +} +const root = createRoot(rootNode); + +root.render( + + + +); diff --git a/src/ui/surfaces/SiteConfiguration.tsx b/src/ui/surfaces/SiteConfiguration.tsx new file mode 100644 index 00000000..3286544d --- /dev/null +++ b/src/ui/surfaces/SiteConfiguration.tsx @@ -0,0 +1,232 @@ +import { + Button, + Card, + CardLoader, + CardTitle, + Checkbox, + Form, + FormField, + SiteBuildDeployConfigurationSurface, +} from "@netlify/sdk/ui/react/components"; +import { trpc } from "../trpc"; +import { useNetlifySDK } from "@netlify/sdk/ui/react"; +import { useEffect, useState } from "react"; +import { z } from "zod"; + +const cspConfigFormSchema = z.object({ + reportOnly: z.boolean().optional(), + reportUri: z.string().url().optional(), + unsafeEval: z.boolean().optional(), + path: z.string(), + excludedPath: z.string().optional(), +}); + +export const SiteConfiguration = () => { + const [triggerTestRun, setTriggerTestRun] = useState(false); + const sdk = useNetlifySDK(); + const trpcUtils = trpc.useUtils(); + const siteConfigQuery = trpc.siteConfig.queryConfig.useQuery(); + const siteConfigurationMutation = trpc.siteConfig.mutateConfig.useMutation({ + onSuccess: async () => { + await trpcUtils.siteConfig.queryConfig.invalidate(); + }, + }); + const siteEnablementMutation = trpc.siteConfig.mutateEnablement.useMutation({ + onSuccess: async () => { + await trpcUtils.siteConfig.queryConfig.invalidate(); + }, + }); + const siteDisablementMutation = trpc.siteConfig.mutateDisablement.useMutation( + { + onSuccess: async () => { + await trpcUtils.siteConfig.queryConfig.invalidate(); + }, + } + ); + const triggerConfigTestMutation = + trpc.siteConfig.mutateTriggerConfigTest.useMutation({ + onSuccess: async () => { + await trpcUtils.siteConfig.queryConfig.invalidate(); + }, + }); + + const onEnableHandler = () => { + siteEnablementMutation.mutate(); + }; + + const onDisableHandler = () => { + siteDisablementMutation.mutate(); + }; + + useEffect(() => { + if (triggerTestRun) { + document + .getElementsByTagName("form")[0] + ?.dispatchEvent(new Event("submit", { bubbles: true })); + + setTriggerTestRun(false); + } + }, [triggerTestRun]); + + if (siteConfigQuery.isLoading) { + return ; + } + + const onSubmitTest = (event: React.MouseEvent) => { + event.preventDefault(); + // Triggers the submit of the form in useEffect + setTriggerTestRun(true); + }; + + type CspConfigFormData = z.infer; + + const onSubmit = ({ + path: newPath, + excludedPath: newExcludedPath, + ...data + }: CspConfigFormData) => { + const path = + newPath === "" + ? [] + : newPath.split("\n").filter((path) => path.trim() !== ""); + + const excludedPath = + !newExcludedPath || newExcludedPath === "" + ? [] + : newExcludedPath.split("\n"); + + void (() => { + if (triggerTestRun) { + setTriggerTestRun(false); + triggerConfigTestMutation.mutateAsync({ + reportOnly: data.reportOnly ?? false, + reportUri: data.reportUri ?? "", + unsafeEval: data.unsafeEval ?? false, + path, + excludedPath, + isTestBuild: true, + }); + } else { + siteConfigurationMutation.mutateAsync({ + ...siteConfigQuery?.data?.config, + cspConfig: { + ...siteConfigQuery?.data?.config?.cspConfig, + reportOnly: data.reportOnly ?? false, + reportUri: data.reportUri, + unsafeEval: data.unsafeEval ?? false, + path, + excludedPath, + }, + }); + sdk.requestTermination(); + } + })(); + }; + + return ( + + + {siteConfigQuery.data?.config?.buildHook ? ( + <> + Disable for site +
+

+ Disabling this affects the Content-Security-Policy header of + future deploys. +

+ +
+ + ) : ( + <> + Enable for site +
+

+ Enabling affects the Content-Security-Policy header of future + deploys. +

+ +
+ + )} +
+ {siteConfigQuery.data?.config?.buildHook && ( + + Configuration +
+
+ + + +
+ + +
+
+

+ Test your configuration on a draft Deploy Preview to inspect + your CSP before going live. This deploy will not publish to + production. +

+ +
+ +

+ After saving, your configuration will apply to future deploys. +

+
+
+
+ )} +
+ ); +}; diff --git a/src/ui/trpc.ts b/src/ui/trpc.ts new file mode 100644 index 00000000..f7da4864 --- /dev/null +++ b/src/ui/trpc.ts @@ -0,0 +1,4 @@ +import { createTRPCReact } from "@trpc/react-query"; +import type { AppRouter } from "../server/router.js"; + +export const trpc = createTRPCReact(); diff --git a/tailwind.config.ts b/tailwind.config.ts new file mode 100644 index 00000000..aeb1a854 --- /dev/null +++ b/tailwind.config.ts @@ -0,0 +1,6 @@ +import config from "@netlify/sdk/ui/react/tailwind-config"; + +export default { + presets: [config], + content: ["./src/ui/index.html", "./src/ui/**/*.{js,jsx,ts,tsx}"], +}; diff --git a/tsconfig.backend.json b/tsconfig.backend.json new file mode 100644 index 00000000..2d7e6a59 --- /dev/null +++ b/tsconfig.backend.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "strict": true, + "esModuleInterop": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "module": "ES2022", + "moduleResolution": "Bundler" + }, + "exclude": [ + "dist/", + "src/ui/**/*", + "src/server/**/*", + "src/functions/**/*", + "vite.config.ts", + "tailwind.config.ts", + "vitest.config.ts", + ], +} \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index c6608bb8..32afa154 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,12 +1,11 @@ { - "compilerOptions": { - "target": "ES2022", - "module": "ES2022", - "moduleResolution": "bundler", - "rootDir": "." - }, - "exclude": [ - "node_modules", - "dist" - ] + "files": [], + "references": [ + { + "path": "./tsconfig.ui.json" + }, + { + "path": "./tsconfig.backend.json" + } + ] } \ No newline at end of file diff --git a/tsconfig.ui.json b/tsconfig.ui.json new file mode 100644 index 00000000..7ac08c1a --- /dev/null +++ b/tsconfig.ui.json @@ -0,0 +1,23 @@ +{ + "extends": [ + "@tsconfig/recommended/tsconfig.json", + "@tsconfig/strictest/tsconfig.json", + "@tsconfig/vite-react/tsconfig.json", + ], + "compilerOptions": { + "esModuleInterop": true, + "noEmit": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "jsx": "react-jsx", + }, + "include": [ + "src/ui/**/*", + "src/server/**/*", + "src/functions/**/*", + ], + "exclude": [ + "dist/", + ] +} \ No newline at end of file diff --git a/vite.config.ts b/vite.config.ts new file mode 100644 index 00000000..a498becd --- /dev/null +++ b/vite.config.ts @@ -0,0 +1,19 @@ +import react from "@vitejs/plugin-react"; +import { defineConfig } from "vite"; +import autoprefixerPlugin from "autoprefixer"; +import tailwindcssPlugin from "tailwindcss"; + +export default defineConfig(() => ({ + base: "", + build: { + outDir: "../../.ntli/site/static/ui", + }, + root: "./src/ui", + plugins: [react()], + css: { + devSourcemap: true, + postcss: { + plugins: [autoprefixerPlugin, tailwindcssPlugin], + }, + }, +})); \ No newline at end of file