@netlify/plugin-csp-nonce 1.3.0 → 1.3.1-alpha.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/index.js +19 -19
- package/package.json +11 -3
- package/src/__csp-nonce.ts +19 -104
package/index.js
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
/* eslint-disable no-console */
|
|
2
|
-
import fs, { copyFileSync } from "fs";
|
|
3
|
-
import {
|
|
2
|
+
import fs, { copyFileSync } from "node:fs";
|
|
3
|
+
import { dirname, resolve } from "node:path";
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
import { getBuildInfo } from "@netlify/build-info/node";
|
|
4
6
|
|
|
5
|
-
const
|
|
7
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
6
8
|
|
|
7
9
|
async function projectUsesNextJS() {
|
|
8
10
|
for (const framework of (await getBuildInfo()).frameworks) {
|
|
9
|
-
if (framework.id ===
|
|
11
|
+
if (framework.id === "next") {
|
|
10
12
|
return true;
|
|
11
13
|
}
|
|
12
14
|
}
|
|
@@ -46,34 +48,30 @@ export const onPreBuild = async ({
|
|
|
46
48
|
}
|
|
47
49
|
|
|
48
50
|
console.log(` Current working directory: ${process.cwd()}`);
|
|
49
|
-
const basePath =
|
|
50
|
-
build.environment.SITE_ID === SITE_ID
|
|
51
|
-
? "./src"
|
|
52
|
-
: "./node_modules/@netlify/plugin-csp-nonce/src";
|
|
53
51
|
|
|
54
52
|
// make the directory in case it actually doesn't exist yet
|
|
55
53
|
await utils.run.command(`mkdir -p ${INTERNAL_EDGE_FUNCTIONS_SRC}`);
|
|
56
54
|
console.log(
|
|
57
|
-
` Writing nonce edge function to ${INTERNAL_EDGE_FUNCTIONS_SRC}
|
|
55
|
+
` Writing nonce edge function to ${INTERNAL_EDGE_FUNCTIONS_SRC}...`
|
|
58
56
|
);
|
|
59
57
|
copyFileSync(
|
|
60
|
-
|
|
61
|
-
`${INTERNAL_EDGE_FUNCTIONS_SRC}/__csp-nonce.ts
|
|
58
|
+
resolve(__dirname, `./src/__csp-nonce.ts`),
|
|
59
|
+
`${INTERNAL_EDGE_FUNCTIONS_SRC}/__csp-nonce.ts`
|
|
62
60
|
);
|
|
63
61
|
|
|
64
62
|
const usesNext = await projectUsesNextJS();
|
|
65
63
|
|
|
66
64
|
// Do not invoke the CSP Edge Function for Netlify Image CDN requests.
|
|
67
|
-
inputs.excludedPath.push(
|
|
68
|
-
|
|
65
|
+
inputs.excludedPath.push("/.netlify/images");
|
|
66
|
+
|
|
69
67
|
// If using NextJS, do not invoke the CSP Edge Function for NextJS Image requests.
|
|
70
68
|
if (usesNext) {
|
|
71
|
-
inputs.excludedPath.push(
|
|
69
|
+
inputs.excludedPath.push("/_next/image");
|
|
72
70
|
}
|
|
73
|
-
|
|
71
|
+
|
|
74
72
|
fs.writeFileSync(
|
|
75
73
|
`${INTERNAL_EDGE_FUNCTIONS_SRC}/__csp-nonce-inputs.json`,
|
|
76
|
-
JSON.stringify(inputs, null, 2)
|
|
74
|
+
JSON.stringify(inputs, null, 2)
|
|
77
75
|
);
|
|
78
76
|
|
|
79
77
|
// if no reportUri in config input, deploy function on site's behalf
|
|
@@ -81,11 +79,11 @@ export const onPreBuild = async ({
|
|
|
81
79
|
// make the directory in case it actually doesn't exist yet
|
|
82
80
|
await utils.run.command(`mkdir -p ${INTERNAL_FUNCTIONS_SRC}`);
|
|
83
81
|
console.log(
|
|
84
|
-
` Writing violations logging function to ${INTERNAL_FUNCTIONS_SRC}
|
|
82
|
+
` Writing violations logging function to ${INTERNAL_FUNCTIONS_SRC}...`
|
|
85
83
|
);
|
|
86
84
|
copyFileSync(
|
|
87
|
-
|
|
88
|
-
`${INTERNAL_FUNCTIONS_SRC}/__csp-violations.ts
|
|
85
|
+
resolve(__dirname, `./src/__csp-violations.ts`),
|
|
86
|
+
`${INTERNAL_FUNCTIONS_SRC}/__csp-violations.ts`
|
|
89
87
|
);
|
|
90
88
|
} else {
|
|
91
89
|
console.log(` Using ${inputs.reportUri} as report-uri directive...`);
|
|
@@ -93,3 +91,5 @@ export const onPreBuild = async ({
|
|
|
93
91
|
|
|
94
92
|
console.log(` Done.`);
|
|
95
93
|
};
|
|
94
|
+
|
|
95
|
+
export const onPreDev = onPreBuild;
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"private": false,
|
|
3
3
|
"name": "@netlify/plugin-csp-nonce",
|
|
4
|
-
"version": "1.3.
|
|
4
|
+
"version": "1.3.1-alpha.1",
|
|
5
5
|
"description": "Use a nonce for the script-src and style-src directives of your Content Security Policy.",
|
|
6
6
|
"main": "index.js",
|
|
7
7
|
"repository": {
|
|
@@ -17,15 +17,23 @@
|
|
|
17
17
|
"src/*"
|
|
18
18
|
],
|
|
19
19
|
"devDependencies": {
|
|
20
|
+
"@types/node": "^22.9.0",
|
|
21
|
+
"cheerio": "^1.0.0",
|
|
22
|
+
"content-security-policy-parser": "^0.6.0",
|
|
23
|
+
"execa": "^9.5.1",
|
|
24
|
+
"get-port": "^7.1.0",
|
|
20
25
|
"prettier": "^2.8.8",
|
|
21
|
-
"
|
|
26
|
+
"strip-ansi": "^7.1.0",
|
|
27
|
+
"typescript": "^5.2.2",
|
|
28
|
+
"vitest": "^2.1.4"
|
|
22
29
|
},
|
|
23
30
|
"bugs": {
|
|
24
31
|
"url": "https://github.com/netlify/plugin-csp-nonce/issues"
|
|
25
32
|
},
|
|
26
33
|
"homepage": "https://github.com/netlify/plugin-csp-nonce#readme",
|
|
27
34
|
"scripts": {
|
|
28
|
-
"build": "tsc src/*.ts --noEmit --strict --lib es2018,dom"
|
|
35
|
+
"build": "tsc src/*.ts --noEmit --strict --lib es2018,dom",
|
|
36
|
+
"test": "vitest"
|
|
29
37
|
},
|
|
30
38
|
"dependencies": {
|
|
31
39
|
"@netlify/build-info": "^7.15.2"
|
package/src/__csp-nonce.ts
CHANGED
|
@@ -2,10 +2,7 @@
|
|
|
2
2
|
// @ts-ignore
|
|
3
3
|
import type { Config, Context } from "netlify:edge";
|
|
4
4
|
// @ts-ignore
|
|
5
|
-
import {
|
|
6
|
-
// @ts-ignore
|
|
7
|
-
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/html-rewriter@0.1.0-pre.19/index.ts";
|
|
8
|
-
|
|
5
|
+
import { csp } from "https://deno.land/x/csp_nonce_html_transformer@v2.1.1/src/index.ts";
|
|
9
6
|
// @ts-ignore
|
|
10
7
|
import inputs from "./__csp-nonce-inputs.json" assert { type: "json" };
|
|
11
8
|
|
|
@@ -15,112 +12,30 @@ type Params = {
|
|
|
15
12
|
unsafeEval: boolean;
|
|
16
13
|
path: string | string[];
|
|
17
14
|
excludedPath: string[];
|
|
15
|
+
distribution?: string;
|
|
16
|
+
strictDynamic?: boolean;
|
|
17
|
+
unsafeInline?: boolean;
|
|
18
|
+
self?: boolean;
|
|
19
|
+
https?: boolean;
|
|
20
|
+
http?: boolean;
|
|
18
21
|
};
|
|
19
22
|
const params = inputs as Params;
|
|
23
|
+
params.reportUri = params.reportUri || "/.netlify/functions/__csp-violations";
|
|
24
|
+
// @ts-ignore
|
|
25
|
+
params.distribution = Netlify.env.get("CSP_NONCE_DISTRIBUTION");
|
|
26
|
+
|
|
27
|
+
params.strictDynamic = true;
|
|
28
|
+
params.unsafeInline = true;
|
|
29
|
+
params.self = true;
|
|
30
|
+
params.https = true;
|
|
31
|
+
params.http = true;
|
|
20
32
|
|
|
21
33
|
const handler = async (request: Request, context: Context) => {
|
|
22
34
|
const response = await context.next(request);
|
|
23
35
|
|
|
24
36
|
// for debugging which routes use this edge function
|
|
25
37
|
response.headers.set("x-debug-csp-nonce", "invoked");
|
|
26
|
-
|
|
27
|
-
const isHTMLResponse = response.headers
|
|
28
|
-
.get("content-type")
|
|
29
|
-
?.startsWith("text/html");
|
|
30
|
-
const shouldTransformResponse = isHTMLResponse;
|
|
31
|
-
if (!shouldTransformResponse) {
|
|
32
|
-
console.log(`Unnecessary invocation for ${request.url}`, {
|
|
33
|
-
method: request.method,
|
|
34
|
-
"content-type": response.headers.get("content-type"),
|
|
35
|
-
});
|
|
36
|
-
return response;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
let header = params.reportOnly
|
|
40
|
-
? "content-security-policy-report-only"
|
|
41
|
-
: "content-security-policy";
|
|
42
|
-
|
|
43
|
-
// CSP_NONCE_DISTRIBUTION is a number from 0 to 1,
|
|
44
|
-
// but 0 to 100 is also supported, along with a trailing %
|
|
45
|
-
// @ts-ignore
|
|
46
|
-
const distribution = Netlify.env.get("CSP_NONCE_DISTRIBUTION");
|
|
47
|
-
if (!!distribution) {
|
|
48
|
-
const threshold =
|
|
49
|
-
distribution.endsWith("%") || parseFloat(distribution) > 1
|
|
50
|
-
? Math.max(parseFloat(distribution) / 100, 0)
|
|
51
|
-
: Math.max(parseFloat(distribution), 0);
|
|
52
|
-
const random = Math.random();
|
|
53
|
-
// if a roll of the dice is greater than our threshold...
|
|
54
|
-
if (random > threshold && threshold <= 1) {
|
|
55
|
-
if (header === "content-security-policy") {
|
|
56
|
-
// if the real CSP is set, then change to report only
|
|
57
|
-
header = "content-security-policy-report-only";
|
|
58
|
-
} else {
|
|
59
|
-
// if the CSP is set to report-only, return unadulterated response
|
|
60
|
-
return response;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
const nonce = randomBytes(24).toString("base64");
|
|
66
|
-
// `'strict-dynamic'` allows scripts to be loaded from trusted scripts
|
|
67
|
-
// when `'strict-dynamic'` is present, `'unsafe-inline' 'self' https: http:` is ignored by browsers
|
|
68
|
-
// `'unsafe-inline' 'self' https: http:` is a compat check for browsers that don't support `strict-dynamic`
|
|
69
|
-
// https://content-security-policy.com/strict-dynamic/
|
|
70
|
-
const rules = [
|
|
71
|
-
`'nonce-${nonce}'`,
|
|
72
|
-
`'strict-dynamic'`,
|
|
73
|
-
`'unsafe-inline'`,
|
|
74
|
-
params.unsafeEval && `'unsafe-eval'`,
|
|
75
|
-
`'self'`,
|
|
76
|
-
`https:`,
|
|
77
|
-
`http:`,
|
|
78
|
-
].filter(Boolean);
|
|
79
|
-
const scriptSrc = `script-src ${rules.join(" ")}`;
|
|
80
|
-
const reportUri = `report-uri ${
|
|
81
|
-
params.reportUri || "/.netlify/functions/__csp-violations"
|
|
82
|
-
}`;
|
|
83
|
-
|
|
84
|
-
const csp = response.headers.get(header) as string;
|
|
85
|
-
if (csp) {
|
|
86
|
-
const directives = csp
|
|
87
|
-
.split(";")
|
|
88
|
-
.map((directive) => {
|
|
89
|
-
// prepend our rules for any existing directives
|
|
90
|
-
const d = directive.trim();
|
|
91
|
-
// intentionally add trailing space to avoid mangling `script-src-elem`
|
|
92
|
-
if (d.startsWith("script-src ")) {
|
|
93
|
-
// append with trailing space to include any user-supplied values
|
|
94
|
-
// https://github.com/netlify/plugin-csp-nonce/issues/72
|
|
95
|
-
return d.replace("script-src ", `${scriptSrc} `).trim();
|
|
96
|
-
}
|
|
97
|
-
// intentionally omit report-uri: theirs should take precedence
|
|
98
|
-
return d;
|
|
99
|
-
})
|
|
100
|
-
.filter(Boolean);
|
|
101
|
-
// push our rules if the directives don't exist yet
|
|
102
|
-
if (!directives.find((d) => d.startsWith("script-src "))) {
|
|
103
|
-
directives.push(scriptSrc);
|
|
104
|
-
}
|
|
105
|
-
if (!directives.find((d) => d.startsWith("report-uri"))) {
|
|
106
|
-
directives.push(reportUri);
|
|
107
|
-
}
|
|
108
|
-
const value = directives.join("; ");
|
|
109
|
-
response.headers.set(header, value);
|
|
110
|
-
} else {
|
|
111
|
-
// make a new ruleset of directives if no CSP present
|
|
112
|
-
const value = [scriptSrc, reportUri].join("; ");
|
|
113
|
-
response.headers.set(header, value);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
const querySelectors = ["script", 'link[rel="preload"][as="script"]'];
|
|
117
|
-
return new HTMLRewriter()
|
|
118
|
-
.on(querySelectors.join(","), {
|
|
119
|
-
element(element: HTMLElement) {
|
|
120
|
-
element.setAttribute("nonce", nonce);
|
|
121
|
-
},
|
|
122
|
-
})
|
|
123
|
-
.transform(response);
|
|
38
|
+
return csp(response, params)
|
|
124
39
|
};
|
|
125
40
|
|
|
126
41
|
// Top 50 most common extensions (minus .html and .htm) according to Humio
|
|
@@ -178,8 +93,8 @@ const excludedExtensions = [
|
|
|
178
93
|
export const config: Config = {
|
|
179
94
|
path: params.path,
|
|
180
95
|
excludedPath: ["/.netlify*", `**/*.(${excludedExtensions.join("|")})`]
|
|
181
|
-
|
|
182
|
-
|
|
96
|
+
.concat(params.excludedPath)
|
|
97
|
+
.filter(Boolean),
|
|
183
98
|
handler,
|
|
184
99
|
onError: "bypass",
|
|
185
100
|
method: "GET",
|