Skip to content

Commit bd2550b

Browse files
committed
feat: add an entrypoint which embeds the wasm instead of requiring it to be fetched over the network when deployed as a Netlify Edge Function
Netlify Edge Functions are not able to read the file-system, which means we can't read the generated `.wasm` file during execution, we currently fetch the wasm file over the network but this request could fail or have an incorrect response, leading to a runtime error when attempting to instantiate the WebAssembly Module. To work-around this, we when building the `.wasm` file we now also build a `.ts` file which includes the exact same bytes as the wasm file, but within a JavaScipt UInt8Array, which we can then instantiate as a WebAssembly Module.
1 parent 91814e1 commit bd2550b

File tree

8 files changed

+537225
-453
lines changed

8 files changed

+537225
-453
lines changed

pkg/embedded-wasm.ts

Lines changed: 536716 additions & 0 deletions
Large diffs are not rendered by default.

scripts/build.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,21 @@ async function main() {
33
cmd: ["wasm-pack", "build", "--target", "web", "--release"],
44
}).status();
55
await Deno.remove("./pkg/.gitignore");
6+
const wasmFile = JSON.parse(
7+
await Deno.readTextFile("./pkg/package.json"),
8+
).files.find((name: string) => name.endsWith(".wasm"));
9+
await Deno.writeTextFile(
10+
"./pkg/embedded-wasm.ts",
11+
`export const wasmBinary = Uint8Array.from(${
12+
JSON.stringify(
13+
Array.from(
14+
await Deno.readFile(
15+
`./pkg/${wasmFile}`,
16+
),
17+
),
18+
)
19+
});`,
20+
);
621
}
722

823
main();

src/csp.ts

Lines changed: 157 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,157 @@
1+
import { HTMLRewriter } from "./html_rewriter_wrapper.ts";
2+
import { Element } from "./types.d.ts";
3+
4+
import { default as init } from "../pkg/csp_nonce_html_transformer.js";
5+
await init();
6+
export type Params = {
7+
/**
8+
* When true, uses the `Content-Security-Policy-Report-Only` header instead
9+
* of the `Content-Security-Policy` header. Setting to `true` is useful for
10+
* testing the CSP with real production traffic without actually blocking resources.
11+
*/
12+
reportOnly?: boolean;
13+
/**
14+
* The relative or absolute URL to report any violations. If left undefined,
15+
* violations will not be reported anywhere, which this plugin deploys. If
16+
* the response already has a `report-uri` directive defined in its CSP header,
17+
* then that value will take precedence.
18+
*/
19+
reportUri?: string;
20+
/**
21+
* When true, adds `'unsafe-eval'` to the CSP for easier adoption. Set to
22+
* `false` to have a safer policy if your code and code dependencies does
23+
* not use `eval()`.
24+
*/
25+
unsafeEval?: boolean;
26+
strictDynamic?: boolean;
27+
unsafeInline?: boolean;
28+
self?: boolean;
29+
https?: boolean;
30+
http?: boolean;
31+
/**
32+
* A number from 0 to 1, but 0 to 100 is also supported, along with a trailing %.
33+
*
34+
* You can ramp up or ramp down the inclusion of the `Content-Security-Policy`
35+
* header by setting this to a value between `0` and `1`.
36+
*
37+
* Any value in between `0` and `1` will include the nonce in randomly distributed traffic.
38+
*
39+
* For example, a value of `0.25` will put the new directives in the `Content-Security-Policy`
40+
* header for 25% of responses. The other 75% of responses will have the new directives
41+
* in the `Content-Security-Policy-Report-Only` header.
42+
*/
43+
distribution?: string;
44+
};
45+
46+
const hexOctets: string[] = [];
47+
48+
for (let i = 0; i <= 255; ++i) {
49+
const hexOctet = i.toString(16).padStart(2, "0");
50+
hexOctets.push(hexOctet);
51+
}
52+
53+
function uInt8ArrayToBase64String(input: Uint8Array): string {
54+
let res = "";
55+
56+
for (let i = 0; i < input.length; i++) {
57+
res += String.fromCharCode(parseInt(hexOctets[input[i]], 16));
58+
}
59+
60+
return btoa(res);
61+
}
62+
63+
export function csp(originalResponse: Response, params?: Params) {
64+
const isHTMLResponse = originalResponse.headers.get("content-type")
65+
?.startsWith(
66+
"text/html",
67+
);
68+
if (!isHTMLResponse) {
69+
return originalResponse;
70+
}
71+
const response = new Response(originalResponse.body, originalResponse);
72+
73+
let header = params && params.reportOnly
74+
? "content-security-policy-report-only"
75+
: "content-security-policy";
76+
77+
// distribution is a number from 0 to 1,
78+
// but 0 to 100 is also supported, along with a trailing %
79+
const distribution = params?.distribution;
80+
if (distribution) {
81+
const threshold = distribution.endsWith("%") || parseFloat(distribution) > 1
82+
? Math.max(parseFloat(distribution) / 100, 0)
83+
: Math.max(parseFloat(distribution), 0);
84+
const random = Math.random();
85+
// if a roll of the dice is greater than our threshold...
86+
if (random > threshold && threshold <= 1) {
87+
if (header === "content-security-policy") {
88+
// if the real CSP is set, then change to report only
89+
header = "content-security-policy-report-only";
90+
} else {
91+
// if the CSP is set to report-only, return unadulterated response
92+
return response;
93+
}
94+
}
95+
}
96+
97+
const nonce = uInt8ArrayToBase64String(
98+
crypto.getRandomValues(new Uint8Array(24)),
99+
);
100+
101+
const rules = [
102+
`'nonce-${nonce}'`,
103+
params?.unsafeEval && `'unsafe-eval'`,
104+
params?.strictDynamic && `'strict-dynamic'`,
105+
params?.unsafeInline && `'unsafe-inline'`,
106+
params?.self && `'self'`,
107+
params?.https && `https:`,
108+
params?.http && `http:`,
109+
].filter(Boolean);
110+
const scriptSrc = `script-src ${rules.join(" ")}`;
111+
112+
const csp = response.headers.get(header) as string;
113+
if (csp) {
114+
const directives = csp
115+
.split(";")
116+
.map((directive) => {
117+
// prepend our rules for any existing directives
118+
const d = directive.trim();
119+
// intentionally add trailing space to avoid mangling `script-src-elem`
120+
if (d.startsWith("script-src ")) {
121+
// append with trailing space to include any user-supplied values
122+
// https://github.com/netlify/plugin-csp-nonce/issues/72
123+
return d.replace("script-src ", `${scriptSrc} `).trim();
124+
}
125+
// intentionally omit report-uri: theirs should take precedence
126+
return d;
127+
})
128+
.filter(Boolean);
129+
// push our rules if the directives don't exist yet
130+
if (!directives.find((d) => d.startsWith("script-src "))) {
131+
directives.push(scriptSrc);
132+
}
133+
if (
134+
params?.reportUri && !directives.find((d) => d.startsWith("report-uri"))
135+
) {
136+
directives.push(`report-uri ${params.reportUri}`);
137+
}
138+
const value = directives.join("; ");
139+
response.headers.set(header, value);
140+
} else {
141+
// make a new ruleset of directives if no CSP present
142+
const value = [scriptSrc];
143+
if (params?.reportUri) {
144+
value.push(`report-uri ${params.reportUri}`);
145+
}
146+
response.headers.set(header, value.join("; "));
147+
}
148+
149+
const querySelectors = ["script", 'link[rel="preload"][as="script"]'];
150+
return new HTMLRewriter()
151+
.on(querySelectors.join(","), {
152+
element(element: Element) {
153+
element.setAttribute("nonce", nonce);
154+
},
155+
})
156+
.transform(response);
157+
}

src/index-embedded-wasm.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { default as init } from "../pkg/csp_nonce_html_transformer.js";
2+
import { wasmBinary } from "../pkg/embedded-wasm.ts";
3+
await init(wasmBinary);
4+
5+
export { csp } from "./csp.ts";

src/index.ts

Lines changed: 1 addition & 154 deletions
Original file line numberDiff line numberDiff line change
@@ -1,157 +1,4 @@
1-
import { HTMLRewriter } from "./html_rewriter_wrapper.ts";
2-
import { Element } from "./types.d.ts";
3-
41
import { default as init } from "../pkg/csp_nonce_html_transformer.js";
52
await init();
6-
type Params = {
7-
/**
8-
* When true, uses the `Content-Security-Policy-Report-Only` header instead
9-
* of the `Content-Security-Policy` header. Setting to `true` is useful for
10-
* testing the CSP with real production traffic without actually blocking resources.
11-
*/
12-
reportOnly?: boolean;
13-
/**
14-
* The relative or absolute URL to report any violations. If left undefined,
15-
* violations will not be reported anywhere, which this plugin deploys. If
16-
* the response already has a `report-uri` directive defined in its CSP header,
17-
* then that value will take precedence.
18-
*/
19-
reportUri?: string;
20-
/**
21-
* When true, adds `'unsafe-eval'` to the CSP for easier adoption. Set to
22-
* `false` to have a safer policy if your code and code dependencies does
23-
* not use `eval()`.
24-
*/
25-
unsafeEval?: boolean;
26-
strictDynamic?: boolean;
27-
unsafeInline?: boolean;
28-
self?: boolean;
29-
https?: boolean;
30-
http?: boolean;
31-
/**
32-
* A number from 0 to 1, but 0 to 100 is also supported, along with a trailing %.
33-
*
34-
* You can ramp up or ramp down the inclusion of the `Content-Security-Policy`
35-
* header by setting this to a value between `0` and `1`.
36-
*
37-
* Any value in between `0` and `1` will include the nonce in randomly distributed traffic.
38-
*
39-
* For example, a value of `0.25` will put the new directives in the `Content-Security-Policy`
40-
* header for 25% of responses. The other 75% of responses will have the new directives
41-
* in the `Content-Security-Policy-Report-Only` header.
42-
*/
43-
distribution?: string;
44-
};
45-
46-
const hexOctets: string[] = [];
47-
48-
for (let i = 0; i <= 255; ++i) {
49-
const hexOctet = i.toString(16).padStart(2, "0");
50-
hexOctets.push(hexOctet);
51-
}
52-
53-
function uInt8ArrayToBase64String(input: Uint8Array): string {
54-
let res = "";
55-
56-
for (let i = 0; i < input.length; i++) {
57-
res += String.fromCharCode(parseInt(hexOctets[input[i]], 16));
58-
}
59-
60-
return btoa(res);
61-
}
62-
63-
export function csp(originalResponse: Response, params?: Params) {
64-
const isHTMLResponse = originalResponse.headers.get("content-type")
65-
?.startsWith(
66-
"text/html",
67-
);
68-
if (!isHTMLResponse) {
69-
return originalResponse;
70-
}
71-
const response = new Response(originalResponse.body, originalResponse);
72-
73-
let header = params && params.reportOnly
74-
? "content-security-policy-report-only"
75-
: "content-security-policy";
76-
77-
// distribution is a number from 0 to 1,
78-
// but 0 to 100 is also supported, along with a trailing %
79-
const distribution = params?.distribution;
80-
if (distribution) {
81-
const threshold = distribution.endsWith("%") || parseFloat(distribution) > 1
82-
? Math.max(parseFloat(distribution) / 100, 0)
83-
: Math.max(parseFloat(distribution), 0);
84-
const random = Math.random();
85-
// if a roll of the dice is greater than our threshold...
86-
if (random > threshold && threshold <= 1) {
87-
if (header === "content-security-policy") {
88-
// if the real CSP is set, then change to report only
89-
header = "content-security-policy-report-only";
90-
} else {
91-
// if the CSP is set to report-only, return unadulterated response
92-
return response;
93-
}
94-
}
95-
}
96-
97-
const nonce = uInt8ArrayToBase64String(
98-
crypto.getRandomValues(new Uint8Array(24)),
99-
);
100-
101-
const rules = [
102-
`'nonce-${nonce}'`,
103-
params?.unsafeEval && `'unsafe-eval'`,
104-
params?.strictDynamic && `'strict-dynamic'`,
105-
params?.unsafeInline && `'unsafe-inline'`,
106-
params?.self && `'self'`,
107-
params?.https && `https:`,
108-
params?.http && `http:`,
109-
].filter(Boolean);
110-
const scriptSrc = `script-src ${rules.join(" ")}`;
111-
112-
const csp = response.headers.get(header) as string;
113-
if (csp) {
114-
const directives = csp
115-
.split(";")
116-
.map((directive) => {
117-
// prepend our rules for any existing directives
118-
const d = directive.trim();
119-
// intentionally add trailing space to avoid mangling `script-src-elem`
120-
if (d.startsWith("script-src ")) {
121-
// append with trailing space to include any user-supplied values
122-
// https://github.com/netlify/plugin-csp-nonce/issues/72
123-
return d.replace("script-src ", `${scriptSrc} `).trim();
124-
}
125-
// intentionally omit report-uri: theirs should take precedence
126-
return d;
127-
})
128-
.filter(Boolean);
129-
// push our rules if the directives don't exist yet
130-
if (!directives.find((d) => d.startsWith("script-src "))) {
131-
directives.push(scriptSrc);
132-
}
133-
if (
134-
params?.reportUri && !directives.find((d) => d.startsWith("report-uri"))
135-
) {
136-
directives.push(`report-uri ${params.reportUri}`);
137-
}
138-
const value = directives.join("; ");
139-
response.headers.set(header, value);
140-
} else {
141-
// make a new ruleset of directives if no CSP present
142-
const value = [scriptSrc];
143-
if (params?.reportUri) {
144-
value.push(`report-uri ${params.reportUri}`);
145-
}
146-
response.headers.set(header, value.join("; "));
147-
}
1483

149-
const querySelectors = ["script", 'link[rel="preload"][as="script"]'];
150-
return new HTMLRewriter()
151-
.on(querySelectors.join(","), {
152-
element(element: Element) {
153-
element.setAttribute("nonce", nonce);
154-
},
155-
})
156-
.transform(response);
157-
}
4+
export { csp } from "./csp.ts";

tests/index-embedded-wasm.test.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import { csp } from "../src/index-embedded-wasm.ts";
2+
3+
import tests from "./tests.ts";
4+
5+
tests(csp);

0 commit comments

Comments
 (0)