@netlify/plugin-csp-nonce 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @netlify/plugin-csp-nonce
2
+
3
+ Use a nonce for the script-src and style-src directives of your Content Security Policy.
4
+
5
+ This package deploys an edge function to add a header and transform the HTML response body, and a function to log CSP violations.
6
+
7
+ ## Configuration options
8
+
9
+ - `reportOnly`: When true, uses the Content-Security-Policy-Report-Only header instead of the Content-Security-Policy header.
10
+ - `path`: The glob expressions of path(s) that should invoke the CSP nonce edge function. Can be a string or array of strings.
11
+ - `excludedPath`: 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)
package/index.js ADDED
@@ -0,0 +1,27 @@
1
+ import fs, { copyFileSync } from "fs";
2
+
3
+ /* eslint-disable no-console */
4
+ export const onPreBuild = async ({ inputs, netlifyConfig, utils }) => {
5
+ console.log(` Current working directory: ${process.cwd()}`);
6
+ const config = JSON.stringify(inputs, null, 2);
7
+
8
+ const functionsDir = netlifyConfig.build.functions || "./netlify/functions";
9
+ // make the directory in case it actually doesn't exist yet
10
+ await utils.run.command(`mkdir -p ${functionsDir}`);
11
+ console.log(` Copying function to ${functionsDir}...`);
12
+ copyFileSync(
13
+ `./src/__csp-violations.ts`,
14
+ `${functionsDir}/__csp-violations.ts`
15
+ );
16
+
17
+ const edgeFunctionsDir =
18
+ netlifyConfig.build.edge_functions || "./netlify/edge-functions";
19
+ // make the directory in case it actually doesn't exist yet
20
+ await utils.run.command(`mkdir -p ${edgeFunctionsDir}`);
21
+ console.log(` Copying edge function to ${edgeFunctionsDir}...`);
22
+ copyFileSync(`./src/__csp-nonce.ts`, `${edgeFunctionsDir}/__csp-nonce.ts`);
23
+ console.log(` Copying config inputs to ${edgeFunctionsDir}...`);
24
+ fs.writeFileSync(`${edgeFunctionsDir}/__csp-nonce-inputs.json`, config);
25
+
26
+ console.log(` Complete.`);
27
+ };
package/package.json ADDED
@@ -0,0 +1,28 @@
1
+ {
2
+ "name": "@netlify/plugin-csp-nonce",
3
+ "version": "1.0.1",
4
+ "description": "Use a nonce for the script-src and style-src directives of your Content Security Policy.",
5
+ "main": "index.js",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/netlify/csp-nonce.git"
9
+ },
10
+ "author": "Jason Barry <jb@netlify.com>",
11
+ "license": "MIT",
12
+ "type": "module",
13
+ "files": [
14
+ "index.js",
15
+ "manifest.yaml",
16
+ "src/*"
17
+ ],
18
+ "devDependencies": {
19
+ "prettier": "^2.8.8"
20
+ },
21
+ "bugs": {
22
+ "url": "https://github.com/netlify/csp-nonce/issues"
23
+ },
24
+ "homepage": "https://github.com/netlify/csp-nonce#readme",
25
+ "scripts": {
26
+ "test": "echo \"Error: no test specified\" && exit 1"
27
+ }
28
+ }
@@ -0,0 +1,5 @@
1
+ {
2
+ "reportOnly": false,
3
+ "path": "/*",
4
+ "excludedPath": []
5
+ }
@@ -0,0 +1,145 @@
1
+ /* eslint-disable import/extensions */
2
+ /* eslint-disable import/no-unresolved */
3
+ // @ts-expect-error
4
+ import { cryptoRandomString } from "https://deno.land/x/crypto_random_string@1.0.0/mod.ts";
5
+ // @ts-expect-error
6
+ import type { Config, Context } from "netlify:edge";
7
+
8
+ import inputs from "./__csp-nonce-inputs.json" assert { type: "json" };
9
+
10
+ type Params = {
11
+ reportOnly: boolean;
12
+ path: string | string[];
13
+ excludedPath: string[];
14
+ };
15
+ const params = inputs as Params;
16
+
17
+ const header = params.reportOnly
18
+ ? "content-security-policy-report-only"
19
+ : "content-security-policy";
20
+
21
+ const handler = async (request: Request, context: Context) => {
22
+ const response = await context.next();
23
+
24
+ // for debugging which routes use this edge function
25
+ response.headers.set("x-debug-csp-nonce", "invoked");
26
+
27
+ // html only
28
+ if (
29
+ !request.headers.get("accept")?.startsWith("text/html") ||
30
+ !response.headers.get("content-type").startsWith("text/html")
31
+ ) {
32
+ return response;
33
+ }
34
+
35
+ const nonce = cryptoRandomString({ length: 16, type: "alphanumeric" });
36
+ // `'strict-dynamic'` allows scripts to be loaded from trusted scripts
37
+ // when `'strict-dynamic'` is present, `'unsafe-inline' 'self' https: http:` is ignored by browsers
38
+ // `'unsafe-inline' 'self' https: http:` is a compat check for browsers that don't support `strict-dynamic`
39
+ // https://content-security-policy.com/strict-dynamic/
40
+ const rules = `'nonce-${nonce}' 'strict-dynamic' 'unsafe-inline' 'self' https: http:`;
41
+ const scriptSrc = `script-src ${rules}`;
42
+ const styleSrc = `style-src ${rules}`;
43
+ const reportUri = `report-uri /.netlify/functions/__csp-violations`;
44
+
45
+ const csp = response.headers.get(header);
46
+ if (csp) {
47
+ const directives = csp
48
+ .split(";")
49
+ .map((directive) => {
50
+ // prepend our rules for any existing directives
51
+ const d = directive.trim();
52
+ if (d.startsWith("script-src")) {
53
+ return d.replace("script-src", scriptSrc);
54
+ }
55
+ if (d.startsWith("style-src")) {
56
+ return d.replace("style-src", styleSrc);
57
+ }
58
+ return d;
59
+ })
60
+ .filter(Boolean);
61
+ // push our rules if the directives don't exist yet
62
+ if (!directives.find((d) => d.startsWith("script-src"))) {
63
+ directives.push(scriptSrc);
64
+ }
65
+ if (!directives.find((d) => d.startsWith("style-src"))) {
66
+ directives.push(styleSrc);
67
+ }
68
+ const value = directives.join("; ");
69
+ response.headers.set(header, value);
70
+ } else {
71
+ // make a new ruleset of directives if no CSP present
72
+ const value = [scriptSrc, styleSrc, reportUri].join("; ");
73
+ response.headers.set(header, value);
74
+ }
75
+
76
+ // time to do some regex magic
77
+ const page = await response.text();
78
+ const rewrittenPage = page.replace(
79
+ /<(script|style)([^>]*)>/gi,
80
+ `<$1$2 nonce="${nonce}">`
81
+ );
82
+ return new Response(rewrittenPage, response);
83
+ };
84
+
85
+ const excludedExtensions = [
86
+ "aspx",
87
+ "avif",
88
+ "babylon",
89
+ "bak",
90
+ "cgi",
91
+ "com",
92
+ "css",
93
+ "ds",
94
+ "env",
95
+ "gif",
96
+ "gz",
97
+ "ico",
98
+ "ini",
99
+ "jpeg",
100
+ "jpg",
101
+ "js",
102
+ "json",
103
+ "jsp",
104
+ "log",
105
+ "m4a",
106
+ "map",
107
+ "md",
108
+ "mjs",
109
+ "mp3",
110
+ "mp4",
111
+ "ogg",
112
+ "otf",
113
+ "pdf",
114
+ "php",
115
+ "png",
116
+ "rar",
117
+ "sh",
118
+ "sql",
119
+ "svg",
120
+ "ttf",
121
+ "txt",
122
+ "wasm",
123
+ "wav",
124
+ "webm",
125
+ "webmanifest",
126
+ "webp",
127
+ "woff",
128
+ "woff2",
129
+ "xml",
130
+ "xsd",
131
+ "yaml",
132
+ "yml",
133
+ "zip",
134
+ ];
135
+
136
+ export const config: Config = {
137
+ path: params.path,
138
+ excludedPath: [
139
+ ...params.excludedPath,
140
+ ...excludedExtensions.map((ext) => `**/*.${ext}`),
141
+ ],
142
+ handler,
143
+ };
144
+
145
+ export default handler;
@@ -0,0 +1,16 @@
1
+ const handler = async (event) => {
2
+ try {
3
+ const { "csp-report": cspReport } = JSON.parse(event.body);
4
+ if (cspReport) {
5
+ // eslint-disable-next-line no-console
6
+ console.log(JSON.stringify(cspReport));
7
+ }
8
+ } catch (err) {
9
+ // ...the sound of silence
10
+ }
11
+ return {
12
+ statusCode: 200,
13
+ };
14
+ };
15
+
16
+ export { handler };