diff --git a/package.json b/package.json index 477c8a03..ba477d0c 100644 --- a/package.json +++ b/package.json @@ -10,7 +10,7 @@ }, "dependencies": { "@netlify/plugin-csp-nonce": "^1.2.1", - "@netlify/sdk": "^0.7.9", + "@netlify/sdk": "^0.8.1", "typescript": "^5.1.6" } } \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8c21bd7c..62a5eda3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1,4 +1,4 @@ -lockfileVersion: '6.1' +lockfileVersion: '6.0' settings: autoInstallPeers: true @@ -9,8 +9,8 @@ dependencies: specifier: ^1.2.1 version: 1.2.1 '@netlify/sdk': - specifier: ^0.7.9 - version: 0.7.9(babel-eslint@10.1.0)(core-js@3.32.0)(react-dom@18.2.0)(react@18.2.0)(webpack@5.88.2) + specifier: ^0.8.1 + version: 0.8.1(babel-eslint@10.1.0)(core-js@3.32.0)(react-dom@18.2.0)(react@18.2.0)(webpack@5.88.2) typescript: specifier: ^5.1.6 version: 5.1.6 @@ -2091,8 +2091,8 @@ packages: resolution: {integrity: sha512-jz3HzCancUgD220v2ePXHRMXiIDQtpYnKzL6SXqhBI/CZgvrJ1i+0U4LfExGYniw5+hkLFM1nH+4LpgQnYJKQw==} dev: false - /@netlify/sdk@0.7.9(babel-eslint@10.1.0)(core-js@3.32.0)(react-dom@18.2.0)(react@18.2.0)(webpack@5.88.2): - resolution: {integrity: sha512-w2yF1IMN+znAxOZTCMhrURNLg8dLFCE4Hqk5aS9SNaHoG4wE4zZbonEI/E2NhgYCTExvuiQg1oTLrRLiwVK/Rg==} + /@netlify/sdk@0.8.1(babel-eslint@10.1.0)(core-js@3.32.0)(react-dom@18.2.0)(react@18.2.0)(webpack@5.88.2): + resolution: {integrity: sha512-J8NGnZdjDpxQ5qCwJ/Kob5PrYsJTjqbz+KKCGz/GQFuBZ6UqBHf7tYA+ORkeMclZHgxBLv8HxFDePxknwReqTw==} hasBin: true dependencies: '@netlify/functions': 1.6.0 diff --git a/src/hooks/index.ts b/src/hooks/index.ts index 13e70109..f5ae72cb 100644 --- a/src/hooks/index.ts +++ b/src/hooks/index.ts @@ -1,7 +1,7 @@ import fs from "fs"; -export const onPreBuild = async ({ inputs, netlifyConfig, utils }) => { - const config = JSON.stringify(inputs, null, 2); +export const onPreBuild = async ({ config, netlifyConfig, utils }) => { + const configString = JSON.stringify(config, null, 2); const { build } = netlifyConfig; // CSP_NONCE_DISTRIBUTION is a number from 0 to 1, @@ -23,7 +23,7 @@ export const onPreBuild = async ({ inputs, netlifyConfig, utils }) => { // make the directory in case it actually doesn't exist yet await utils.run.command(`mkdir -p ${edgeFunctionsDir}`); - fs.writeFileSync(`${edgeFunctionsDir}/__csp-nonce-inputs.json`, config); + fs.writeFileSync(`${edgeFunctionsDir}/__csp-nonce-inputs.json`, configString); console.log(` Writing nonce edge function to ${edgeFunctionsDir}...`); const nonceSource = ".netlify/plugins/node_modules/@netlify/plugin-csp-nonce/src/__csp-nonce.ts"; @@ -31,7 +31,7 @@ export const onPreBuild = async ({ inputs, netlifyConfig, utils }) => { fs.copyFileSync(nonceSource, nonceDest); // if no reportUri in config input, deploy function on site's behalf - if (!inputs.reportUri) { + if (!config.reportUri) { const functionsDir = build.functions || "./netlify/functions"; // make the directory in case it actually doesn't exist yet await utils.run.command(`mkdir -p ${functionsDir}`); @@ -41,7 +41,7 @@ export const onPreBuild = async ({ inputs, netlifyConfig, utils }) => { const violationsDest = `${functionsDir}/__csp-violations.ts`; fs.copyFileSync(violationsSource, violationsDest); } else { - console.log(` Using ${inputs.reportUri} as report-uri directive...`); + console.log(` Using ${config.reportUri} as report-uri directive...`); } console.log(` Done.`); diff --git a/src/index.ts b/src/index.ts index c9ece4a7..6e5d6489 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,61 +1,97 @@ // Documentation: https://github.com/netlify/sdk -import { NetlifyIntegration } from "@netlify/sdk"; +import { NetlifyIntegration, z } from "@netlify/sdk"; import { onPreBuild } from "./hooks"; -type SiteConfig = { - buildHook?: { - url: string; - id: string; - }; - cspConfig?: { - reportOnly: boolean; - reportUri: string; - unsafeEval: boolean; - path: string | string[]; - excludedPath: string[]; - }; -}; - -type BuildContext = { - inputs?: SiteConfig["cspConfig"]; -}; - -const integration = new NetlifyIntegration(); - -integration.addBuildHook("onPreBuild", ({ buildContext, ...opts }) => { - // We lean on this favoured order of precedence: - // 1. Incoming hook body - // 2. Build context - // 3. Plugin options - realistically won't ever be called in this context - let { inputs } = buildContext ?? opts ?? {}; - - if (process.env.INCOMING_HOOK_BODY) { - console.log("Using temporary config from test build."); - inputs = JSON.parse(process.env.INCOMING_HOOK_BODY); - } else { - console.log("Using stored CSP config."); - } +const siteConfigSchema = z.object({ + buildHook: z + .object({ + url: z.string(), + id: z.string(), + }) + .optional(), + cspConfig: z + .object({ + reportOnly: z.boolean(), + reportUri: z.string(), + unsafeEval: z.boolean(), + path: z.string().array(), + excludedPath: z.string().array(), + }) + .optional(), +}); - if (inputs) { - inputs.reportOnly = inputs.reportOnly === "true" ? true : false; - inputs.unsafeEval = inputs.unsafeEval === "true" ? true : false; - } +const buildConfigSchema = z.object({ + reportOnly: z.boolean().optional(), + reportUri: z.string().optional(), + unsafeEval: z.boolean().optional(), + path: z.string().array().optional(), + excludedPath: z.string().array().optional(), +}); - const newOpts = { - ...opts, - inputs, - }; +const buildContextSchema = z.object({ + config: siteConfigSchema.shape.cspConfig, +}); - return onPreBuild(newOpts); +const integration = new NetlifyIntegration({ + siteConfigSchema, + buildConfigSchema, + buildContextSchema, }); -integration.addBuildContext(async ({ site_config }) => { +integration.addBuildEventHandler( + "onPreBuild", + ({ buildContext, netlifyConfig, utils, ...opts }) => { + // We lean on this favoured order of precedence: + // 1. Incoming hook body + // 2. Build context + // 3. Plugin options - realistically won't ever be called in this context + + let { config } = buildContext ?? opts ?? {}; + + if (process.env.INCOMING_HOOK_BODY) { + console.log("Using temporary config from test build."); + try { + const hookBody = JSON.parse(process.env.INCOMING_HOOK_BODY); + config = integration._buildConfigurationSchema.parse(hookBody); + } catch (e) { + console.warn("Failed to parse incoming hook body."); + console.log(e); + } + } else { + if (!config) { + console.log(); + config = { + reportOnly: true, + reportUri: "", + unsafeEval: true, + path: ["/*"], + excludedPath: [], + }; + console.log("Using default CSP config."); + } else { + console.log("Using stored CSP config."); + } + } + + const newOpts = { + ...opts, + netlifyConfig, + utils, + config, + }; + + return onPreBuild(newOpts); + } +); + +integration.addBuildEventContext(async ({ site_config }) => { if (site_config.cspConfig) { return { - inputs: site_config.cspConfig, + config: site_config.cspConfig, }; } - return {}; + + return undefined; }); integration.addHandler("get-config", async (_, { client, siteId }) => { @@ -75,13 +111,23 @@ integration.addHandler("get-config", async (_, { client, siteId }) => { integration.addHandler("save-config", async ({ body }, { client, siteId }) => { console.log(`Saving config for ${siteId}.`); - const config = JSON.parse(body) as SiteConfig["cspConfig"]; + const result = integration._siteConfigSchema.shape.cspConfig.safeParse( + JSON.parse(body) + ); + + if (!result.success) { + return { + statusCode: 400, + body: JSON.stringify(result), + }; + } + const { data } = result; const existingConfig = await client.getSiteIntegration(siteId); await client.updateSiteIntegration(siteId, { ...existingConfig.config, - cspConfig: config, + cspConfig: data, }); return { @@ -119,7 +165,7 @@ integration.addHandler( async (_, { client, siteId, teamId }) => { const { token } = await client.generateBuildToken(siteId); await client.setBuildToken(teamId, siteId, token); - await client.enableBuildhook(siteId); + await client.enableBuildEventHandlers(siteId); const { url, id } = await client.createBuildHook(siteId, { title: "CSP Configuration Tests", @@ -149,7 +195,7 @@ integration.addHandler( }, } = await client.getSiteIntegration(siteId); - await client.disableBuildhook(siteId); + await client.disableBuildEventHandlers(siteId); await client.removeBuildToken(teamId, siteId); await client.deleteBuildHook(siteId, buildHookId); @@ -171,7 +217,7 @@ integration.onDisable(async ({ queryStringParameters }, { client }) => { const { id: buildHookId } = buildHook ?? {}; if (buildHookId) { - await client.disableBuildhook(siteId); + await client.disableBuildEventHandlers(siteId); await client.removeBuildToken(teamId, siteId); await client.deleteBuildHook(siteId, buildHookId); diff --git a/src/ui/index.ts b/src/ui/index.ts index 36f9fd25..67bcef36 100644 --- a/src/ui/index.ts +++ b/src/ui/index.ts @@ -18,15 +18,20 @@ root.onLoad( picker.getElementById("disable-build-hooks").display = has_build_hook_enabled ? "visible" : "hidden"; + picker.getElementById("csp-configuration").display = has_build_hook_enabled + ? "visible" + : "hidden"; + surfaceInputsData["csp-configuration_reportOnly"] = cspConfig.reportOnly ?? "true"; surfaceInputsData["csp-configuration_reportUri"] = cspConfig.reportUri ?? ""; surfaceInputsData["csp-configuration_unsafeEval"] = cspConfig.unsafeEval ?? "true"; - surfaceInputsData["csp-configuration_path"] = cspConfig.path ?? ""; + surfaceInputsData["csp-configuration_path"] = + cspConfig.path?.join("\n") ?? ""; surfaceInputsData["csp-configuration_excludedPath"] = - cspConfig.excludedPath ?? ""; + cspConfig.excludedPath?.join("\n") ?? ""; return { surfaceInputsData, @@ -35,6 +40,40 @@ root.onLoad( } ); +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", @@ -47,10 +86,11 @@ root.addCard( }); card.addLink({ - text: 'Learn more in the integration readme', - href: 'https://github.com/netlify/integration-csp', - target: '_blank', - }) + text: "Learn more in the integration readme", + href: "https://github.com/netlify/integration-csp", + target: "_blank", + block: true, + }); card.addButton({ id: "enable-build-hooks", @@ -63,6 +103,8 @@ root.addCard( if (res.ok) { picker.getElementById("enable-build-hooks").display = "hidden"; picker.getElementById("disable-build-hooks").display = "visible"; + + picker.getElementById("csp-configuration").display = "visible"; } }, }); @@ -79,6 +121,8 @@ root.addCard( if (res.ok) { picker.getElementById("enable-build-hooks").display = "visible"; picker.getElementById("disable-build-hooks").display = "hidden"; + + picker.getElementById("csp-configuration").display = "hidden"; } }, }); @@ -89,24 +133,9 @@ root.addForm( { id: "csp-configuration", title: "Configuration", + display: "hidden", onSubmit: async ({ surfaceInputsData, fetch }) => { - const { - "csp-configuration_reportOnly": reportOnly = "true", - "csp-configuration_reportUri": reportUri = "", - "csp-configuration_unsafeEval": unsafeEval = "true", - "csp-configuration_path": path = "/*", - "csp-configuration_excludedPath": excludedPath = "[]", - } = surfaceInputsData; - - const config = { - reportOnly, - reportUri, - unsafeEval, - // @ts-expect-error - path: JSON.stringify(path.split('\n')), - // @ts-expect-error - excludedPath: JSON.stringify(excludedPath.split('\n')), - }; + const config = mapConfig(surfaceInputsData); await fetch(`save-config`, { method: "POST", @@ -147,7 +176,7 @@ root.addForm( form.addInputText({ id: "path", label: "Path", - fieldType: "textarea" as any, + fieldType: "textarea", helpText: "The glob expressions of path(s) that should invoke the integration's edge function, separated by newlines.", }); @@ -155,7 +184,7 @@ root.addForm( form.addInputText({ id: "excludedPath", label: "Excluded Path", - fieldType: "textarea" as any, + 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.", }); @@ -169,21 +198,7 @@ root.addForm( id: "test", title: "Test on Deploy Preview", callback: async ({ surfaceInputsData, fetch }) => { - const { - "csp-configuration_reportOnly": reportOnly, - "csp-configuration_reportUri": reportUri, - "csp-configuration_unsafeEval": unsafeEval, - "csp-configuration_path": path, - "csp-configuration_excludedPath": excludedPath, - } = surfaceInputsData; - - const config = { - reportOnly, - reportUri, - unsafeEval, - path, - excludedPath, - }; + const config = mapConfig(surfaceInputsData); await fetch(`trigger-config-test`, { method: "POST", @@ -193,8 +208,7 @@ root.addForm( }); form.addText({ - value: - "After saving, your configuration will apply to future deploys.", + value: "After saving, your configuration will apply to future deploys.", }); } );