diff --git a/package-lock.json b/package-lock.json
index ac4857f8..5be38e36 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -26,6 +26,7 @@
"mdast-util-to-string": "4.0.0",
"micromark-util-subtokenize": "2.0.4",
"mime": "4.0.6",
+ "parse5": "7.2.1",
"rehype-format": "5.0.1",
"rehype-parse": "9.0.1",
"remark-parse": "11.0.0",
diff --git a/package.json b/package.json
index 3e6f48ec..2abb7159 100644
--- a/package.json
+++ b/package.json
@@ -60,6 +60,7 @@
"mdast-util-to-string": "4.0.0",
"micromark-util-subtokenize": "2.0.4",
"mime": "4.0.6",
+ "parse5": "7.2.1",
"rehype-format": "5.0.1",
"rehype-parse": "9.0.1",
"remark-parse": "11.0.0",
diff --git a/src/html-pipe.js b/src/html-pipe.js
index f51830d9..15ba4490 100644
--- a/src/html-pipe.js
+++ b/src/html-pipe.js
@@ -171,6 +171,7 @@ export async function htmlPipe(state, req) {
if (state.content.sourceBus === 'code' || state.info.originalExtension === '.md') {
state.timer?.update('serialize');
+ await setCustomResponseHeaders(state, req, res);
await renderCode(state, req, res);
} else {
state.timer?.update('parse');
@@ -187,6 +188,7 @@ export async function htmlPipe(state, req) {
await createPictures(state);
await extractMetaData(state, req);
await addHeadingIds(state);
+ await setCustomResponseHeaders(state, req, res);
await render(state, req, res);
state.timer?.update('serialize');
await tohtml(state, req, res);
@@ -194,7 +196,6 @@ export async function htmlPipe(state, req) {
}
setLastModified(state, res);
- await setCustomResponseHeaders(state, req, res);
await setXSurrogateKeyHeader(state, req, res);
} catch (e) {
res.error = e.message;
diff --git a/src/steps/csp.js b/src/steps/csp.js
new file mode 100644
index 00000000..eebf527b
--- /dev/null
+++ b/src/steps/csp.js
@@ -0,0 +1,235 @@
+/*
+ * Copyright 2024 Adobe. All rights reserved.
+ * This file is licensed to you under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License. You may obtain a copy
+ * of the License at http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software distributed under
+ * the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
+ * OF ANY KIND, either express or implied. See the License for the specific language
+ * governing permissions and limitations under the License.
+ */
+import { select } from 'hast-util-select';
+import { Tokenizer } from 'parse5';
+import { remove } from 'unist-util-remove';
+import { visit } from 'unist-util-visit';
+// eslint-disable-next-line import/no-unresolved
+import cryptoImpl from '#crypto';
+
+export const NONCE_AEM = '\'nonce-aem\'';
+
+/**
+ * Parse a CSP string into its directives
+ * @param {string | undefined | null} csp
+ * @returns {Object}
+ */
+function parseCSP(csp) {
+ if (!csp) {
+ return {};
+ }
+
+ const parts = csp.split(';');
+ const result = {};
+ parts.forEach((part) => {
+ const [directive, ...values] = part.trim().split(' ');
+ result[directive] = values.join(' ');
+ });
+ return result;
+}
+
+/**
+ * Computes where nonces should be applied
+ * @param {string | null | undefined} metaCSPText The actual CSP value from the meta tag
+ * @param {string | null | undefined} headersCSPText The actual CSP value from the headers
+ * @returns {scriptNonce: boolean, styleNonce: boolean}
+ */
+function shouldApplyNonce(metaCSPText, headersCSPText) {
+ const metaBased = parseCSP(metaCSPText);
+ const headersBased = parseCSP(headersCSPText);
+ return {
+ scriptNonce: metaBased['script-src']?.includes(NONCE_AEM)
+ || headersBased['script-src']?.includes(NONCE_AEM),
+ styleNonce: metaBased['style-src']?.includes(NONCE_AEM)
+ || headersBased['style-src']?.includes(NONCE_AEM),
+ };
+}
+
+/**
+ * Create a nonce for CSP
+ * @returns {string}
+ */
+function createNonce() {
+ const array = new Uint8Array(18);
+ cryptoImpl.getRandomValues(array);
+ return btoa(String.fromCharCode(...array));
+}
+
+/**
+ * Get the applied CSP header from a response
+ * @param {PipelineResponse} res
+ * @returns {string}
+ */
+export function getHeaderCSP(res) {
+ return res.headers?.get('content-security-policy');
+}
+
+/**
+ * Apply CSP with nonces on an AST
+ * @param {PipelineResponse} res
+ * @param {Object} tree
+ * @param {Object} metaCSP
+ * @param {string} headersCSP
+ */
+function createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP) {
+ const nonce = createNonce();
+ const { scriptNonce, styleNonce } = shouldApplyNonce(metaCSP?.properties.content, headersCSP);
+
+ if (metaCSP) {
+ metaCSP.properties.content = metaCSP.properties.content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`);
+ }
+
+ if (headersCSP) {
+ res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
+ }
+
+ visit(tree, (node) => {
+ if (scriptNonce && node.tagName === 'script' && node.properties?.nonce === 'aem') {
+ node.properties.nonce = nonce;
+ return;
+ }
+
+ if (styleNonce
+ && (node.tagName === 'style' || (node.tagName === 'link' && node.properties?.rel?.[0] === 'stylesheet'))
+ && node.properties?.nonce === 'aem'
+ ) {
+ node.properties.nonce = nonce;
+ }
+ });
+}
+
+export function checkResponseBodyForMetaBasedCSP(res) {
+ return res.body?.includes('http-equiv="content-security-policy"')
+ || res.body?.includes('http-equiv="Content-Security-Policy"');
+}
+
+export function checkResponseBodyForAEMNonce(res) {
+ /*
+ we only look for 'nonce-aem' (single quote) to see if there is a meta CSP with nonce
+ we don't want to generate nonces if they appear just on script/style tags,
+ as those have no effect without the actual CSP meta (or header).
+ this means it is ok to not check for the "nonce-aem" (double quotes)
+ */
+ return res.body?.includes(NONCE_AEM);
+}
+
+export function getMetaCSP(tree) {
+ return select('meta[http-equiv="content-security-policy"]', tree)
+ || select('meta[http-equiv="Content-Security-Policy"]', tree);
+}
+
+export function contentSecurityPolicyOnAST(res, tree) {
+ const metaCSP = getMetaCSP(tree);
+ const headersCSP = getHeaderCSP(res);
+
+ if (!metaCSP && !headersCSP) {
+ // No CSP defined
+ return;
+ }
+
+ // CSP with nonce
+ if (metaCSP?.properties.content.includes(NONCE_AEM) || headersCSP?.includes(NONCE_AEM)) {
+ createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP);
+ }
+
+ if (metaCSP?.properties['move-as-header'] === 'true') {
+ if (!headersCSP) {
+ // if we have a CSP in meta but no CSP in headers
+ // we can move the CSP from meta to headers, if requested
+ res.headers.set('content-security-policy', metaCSP.properties.content);
+ remove(tree, null, metaCSP);
+ } else {
+ delete metaCSP.properties['move-as-header'];
+ }
+ }
+}
+
+export function contentSecurityPolicyOnCode(state, res) {
+ if (state.type !== 'html') {
+ return;
+ }
+
+ const cspHeader = getHeaderCSP(res);
+ if (!(
+ cspHeader?.includes(NONCE_AEM)
+ || (checkResponseBodyForMetaBasedCSP(res) && checkResponseBodyForAEMNonce(res))
+ )) {
+ return;
+ }
+
+ const nonce = createNonce();
+ let { scriptNonce, styleNonce } = shouldApplyNonce(null, cspHeader);
+
+ const html = res.body;
+ const chunks = [];
+ let lastOffset = 0;
+
+ const getRawHTML = (token) => html.slice(token.location.startOffset, token.location.endOffset);
+
+ const tokenizer = new Tokenizer({
+ sourceCodeLocationInfo: true,
+ }, {
+ onStartTag(tag) {
+ chunks.push(html.slice(lastOffset, tag.location.startOffset));
+ try {
+ if (tag.tagName === 'meta'
+ && tag.attrs.find(
+ (attr) => attr.name.toLowerCase() === 'http-equiv' && attr.value.toLowerCase() === 'content-security-policy',
+ )
+ ) {
+ const contentAttr = tag.attrs.find((attr) => attr.name.toLowerCase() === 'content');
+ if (contentAttr) {
+ ({ scriptNonce, styleNonce } = shouldApplyNonce(contentAttr.value, cspHeader));
+
+ if (!cspHeader && tag.attrs.find((attr) => attr.name === 'move-as-header' && attr.value === 'true')) {
+ res.headers.set('content-security-policy', contentAttr.value.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
+ return; // don't push the chunk so it gets removed from the response body
+ }
+ chunks.push(getRawHTML(tag).replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
+ return;
+ }
+ }
+
+ if (scriptNonce && tag.tagName === 'script' && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) {
+ chunks.push(getRawHTML(tag).replace(/nonce="aem"/i, `nonce="${nonce}"`));
+ return;
+ }
+
+ if (styleNonce && (tag.tagName === 'style' || tag.tagName === 'link') && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) {
+ chunks.push(getRawHTML(tag).replace(/nonce="aem"/i, `nonce="${nonce}"`));
+ return;
+ }
+
+ chunks.push(getRawHTML(tag));
+ } finally {
+ lastOffset = tag.location.endOffset;
+ }
+ },
+ // no-op callbacks. onStartTag will take care of these
+ onComment(_) {},
+ onDoctype(_) {},
+ onEndTag(_) {},
+ onEof(_) {},
+ onCharacter(_) {},
+ onNullCharacter(_) {},
+ onWhitespaceCharacter(_) {},
+ onParseError(_) {},
+ });
+
+ tokenizer.write(html);
+ chunks.push(html.slice(lastOffset));
+
+ res.body = chunks.join('');
+ if (cspHeader) {
+ res.headers.set('content-security-policy', cspHeader.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
+ }
+}
diff --git a/src/steps/fetch-404.js b/src/steps/fetch-404.js
index 018506e5..01d3bfd5 100644
--- a/src/steps/fetch-404.js
+++ b/src/steps/fetch-404.js
@@ -10,6 +10,7 @@
* governing permissions and limitations under the License.
*/
import { extractLastModified, recordLastModified } from '../utils/last-modified.js';
+import { contentSecurityPolicyOnCode } from './csp.js';
import { getPathKey } from './set-x-surrogate-key-header.js';
/**
@@ -34,6 +35,7 @@ export default async function fetch404(state, req, res) {
// keep 404 response status
res.body = ret.body;
+ contentSecurityPolicyOnCode(state, res);
res.headers.set('last-modified', ret.headers.get('last-modified'));
res.headers.set('content-type', 'text/html; charset=utf-8');
}
diff --git a/src/steps/render-code.js b/src/steps/render-code.js
index 4c954568..46a00592 100644
--- a/src/steps/render-code.js
+++ b/src/steps/render-code.js
@@ -10,6 +10,9 @@
* governing permissions and limitations under the License.
*/
import mime from 'mime';
+import {
+ contentSecurityPolicyOnCode,
+} from './csp.js';
const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i;
@@ -32,4 +35,6 @@ export default async function renderCode(state, req, res) {
}
}
res.headers.set('content-type', contentType);
+
+ contentSecurityPolicyOnCode(state, res);
}
diff --git a/src/steps/render.js b/src/steps/render.js
index 0f72f868..fe898516 100644
--- a/src/steps/render.js
+++ b/src/steps/render.js
@@ -15,6 +15,7 @@ import { h } from 'hastscript';
import { unified } from 'unified';
import rehypeParse from 'rehype-parse';
import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
+import { contentSecurityPolicyOnAST } from './csp.js';
function appendElement($parent, $el) {
if ($el) {
@@ -102,6 +103,8 @@ export default async function render(state, req, res) {
const $headHtml = await unified()
.use(rehypeParse, { fragment: true })
.parse(headHtml);
+
+ contentSecurityPolicyOnAST(res, $headHtml);
$head.children.push(...$headHtml.children);
} else {
appendElement($head, createElement('meta', 'name', 'viewport', 'content', 'width=device-width, initial-scale=1'));
diff --git a/test/FileS3Loader.js b/test/FileS3Loader.js
index af6d9c15..ac211108 100644
--- a/test/FileS3Loader.js
+++ b/test/FileS3Loader.js
@@ -24,6 +24,7 @@ export class FileS3Loader {
},
statusCodeOverrides: {},
rewrites: [],
+ bodyRewrites: {},
headerOverride: {},
});
}
@@ -33,6 +34,11 @@ export class FileS3Loader {
return this;
}
+ rewriteBody(fileName, body) {
+ this.bodyRewrites[fileName] = body;
+ return this;
+ }
+
status(fileName, status) {
this.statusCodeOverrides[fileName] = status;
return this;
@@ -68,11 +74,21 @@ export class FileS3Loader {
};
}
- const file = path.resolve(dir, fileName);
+ const bodyRewrite = this.bodyRewrites[fileName];
+
try {
- const body = await readFile(file, 'utf-8');
- // eslint-disable-next-line no-console
- console.log(`FileS3Loader: loading ${bucketId}/${fileName} -> 200`);
+ let body;
+ if (bodyRewrite) {
+ body = bodyRewrite;
+ // eslint-disable-next-line no-console
+ console.log(`FileS3Loader: loading ${bucketId}/${fileName} with re-written body -> 200`);
+ } else {
+ const file = path.resolve(dir, fileName);
+ body = await readFile(file, 'utf-8');
+ // eslint-disable-next-line no-console
+ console.log(`FileS3Loader: loading ${bucketId}/${fileName} -> 200`);
+ }
+
return {
status: 200,
body,
diff --git a/test/fixtures/code/super-test/404-csp-nonce.html b/test/fixtures/code/super-test/404-csp-nonce.html
new file mode 100644
index 00000000..0b4aa8c5
--- /dev/null
+++ b/test/fixtures/code/super-test/404-csp-nonce.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Page not found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Page Not Found
+
+ Go home
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/fixtures/code/super-test/404-csp-nonce.ref.html b/test/fixtures/code/super-test/404-csp-nonce.ref.html
new file mode 100644
index 00000000..a1a238e2
--- /dev/null
+++ b/test/fixtures/code/super-test/404-csp-nonce.ref.html
@@ -0,0 +1,69 @@
+
+
+
+
+
+ Page not found
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Page Not Found
+
+ Go home
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/test/fixtures/code/super-test/static-nonce-header.html b/test/fixtures/code/super-test/static-nonce-header.html
new file mode 100644
index 00000000..dabff8fa
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-header.html
@@ -0,0 +1,37 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-header.ref.html b/test/fixtures/code/super-test/static-nonce-header.ref.html
new file mode 100644
index 00000000..5ee83800
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-header.ref.html
@@ -0,0 +1,37 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta-different.html b/test/fixtures/code/super-test/static-nonce-meta-different.html
new file mode 100644
index 00000000..b660b86f
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta-different.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta-different.ref.html b/test/fixtures/code/super-test/static-nonce-meta-different.ref.html
new file mode 100644
index 00000000..b660b86f
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta-different.ref.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta-move-as-header.html b/test/fixtures/code/super-test/static-nonce-meta-move-as-header.html
new file mode 100644
index 00000000..70cbe1a7
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta-move-as-header.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta-move-as-header.ref.html b/test/fixtures/code/super-test/static-nonce-meta-move-as-header.ref.html
new file mode 100644
index 00000000..7a8b1c19
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta-move-as-header.ref.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta.html b/test/fixtures/code/super-test/static-nonce-meta.html
new file mode 100644
index 00000000..4a3e2a06
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/code/super-test/static-nonce-meta.ref.html b/test/fixtures/code/super-test/static-nonce-meta.ref.html
new file mode 100644
index 00000000..9fb09c12
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-meta.ref.html
@@ -0,0 +1,38 @@
+
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Nonce Test
+
+
+
+
+
+
+
+
+
+
diff --git a/test/fixtures/content/nonce-headers-different.html b/test/fixtures/content/nonce-headers-different.html
new file mode 100644
index 00000000..ce02bb02
--- /dev/null
+++ b/test/fixtures/content/nonce-headers-different.html
@@ -0,0 +1,30 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-headers-different.md b/test/fixtures/content/nonce-headers-different.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-headers-different.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-headers-meta.html b/test/fixtures/content/nonce-headers-meta.html
new file mode 100644
index 00000000..547995dc
--- /dev/null
+++ b/test/fixtures/content/nonce-headers-meta.html
@@ -0,0 +1,31 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-headers-meta.md b/test/fixtures/content/nonce-headers-meta.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-headers-meta.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-headers.html b/test/fixtures/content/nonce-headers.html
new file mode 100644
index 00000000..f941a05b
--- /dev/null
+++ b/test/fixtures/content/nonce-headers.html
@@ -0,0 +1,30 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-headers.md b/test/fixtures/content/nonce-headers.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-headers.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-meta-different.html b/test/fixtures/content/nonce-meta-different.html
new file mode 100644
index 00000000..c5505253
--- /dev/null
+++ b/test/fixtures/content/nonce-meta-different.html
@@ -0,0 +1,31 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-meta-different.md b/test/fixtures/content/nonce-meta-different.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-meta-different.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-meta-move-as-header.html b/test/fixtures/content/nonce-meta-move-as-header.html
new file mode 100644
index 00000000..8733139a
--- /dev/null
+++ b/test/fixtures/content/nonce-meta-move-as-header.html
@@ -0,0 +1,30 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-meta-move-as-header.md b/test/fixtures/content/nonce-meta-move-as-header.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-meta-move-as-header.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-meta.html b/test/fixtures/content/nonce-meta.html
new file mode 100644
index 00000000..f6d3289e
--- /dev/null
+++ b/test/fixtures/content/nonce-meta.html
@@ -0,0 +1,31 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-meta.md b/test/fixtures/content/nonce-meta.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-meta.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-script-only.html b/test/fixtures/content/nonce-script-only.html
new file mode 100644
index 00000000..50b5d03c
--- /dev/null
+++ b/test/fixtures/content/nonce-script-only.html
@@ -0,0 +1,30 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-script-only.md b/test/fixtures/content/nonce-script-only.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-script-only.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/fixtures/content/nonce-style-only.html b/test/fixtures/content/nonce-style-only.html
new file mode 100644
index 00000000..a21c0bd0
--- /dev/null
+++ b/test/fixtures/content/nonce-style-only.html
@@ -0,0 +1,30 @@
+
+
+ ACME CORP
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Nonce Test
+
+
+
+
diff --git a/test/fixtures/content/nonce-style-only.md b/test/fixtures/content/nonce-style-only.md
new file mode 100644
index 00000000..02445e40
--- /dev/null
+++ b/test/fixtures/content/nonce-style-only.md
@@ -0,0 +1 @@
+# Nonce Test
diff --git a/test/rendering.test.js b/test/rendering.test.js
index f0d68b3d..7da20719 100644
--- a/test/rendering.test.js
+++ b/test/rendering.test.js
@@ -11,14 +11,44 @@
*/
/* eslint-env mocha */
import assert from 'assert';
+import esmock from 'esmock';
+import crypto from 'crypto';
import path from 'path';
import { readFile } from 'fs/promises';
import { JSDOM } from 'jsdom';
import { assertHTMLEquals } from './utils.js';
-import { htmlPipe, PipelineRequest, PipelineState } from '../src/index.js';
import { FileS3Loader } from './FileS3Loader.js';
+const mockCrypto = {
+ getRandomValues: (array) => {
+ const mockRandomValues = new TextEncoder().encode('rA4nd0mmmrA4nd0mmm');
+ for (let i = 0; i < array.length; i += 1) {
+ array[i] = mockRandomValues[i];
+ }
+ },
+};
+
+const { htmlPipe, PipelineRequest, PipelineState } = await esmock('../src/index.js', {
+ '../src/html-pipe.js': await esmock('../src/html-pipe.js', {
+ '../src/steps/render.js': await esmock('../src/steps/render.js', {
+ '../src/steps/csp.js': await esmock('../src/steps/csp.js', {
+ '#crypto': mockCrypto,
+ }),
+ }),
+ '../src/steps/render-code.js': await esmock('../src/steps/render-code.js', {
+ '../src/steps/csp.js': await esmock('../src/steps/csp.js', {
+ '#crypto': mockCrypto,
+ }),
+ }),
+ '../src/steps/fetch-404.js': await esmock('../src/steps/fetch-404.js', {
+ '../src/steps/csp.js': await esmock('../src/steps/csp.js', {
+ '#crypto': mockCrypto,
+ }),
+ }),
+ }),
+});
+
describe('Rendering', () => {
let loader;
@@ -50,6 +80,48 @@ describe('Rendering', () => {
return res;
}
+ async function defaultHelixConfig() {
+ const configFile = path.resolve(__testdir, 'fixtures', 'code', 'super-test', 'helix-config.json');
+ const config = await readFile(configFile, 'utf-8');
+ return JSON.parse(config);
+ }
+
+ async function defaultDotHelixConfigAll() {
+ const configFile = path.resolve(__testdir, 'fixtures', 'content', '.helix', 'config-all.json');
+ const config = await readFile(configFile, 'utf-8');
+ return JSON.parse(config);
+ }
+
+ async function testRenderCode(url, expStatus = 200, spec = null, forceCompare = false) {
+ if (!(url instanceof URL)) {
+ // eslint-disable-next-line no-param-reassign
+ url = new URL(`https://helix-pages.com/${url}`);
+ }
+
+ // eslint-disable-next-line no-param-reassign
+ spec = spec || url.pathname.split('/').pop().split('.')[0];
+ const expFile = path.resolve(__testdir, 'fixtures', 'code/super-test', `${spec}.ref.html`);
+ let expHtml = null;
+ try {
+ expHtml = await readFile(expFile, 'utf-8');
+ } catch {
+ // ignore
+ }
+
+ const response = await render(url, '', expStatus);
+ assert.strictEqual(response.status, expStatus);
+ const actHtml = response.body;
+ // console.log(actHtml);
+ if (expStatus === 200 || forceCompare) {
+ /*
+ we use strict equality here because we want to ensure minimal intrusion in the customer HTML
+ by the rendering pipeline. JSDOM will normalize the HTML, so we can't use it for comparison.
+ */
+ assert.strictEqual(actHtml, expHtml);
+ }
+ return response;
+ }
+
// eslint-disable-next-line default-param-last
async function testRender(url, domSelector = 'main', expStatus) {
if (!(url instanceof URL)) {
@@ -69,7 +141,7 @@ describe('Rendering', () => {
}
const response = await render(url, '', expStatus);
const actHtml = response.body;
- console.log(actHtml);
+ // console.log(actHtml);
if (expStatus === 200) {
const $actMain = new JSDOM(actHtml).window.document.querySelector(domSelector);
const $expMain = new JSDOM(expHtml).window.document.querySelector(domSelector);
@@ -353,6 +425,252 @@ describe('Rendering', () => {
await testRender('head-with-script', 'html');
});
+ it('renders csp nonce meta', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+ const { headers } = await testRender('nonce-meta', 'html');
+ assert.ok(!headers.get('content-security-policy'));
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce headers', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ loader.rewriteBody(
+ '.helix/config-all.json',
+ JSON.stringify({
+ ...await defaultDotHelixConfigAll(),
+ headers: {
+ data: {
+ '/**': [
+ {
+ key: 'Content-Security-Policy',
+ // eslint-disable-next-line quotes
+ value: `script-src 'nonce-aem' 'strict-dynamic'; style-src 'nonce-aem'; base-uri 'self'; object-src 'none';`,
+ },
+ ],
+ },
+ lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT',
+ },
+ }),
+ );
+ const { headers } = await testRender('nonce-headers', 'html');
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; style-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce metadata - move as header', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ // eslint-disable-next-line quotes
+ html: `\n`
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ const { headers } = await testRender('nonce-meta-move-as-header', 'html');
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; style-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce headers and metadata - move as header', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ // eslint-disable-next-line quotes
+ html: `\n`
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ loader.rewriteBody(
+ '.helix/config-all.json',
+ JSON.stringify({
+ ...await defaultDotHelixConfigAll(),
+ headers: {
+ data: {
+ '/**': [
+ {
+ key: 'content-security-policy',
+ value: 'frame-ancestors \'self\'',
+ },
+ ],
+ },
+ lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT',
+ },
+ }),
+ );
+
+ const { headers } = await testRender('nonce-headers-meta', 'html');
+ assert.strictEqual(headers.get('content-security-policy'), 'frame-ancestors \'self\'');
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce script only', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ loader.rewriteBody(
+ '.helix/config-all.json',
+ JSON.stringify({
+ ...await defaultDotHelixConfigAll(),
+ headers: {
+ data: {
+ '/**': [
+ {
+ key: 'content-security-policy',
+ // eslint-disable-next-line quotes
+ value: `script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';`,
+ },
+ ],
+ },
+ lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT',
+ },
+ }),
+ );
+
+ const { headers } = await testRender('nonce-script-only', 'html');
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('does not alter csp nonce if already set to a different value by meta', async () => {
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ // eslint-disable-next-line quotes
+ html: `\n`
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ const { headers } = await testRender('nonce-meta-different', 'html');
+ assert.ok(!headers.get('content-security-policy'));
+ });
+
+ it('does not alter csp nonce if already set to a different value by header', async () => {
+ loader.rewriteBody(
+ 'super-test/helix-config.json',
+ JSON.stringify({
+ ...await defaultHelixConfig(),
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ }),
+ );
+
+ loader.rewriteBody(
+ '.helix/config-all.json',
+ JSON.stringify({
+ ...await defaultDotHelixConfigAll(),
+ headers: {
+ data: {
+ '/**': [
+ {
+ key: 'Content-Security-Policy',
+ // eslint-disable-next-line quotes
+ value: `script-src 'nonce-r4nD0m' 'strict-dynamic'; style-src 'nonce-r4nD0m'; base-uri 'self'; object-src 'none';`,
+ },
+ ],
+ },
+ lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT',
+ },
+ }),
+ );
+ const { headers } = await testRender('nonce-headers-different', 'html');
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-r4nD0m' 'strict-dynamic'; style-src 'nonce-r4nD0m'; base-uri 'self'; object-src 'none';`);
+ });
+
it('renders header correctly if head.html is missing', async () => {
loader.rewrite('super-test/helix-config.json', 'super-test/helix-config-no-head-html.json');
await testRender('no-head-html', 'html');
@@ -445,6 +763,80 @@ describe('Rendering', () => {
assert.strictEqual(body.trim(), '');
});
+ it('renders 404 html from codebus and applies csp', async () => {
+ loader
+ .rewrite('404.html', 'super-test/404-csp-nonce.html')
+ .headers('super-test/404-test.html', 'x-amz-meta-x-source-last-modified', 'Mon, 12 Oct 2009 17:50:00 GMT');
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ const { headers } = await testRenderCode('not-found', 404, '404-csp-nonce', true);
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp from header with nonce', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ loader.rewriteBody(
+ '.helix/config-all.json',
+ JSON.stringify({
+ ...await defaultDotHelixConfigAll(),
+ headers: {
+ data: {
+ '/**': [
+ {
+ key: 'content-security-policy',
+ // eslint-disable-next-line quotes
+ value: `script-src 'nonce-aem' 'strict-dynamic'; style-src 'nonce-aem'; base-uri 'self'; object-src 'none';`,
+ },
+ ],
+ },
+ lastModified: 'Thu, 01 Jan 1970 00:00:00 GMT',
+ },
+ }),
+ );
+
+ const { headers } = await testRenderCode(new URL('https://helix-pages.com/static-nonce-header.html'));
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; style-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp from meta with nonce', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ const { headers } = await testRenderCode(new URL('https://helix-pages.com/static-nonce-meta.html'));
+ assert.ok(!headers.get('content-security-policy'));
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp from meta with nonce moved as header', async () => {
+ const originalRandomBytes = crypto.randomBytes;
+ try {
+ crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ const { headers } = await testRenderCode(new URL('https://helix-pages.com/static-nonce-meta-move-as-header.html'));
+ // eslint-disable-next-line quotes
+ assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t' 'strict-dynamic'; style-src 'nonce-ckE0bmQwbW1tckE0bmQwbW1t'; base-uri 'self'; object-src 'none';`);
+ } finally {
+ crypto.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp with different nonce without altering', async () => {
+ const { headers } = await testRenderCode(new URL('https://helix-pages.com/static-nonce-meta-different.html'));
+ assert.ok(!headers.get('content-security-policy'));
+ });
+
it('renders 400 for invalid helix-config', async () => {
loader.rewrite('super-test/helix-config.json', 'super-test/helix-config.corrupt');
await testRender('no-head-html', 'html', 400);