From a228108cc5eaa6fc62c0c0de0a91ba8e8496b1df Mon Sep 17 00:00:00 2001 From: Guillaume Moissaing Date: Mon, 5 Aug 2024 09:29:59 +0200 Subject: [PATCH] Update CSP example - 128bit nonce - content-security-policy and content-security-policy-report-only - multiple nonces in CSP - nonce in any CSP directive - response content transformation pattern - forwarding non-html content - serve as-is when CSP headers are missing - processing response with any status --- edgecompute/examples/security/csp/bundle.json | 2 +- .../examples/security/csp/cspPolicyParser.js | 10 -- .../examples/security/csp/http-helpers.js | 62 ++++++++ edgecompute/examples/security/csp/main.js | 132 ++++++++++++------ 4 files changed, 153 insertions(+), 53 deletions(-) delete mode 100644 edgecompute/examples/security/csp/cspPolicyParser.js create mode 100644 edgecompute/examples/security/csp/http-helpers.js diff --git a/edgecompute/examples/security/csp/bundle.json b/edgecompute/examples/security/csp/bundle.json index bc0dadd..6cd7144 100644 --- a/edgecompute/examples/security/csp/bundle.json +++ b/edgecompute/examples/security/csp/bundle.json @@ -1,4 +1,4 @@ { "edgeworker-version": "1.0", - "description" : "Dynamic CSP" + "description" : "Updates existing Content Security Policies at the edge with fresh nonces" } \ No newline at end of file diff --git a/edgecompute/examples/security/csp/cspPolicyParser.js b/edgecompute/examples/security/csp/cspPolicyParser.js deleted file mode 100644 index 4d490ca..0000000 --- a/edgecompute/examples/security/csp/cspPolicyParser.js +++ /dev/null @@ -1,10 +0,0 @@ -export const parsePolicy = (policy) => { - const result = {}; - policy.split(";").forEach((directive) => { - const [directiveKey, ...directiveValue] = directive.trim().split(/\s+/g); - if (directiveKey && !Object.hasOwnProperty.call(result, directiveKey)) { - result[directiveKey] = directiveValue; - } - }); - return result; -}; \ No newline at end of file diff --git a/edgecompute/examples/security/csp/http-helpers.js b/edgecompute/examples/security/csp/http-helpers.js new file mode 100644 index 0000000..74738de --- /dev/null +++ b/edgecompute/examples/security/csp/http-helpers.js @@ -0,0 +1,62 @@ +/** + * Sub-request helper module + * HTTP headers manipulation + * Handling unsafe hop-by-hop headers when request or response are duplicated + */ + +const UNSAFE_REQUEST_HEADERS = [ + 'host', 'pragma', 'accept-encoding', + 'connection', 'proxy-authorization', + 'te', 'trailer', 'transfer-encoding', 'upgrade' +]; +const UNSAFE_RESPONSE_HEADERS = [ + 'content-length', 'vary', 'content-encoding', + 'connection', 'keep-alive', 'proxy-authenticate', + 'trailer', 'transfer-encoding', 'upgrade' +]; +/** + * @param {EW.Headers} headers + * @param {string[]} unsafeHeaders + */ +function removeUnsafeHeaders(headers, unsafeHeaders) { + for (let unsafeHeader of unsafeHeaders) { + if (unsafeHeader in headers) { + delete headers[unsafeHeader] + } + } + return headers; +} + +/** + * Removes hop-by-hop unsafe request headers from provided header collection. + * @param {EW.Headers} headers - Collection of http request headers. + * @returns headers parameter + */ +export function removeUnsafeRequestHeaders(headers) { + return removeUnsafeHeaders(headers, UNSAFE_REQUEST_HEADERS); +} + +/** + * Removes hop-by-hop unsafe response headers from provided header collection. + * @param {EW.Headers} headers - Collection of http response headers. + * @returns headers parameter + */ +export function removeUnsafeResponseHeaders(headers) { + return removeUnsafeHeaders(headers, UNSAFE_RESPONSE_HEADERS); +} + +/** + * Helper function to get value for a unique request header. + * @param {EW.ReadsHeaders} requestOrResponse + * @param {string} headerName - name of http header. + * @param {string?} defaultValue - Value returned if header is missing or got multiple values. + * @returns {string?} header value or default + */ +export function getUniqueHeaderValue(requestOrResponse, headerName, defaultValue = null) { + const headers = requestOrResponse.getHeader(headerName); + if (headers && headers.length == 1) { + return headers[0]; + } else { + return defaultValue; + } +} diff --git a/edgecompute/examples/security/csp/main.js b/edgecompute/examples/security/csp/main.js index a880f93..0c1865f 100644 --- a/edgecompute/examples/security/csp/main.js +++ b/edgecompute/examples/security/csp/main.js @@ -1,50 +1,98 @@ -import {parsePolicy} from './cspPolicyParser.js'; -import {HtmlRewritingStream} from 'html-rewriter'; -import {httpRequest} from 'http-request'; -import {createResponse} from 'create-response'; -import {crypto} from 'crypto'; -import {btoa} from "encoding"; +import { HtmlRewritingStream } from 'html-rewriter'; +import { httpRequest } from 'http-request'; +import { createResponse } from 'create-response'; +import { crypto } from 'crypto'; +import { base64 } from "encoding"; +import { logger } from 'log'; +import { removeUnsafeRequestHeaders, removeUnsafeResponseHeaders, getUniqueHeaderValue } from './http-helpers.js'; +/** + * Allows caching of HTML with CSP nonce by caching object from the Origin and + * updating nonce values for each user request. The worker only updates + * existing CSP nonces, it doesn't replace CSP implementation by the Origin. + * + * CSP headers are supported, CSP meta tags in html head are NOT supported. + * Non-html content-type will be forwarded as-is, with no transformation. + * + * Code does a sub-request, looping back on the same property. + * sub-request will carry a `x-bypass-edge-csp-nonce` header + * + * Property configuration: + * - Run only for cacheable HTML + * Matching on content-type is not possible (too late) + * You may rely on requests criteria such as: + * - Method is Get + * - File extension: EMPTY_STRING html + * - Variable AKA_PM_CACHEABLE_OBJECT=true + * - Request header Sec-Fetch-Mode: navigate + * Do not run when request header `x-bypass-edge-csp-nonce` exists + * - Cache must be bypassed on first leg, to always refresh nonce + * request without `x-bypass-edge-csp-nonce` header + * - Cache must NOT be bypassed on second leg, to cache pristine HTML + * request with `x-bypass-edge-csp-nonce: sub-request` header. + * + * @param {EW.ResponseProviderRequest} request + * @returns {Promise} response + */ export async function responseProvider(request) { - //Step 1: Calculate the Nonce - let array = new Uint32Array(1); - crypto.getRandomValues(array); - let stringToEncode = array[0].toString(); - let encodedData = btoa(stringToEncode); - let headerNonce = 'nonce-' + encodedData; - - //Step 2: Replace the origin nonce with our generated nonce in the CSP response header - const headers = request.getHeaders(); - let options = {}; - let htmlResponse = await httpRequest("/", options); - if (!htmlResponse.ok) { - return createResponse(500, {}, `Failed to fetch doc: ${htmlResponse.status}`); - } - let responseHeaders = htmlResponse.getHeaders(); - let originCSPHeader = htmlResponse.getHeader('Content-Security-Policy')[0]; - const parsedPolicy = parsePolicy(originCSPHeader); - let parsedPolicyElement = parsedPolicy['script-src'][0].toString(); - let newCspHeader = originCSPHeader.replace(parsedPolicyElement, "'" + headerNonce + "'"); - responseHeaders['content-security-policy'] = [newCspHeader]; - - //Step 3: Rewrite the HTML with our generated nonce - rewriter.onElement('[nonce=' + parsedPolicyElement + ']', el => { - el.setAttribute('nonce', encodedData, {quote: "'"}) + //Step 1: Request pristine content + const requestHeaders = removeUnsafeRequestHeaders(request.getHeaders()); + requestHeaders["x-bypass-edge-csp-nonce"] = ["sub-request"]; + const pristineResponse = await httpRequest(request.url, { + method: request.method, + headers: requestHeaders, + body: request.body }); - return createResponse(200, getSafeResponseHeaders(responseHeaders), htmlResponse.body.pipeThrough(rewriter)); -} - -const UNSAFE_RESPONSE_HEADERS = ['content-length', 'transfer-encoding', 'connection', 'vary', - 'accept-encoding', 'content-encoding', 'keep-alive', - 'proxy-authenticate', 'proxy-authorization', 'te', 'trailers', 'upgrade', 'host']; + //Step 2: prepare response from pristine content + const responseHeaders = removeUnsafeResponseHeaders(pristineResponse.getHeaders()); + let responseBody = pristineResponse.body; -function getSafeResponseHeaders(headers) { - for (let unsafeResponseHeader of UNSAFE_RESPONSE_HEADERS) { - if (unsafeResponseHeader in headers) { - delete headers[unsafeResponseHeader]; + //Step 3: Rewrite the pristine nonce our generated nonce in both response header and HTML + const pristineContentType = getUniqueHeaderValue(pristineResponse, "content-type")?.trim()?.toLowerCase(); + // Only for HTML content type + if (!pristineContentType?.startsWith("text/html")) { + logger.warn('Unneeded execution: response Content-Type is not HTML'); + } + else { + //Step 3.1: Calculate new nonces for both CSP headers + const nonces = new Map(); + for (const cspHeaderValues of [responseHeaders["content-security-policy"], responseHeaders["content-security-policy-report-only"]]) { + if (cspHeaderValues) { + // Header may contain multiple nonces, for instance different nonces for script and css + for (let i = 0; i < cspHeaderValues.length; i++) { + // We replace each nonce with a new one, we keep separated values to reduce potential impact on security + const pristineCspHeaderValue = cspHeaderValues[i]; + cspHeaderValues[i] = pristineCspHeaderValue.replaceAll(/'nonce-([a-zA-Z0-9+/_=-]+)'/g, (nonceDefinition, pristineNonce) => { + let newNonce = nonces.get(pristineNonce); + if (!newNonce) { + // Spec recommends at least 128 bits https://w3c.github.io/webappsec-csp/#security-nonces + newNonce = base64.encode(crypto.getRandomValues(new Uint8Array(16))); + nonces.set(pristineNonce, newNonce); + logger.debug(`pristine-nonce-${pristineNonce} new-nonce-${newNonce}`); + } + return `'nonce-${newNonce}'`; + }); + } + } + } + //Step 3.2: Rewrite the HTML with freshly generated nonces + if (nonces.size === 0) { + logger.warn('Unneeded execution: no CSP response header with nonce'); + } else { + logger.info('Updating HTML with CSP nonce'); + const rewriter = new HtmlRewritingStream(); + for (const [pristineNonce, newNonce] of nonces) { + // https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce + rewriter.onElement(`[nonce=${pristineNonce}]`, el => { + el.setAttribute('nonce', newNonce, { quote: "'" }) + }); + } + responseBody = responseBody.pipeThrough(rewriter); } } - return headers; -} \ No newline at end of file + + //Step 4: Forward pristine response to end-user + return createResponse(pristineResponse.status, responseHeaders, responseBody); +}