Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

fix(refactor): use csp_nonce_html_transformer library #96

Merged
merged 1 commit into from
Nov 15, 2024
Merged
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 17 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,30 @@ type Params = {
unsafeEval: boolean;
path: string | string[];
excludedPath: string[];
distribution?: string;
strictDynamic?: boolean;
unsafeInline?: boolean;
self?: boolean;
https?: boolean;
http?: boolean;
};
const params = inputs as Params;
params.reportUri = params.reportUri || "/.netlify/functions/__csp-violations";
// @ts-ignore
params.distribution = Netlify.env.get("CSP_NONCE_DISTRIBUTION");

params.strictDynamic = true;
params.unsafeInline = true;
params.self = true;
params.https = true;
params.http = true;

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"]'];
JakeChampion marked this conversation as resolved.
Show resolved Hide resolved
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
Loading