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 + + + + + + + + + + + + +
+
+
+ + 404 + +

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 + + + + + + + + + + + + +
+
+
+ + 404 + +

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);