diff --git a/package-lock.json b/package-lock.json
index 6ec9292e..3eb332b7 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -24,6 +24,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 efc6515a..33fdeee3 100644
--- a/package.json
+++ b/package.json
@@ -57,6 +57,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 4ddee834..e5221e2b 100644
--- a/src/html-pipe.js
+++ b/src/html-pipe.js
@@ -149,6 +149,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');
@@ -165,6 +166,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);
@@ -172,7 +174,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..d6e48658
--- /dev/null
+++ b/src/steps/csp.js
@@ -0,0 +1,233 @@
+/*
+ * 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() {
+ return cryptoImpl.randomBytes(18).toString('base64');
+}
+
+/**
+ * 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 592406a0..1c65bbf5 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 { computeContentPathKey, computeCodePathKey } 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 94f5b657..9e747187 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,7 @@ 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);
}
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-fragment.html b/test/fixtures/code/super-test/static-nonce-fragment.html
new file mode 100644
index 00000000..c8f32793
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-fragment.html
@@ -0,0 +1,4 @@
+
+
+
+Nonce Test
diff --git a/test/fixtures/code/super-test/static-nonce-fragment.ref.html b/test/fixtures/code/super-test/static-nonce-fragment.ref.html
new file mode 100644
index 00000000..21cbd4a5
--- /dev/null
+++ b/test/fixtures/code/super-test/static-nonce-fragment.ref.html
@@ -0,0 +1,4 @@
+
+
+
+Nonce Test
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 0b1a5ee5..a70ff1f5 100644
--- a/test/rendering.test.js
+++ b/test/rendering.test.js
@@ -18,6 +18,8 @@ import { assertHTMLEquals } from './utils.js';
import { htmlPipe, PipelineRequest, PipelineState } from '../src/index.js';
import { FileS3Loader } from './FileS3Loader.js';
+// eslint-disable-next-line import/no-unresolved
+import cryptoImpl from '#crypto';
const METADATA = {
data: {
@@ -186,13 +188,14 @@ describe('Rendering', () => {
} catch {
// ignore
}
+ // console.log(expHtml);
if (!expStatus) {
// eslint-disable-next-line no-param-reassign
expStatus = expHtml === null ? 404 : 200;
}
const response = await render(url, '', expStatus, partition);
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);
@@ -220,6 +223,36 @@ describe('Rendering', () => {
return response;
}
+ 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;
+ }
+
describe('Section DIVS', () => {
it('renders document with 1 section correctly', async () => {
await testRender('one-section');
@@ -513,6 +546,190 @@ describe('Rendering', () => {
await testRender('head-with-script', 'html');
});
+ it('renders csp nonce meta', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ head: {
+ // eslint-disable-next-line quotes
+ html: `\n`
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ };
+ const { headers } = await testRender('nonce-meta', 'html');
+ assert.ok(!headers.get('content-security-policy'));
+ } finally {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce headers', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ headers: {
+ '/**': [
+ {
+ 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';`,
+ },
+ ],
+ },
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ };
+ 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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce metadata - move as header', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ 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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce headers and metadata - move as header', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ headers: {
+ '/**': [
+ {
+ key: 'content-security-policy',
+ value: 'frame-ancestors \'self\'',
+ },
+ ],
+ },
+ head: {
+ // eslint-disable-next-line quotes
+ html: `\n`
+ + '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ };
+ const { headers } = await testRender('nonce-headers-meta', 'html');
+ assert.strictEqual(headers.get('content-security-policy'), 'frame-ancestors \'self\'');
+ } finally {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders csp nonce script only', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ headers: {
+ '/**': [
+ {
+ key: 'content-security-policy',
+ // eslint-disable-next-line quotes
+ value: `script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';`,
+ },
+ ],
+ },
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ };
+ 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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('does not alter csp nonce if already set to a different value by meta', async () => {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ 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 () => {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ headers: {
+ '/**': [
+ {
+ 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';`,
+ },
+ ],
+ },
+ head: {
+ html: '\n'
+ + '\n'
+ + '\n'
+ + '\n'
+ + '',
+ },
+ };
+ 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 404 if content not found', async () => {
await testRender('not-found', 'html');
// preview (code coverage)
@@ -808,5 +1025,84 @@ describe('Rendering', () => {
link: '; rel=modulepreload; as=script; crossorigin=use-credentials',
});
});
+
+ it('renders static html from the codebus and applies csp from header with nonce', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ config = {
+ ...DEFAULT_CONFIG,
+ headers: {
+ '/**': [
+ {
+ 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';`,
+ },
+ ],
+ },
+ };
+
+ 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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp from meta with nonce', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ it('renders static html from the codebus and applies csp from meta with nonce moved as header', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.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 {
+ cryptoImpl.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 static html from the codebus and applies csp without altering the HTML structure', async () => {
+ const originalRandomBytes = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm');
+ const { headers } = await testRenderCode(new URL('https://helix-pages.com/static-nonce-fragment.html'));
+ assert.ok(!headers.get('content-security-policy'));
+ } finally {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
+
+ 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 = cryptoImpl.randomBytes;
+ try {
+ cryptoImpl.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 {
+ cryptoImpl.randomBytes = originalRandomBytes;
+ }
+ });
});
});