From b74d7711952aa507a431c8aee96fc90893dadbb4 Mon Sep 17 00:00:00 2001 From: Nathan Houle Date: Thu, 24 Oct 2024 11:12:53 -0700 Subject: [PATCH] revert: revert migration to CSP v2 (#55) * Revert "fix: revert guard removal made in 9cb2744 (#54)" This reverts commit a6a4c0863209daebeaebbafcc7d6099ddbf0c881. * Revert "refactor: clean up project setup (#51)" This reverts commit 34d03a24bf9180be14fcd20e4cf71b457638e21a. * Revert "feat: migration to SDK v2 (#40)" This reverts commit 9cb2744d20ef503e4b5cfcd8ee2a5b282138787d. --- .github/workflows/main.yml | 33 - .gitignore | 13 +- details.md | 76 - eslint.config.js | 75 - extension.yaml | 9 - integration.yaml | 2 + netlify.toml | 18 +- package.json | 52 +- pnpm-lock.yaml | 18792 +++-------------- src/endpoints/trpc.ts | 14 - src/{build-event-handlers => hooks}/index.ts | 48 +- src/index.ts | 229 +- src/server/router.ts | 248 - src/server/trpc.ts | 7 - src/ui/App.tsx | 35 - src/ui/index.css | 1 - src/ui/index.html | 12 - src/ui/index.ts | 226 + src/ui/index.tsx | 18 - src/ui/surfaces/SiteConfiguration.tsx | 230 - src/ui/trpc.ts | 4 - tailwind.config.ts | 6 - tsconfig.backend.json | 15 - tsconfig.json | 19 +- tsconfig.ui.json | 16 - vite.config.ts | 19 - 26 files changed, 3334 insertions(+), 16883 deletions(-) delete mode 100644 .github/workflows/main.yml delete mode 100644 details.md delete mode 100644 eslint.config.js delete mode 100644 extension.yaml create mode 100644 integration.yaml delete mode 100644 src/endpoints/trpc.ts rename src/{build-event-handlers => hooks}/index.ts (61%) delete mode 100644 src/server/router.ts delete mode 100644 src/server/trpc.ts delete mode 100644 src/ui/App.tsx delete mode 100644 src/ui/index.css delete mode 100644 src/ui/index.html create mode 100644 src/ui/index.ts delete mode 100644 src/ui/index.tsx delete mode 100644 src/ui/surfaces/SiteConfiguration.tsx delete mode 100644 src/ui/trpc.ts delete mode 100644 tailwind.config.ts delete mode 100644 tsconfig.backend.json delete mode 100644 tsconfig.ui.json delete mode 100644 vite.config.ts diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml deleted file mode 100644 index 6b58f02b..00000000 --- a/.github/workflows/main.yml +++ /dev/null @@ -1,33 +0,0 @@ ---- -name: Build and test - -on: - pull_request: - -jobs: - build_and_test: - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v4 - - - uses: actions/setup-node@v4 - if: "${{ steps.extract_branch.outputs.branch != 'release-please--branches--main' }}" - with: - node-version: 18.19.0 - - - uses: pnpm/action-setup@v3 - - - name: Install dependencies - run: pnpm install - - - name: Lint - run: pnpm run lint - - - name: Typecheck - run: pnpm run typecheck - - - name: Run tests - run: pnpm run test - - - name: Build - run: pnpm run build diff --git a/.gitignore b/.gitignore index 339e04d0..769c8289 100644 --- a/.gitignore +++ b/.gitignore @@ -1,9 +1,4 @@ -*.tsbuildinfo -.DS_Store -.cache/ -.eslintcache -.netlify/ -.ntli/ -.vscode/ -dev-model.gql -node_modules/ +# Local Netlify folder +.netlify +node_modules +.ntli diff --git a/details.md b/details.md deleted file mode 100644 index fed259bb..00000000 --- a/details.md +++ /dev/null @@ -1,76 +0,0 @@ -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 new file mode 100644 index 00000000..dcef182c --- /dev/null +++ b/src/ui/index.ts @@ -0,0 +1,226 @@ +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 deleted file mode 100644 index db4808db..00000000 --- a/src/ui/index.tsx +++ /dev/null @@ -1,18 +0,0 @@ -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 deleted file mode 100644 index d2c882a1..00000000 --- a/src/ui/surfaces/SiteConfiguration.tsx +++ /dev/null @@ -1,230 +0,0 @@ -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 = async ({ - path: newPath, - excludedPath: newExcludedPath, - ...data - }: CspConfigFormData) => { - const path = - newPath === "" - ? [] - : newPath.split("\n").filter((path) => path.trim() !== ""); - - const excludedPath = - !newExcludedPath || newExcludedPath === "" - ? [] - : newExcludedPath.split("\n"); - - if (triggerTestRun) { - setTriggerTestRun(false); - await triggerConfigTestMutation.mutateAsync({ - reportOnly: data.reportOnly ?? false, - reportUri: data.reportUri ?? "", - unsafeEval: data.unsafeEval ?? false, - path, - excludedPath, - isTestBuild: true, - }); - } else { - await 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 deleted file mode 100644 index f7da4864..00000000 --- a/src/ui/trpc.ts +++ /dev/null @@ -1,4 +0,0 @@ -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 deleted file mode 100644 index aeb1a854..00000000 --- a/tailwind.config.ts +++ /dev/null @@ -1,6 +0,0 @@ -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 deleted file mode 100644 index 48b166e5..00000000 --- a/tsconfig.backend.json +++ /dev/null @@ -1,15 +0,0 @@ -{ - "extends": [ - "@tsconfig/recommended/tsconfig.json", - "@tsconfig/node18/tsconfig.json" - ], - "compilerOptions": { - "noEmit": true, - "sourceMap": true, - "verbatimModuleSyntax": true - }, - "exclude": [ - "dist/", - "src/ui/" - ] -} diff --git a/tsconfig.json b/tsconfig.json index 1a016fd9..c6608bb8 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,11 +1,12 @@ { - "files": [], - "references": [ - { - "path": "./tsconfig.ui.json" - }, - { - "path": "./tsconfig.backend.json" - } + "compilerOptions": { + "target": "ES2022", + "module": "ES2022", + "moduleResolution": "bundler", + "rootDir": "." + }, + "exclude": [ + "node_modules", + "dist" ] -} +} \ No newline at end of file diff --git a/tsconfig.ui.json b/tsconfig.ui.json deleted file mode 100644 index 615fe129..00000000 --- a/tsconfig.ui.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "extends": [ - "@tsconfig/recommended/tsconfig.json", - "@tsconfig/strictest/tsconfig.json", - "@tsconfig/vite-react/tsconfig.json", - ], - "compilerOptions": { - "noEmit": true, - "sourceMap": true, - "verbatimModuleSyntax": true, - "jsx": "react-jsx" - }, - "include": [ - "src/ui/" - ] -} diff --git a/vite.config.ts b/vite.config.ts deleted file mode 100644 index 8b847034..00000000 --- a/vite.config.ts +++ /dev/null @@ -1,19 +0,0 @@ -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], - }, - }, -}));