-
Notifications
You must be signed in to change notification settings - Fork 4
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
fix(refactor): use csp_nonce_html_transformer library
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
1 parent
d3e2c71
commit 2e6c482
Showing
1 changed file
with
6 additions
and
102 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
|
@@ -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" }; | ||
|
||
|
@@ -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 | ||
|