@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 +11 -0
- package/index.js +27 -0
- package/package.json +28 -0
- package/src/__csp-nonce-inputs.json +5 -0
- package/src/__csp-nonce.ts +145 -0
- package/src/__csp-violations.ts +16 -0
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,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 };
|