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
+
+
+ )}
+
+ );
+};
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