Skip to content

Commit

Permalink
fix(refactor): use csp_nonce_html_transformer library
Browse files Browse the repository at this point in the history
csp_nonce_html_transformer internally has a custom non-asyncify wasm build of lol-html which only includes the setAttribute method, as that is all that is needed to add nonce attributes to elements. This reduces the wasm size by ~70%. It is also using the latest v2.0.0 of lol-html.

csp_nonce_html_transformer is a standalone library for Deno which takes in a Response instance and will add CSP directives to the configured CSP header and will add nonce attributes to the response body.
  • Loading branch information
JakeChampion committed Nov 8, 2024
1 parent d3e2c71 commit 2e6c482
Showing 1 changed file with 6 additions and 102 deletions.
108 changes: 6 additions & 102 deletions src/__csp-nonce.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@
// @ts-ignore
import type { Config, Context } from "netlify:edge";
// @ts-ignore
import { randomBytes } from "node:crypto";
// @ts-ignore
import { HTMLRewriter } from "https://ghuc.cc/worker-tools/[email protected]/index.ts";

import { csp } from "https://deno.land/x/[email protected]/src/index.ts";
// @ts-ignore
import inputs from "./__csp-nonce-inputs.json" assert { type: "json" };

Expand All @@ -15,112 +12,19 @@ type Params = {
unsafeEval: boolean;
path: string | string[];
excludedPath: string[];
distribution?: string;
};
const params = inputs as Params;
params.reportUri = params.reportUri || "/.netlify/functions/__csp-violations";
// @ts-ignore
params.distribution = Netlify.env.get("CSP_NONCE_DISTRIBUTION");

const handler = async (request: Request, context: Context) => {
const response = await context.next(request);

// for debugging which routes use this edge function
response.headers.set("x-debug-csp-nonce", "invoked");

const isHTMLResponse = response.headers
.get("content-type")
?.startsWith("text/html");
const shouldTransformResponse = isHTMLResponse;
if (!shouldTransformResponse) {
console.log(`Unnecessary invocation for ${request.url}`, {
method: request.method,
"content-type": response.headers.get("content-type"),
});
return response;
}

let header = params.reportOnly
? "content-security-policy-report-only"
: "content-security-policy";

// CSP_NONCE_DISTRIBUTION is a number from 0 to 1,
// but 0 to 100 is also supported, along with a trailing %
// @ts-ignore
const distribution = Netlify.env.get("CSP_NONCE_DISTRIBUTION");
if (!!distribution) {
const threshold =
distribution.endsWith("%") || parseFloat(distribution) > 1
? Math.max(parseFloat(distribution) / 100, 0)
: Math.max(parseFloat(distribution), 0);
const random = Math.random();
// if a roll of the dice is greater than our threshold...
if (random > threshold && threshold <= 1) {
if (header === "content-security-policy") {
// if the real CSP is set, then change to report only
header = "content-security-policy-report-only";
} else {
// if the CSP is set to report-only, return unadulterated response
return response;
}
}
}

const nonce = randomBytes(24).toString("base64");
// `'strict-dynamic'` allows scripts to be loaded from trusted scripts
// when `'strict-dynamic'` is present, `'unsafe-inline' 'self' https: http:` is ignored by browsers
// `'unsafe-inline' 'self' https: http:` is a compat check for browsers that don't support `strict-dynamic`
// https://content-security-policy.com/strict-dynamic/
const rules = [
`'nonce-${nonce}'`,
`'strict-dynamic'`,
`'unsafe-inline'`,
params.unsafeEval && `'unsafe-eval'`,
`'self'`,
`https:`,
`http:`,
].filter(Boolean);
const scriptSrc = `script-src ${rules.join(" ")}`;
const reportUri = `report-uri ${
params.reportUri || "/.netlify/functions/__csp-violations"
}`;

const csp = response.headers.get(header) as string;
if (csp) {
const directives = csp
.split(";")
.map((directive) => {
// prepend our rules for any existing directives
const d = directive.trim();
// intentionally add trailing space to avoid mangling `script-src-elem`
if (d.startsWith("script-src ")) {
// append with trailing space to include any user-supplied values
// https://github.com/netlify/plugin-csp-nonce/issues/72
return d.replace("script-src ", `${scriptSrc} `).trim();
}
// intentionally omit report-uri: theirs should take precedence
return d;
})
.filter(Boolean);
// push our rules if the directives don't exist yet
if (!directives.find((d) => d.startsWith("script-src "))) {
directives.push(scriptSrc);
}
if (!directives.find((d) => d.startsWith("report-uri"))) {
directives.push(reportUri);
}
const value = directives.join("; ");
response.headers.set(header, value);
} else {
// make a new ruleset of directives if no CSP present
const value = [scriptSrc, reportUri].join("; ");
response.headers.set(header, value);
}

const querySelectors = ["script", 'link[rel="preload"][as="script"]'];
return new HTMLRewriter()
.on(querySelectors.join(","), {
element(element: HTMLElement) {
element.setAttribute("nonce", nonce);
},
})
.transform(response);
return csp(response, params)
};

// Top 50 most common extensions (minus .html and .htm) according to Humio
Expand Down

0 comments on commit 2e6c482

Please sign in to comment.