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

Update CSP example #211

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
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
2 changes: 1 addition & 1 deletion edgecompute/examples/security/csp/bundle.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"edgeworker-version": "1.0",
"description" : "Dynamic CSP"
"description" : "Updates existing Content Security Policies at the edge with fresh nonces"
}
10 changes: 0 additions & 10 deletions edgecompute/examples/security/csp/cspPolicyParser.js

This file was deleted.

62 changes: 62 additions & 0 deletions edgecompute/examples/security/csp/http-helpers.js
Original file line number Diff line number Diff line change
@@ -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;
}
}
132 changes: 90 additions & 42 deletions edgecompute/examples/security/csp/main.js
Original file line number Diff line number Diff line change
@@ -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<object>} 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;
}

//Step 4: Forward pristine response to end-user
return createResponse(pristineResponse.status, responseHeaders, responseBody);
}