From de852c9355db0226de2e8269753276c39fcc0976 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 6 Nov 2024 16:50:48 +0100 Subject: [PATCH 1/9] feat: Enable CSP with nonce --- src/html-pipe.js | 3 +- src/steps/csp.js | 116 ++++++++ src/steps/render-code.js | 25 ++ src/steps/render.js | 2 + .../code/super-test/static-nonce-header.html | 37 +++ .../super-test/static-nonce-header.ref.html | 37 +++ .../static-nonce-meta-different.html | 38 +++ .../static-nonce-meta-different.ref.html | 38 +++ .../static-nonce-meta-move-as-header.html | 38 +++ .../static-nonce-meta-move-as-header.ref.html | 37 +++ .../code/super-test/static-nonce-meta.html | 38 +++ .../super-test/static-nonce-meta.ref.html | 38 +++ .../content/nonce-headers-different.html | 30 ++ .../content/nonce-headers-different.md | 1 + test/fixtures/content/nonce-headers-meta.html | 31 ++ test/fixtures/content/nonce-headers-meta.md | 1 + test/fixtures/content/nonce-headers.html | 30 ++ test/fixtures/content/nonce-headers.md | 1 + .../content/nonce-meta-different.html | 31 ++ test/fixtures/content/nonce-meta-different.md | 1 + .../content/nonce-meta-move-as-header.html | 30 ++ .../content/nonce-meta-move-as-header.md | 1 + test/fixtures/content/nonce-meta.html | 31 ++ test/fixtures/content/nonce-meta.md | 1 + test/fixtures/content/nonce-script-only.html | 30 ++ test/fixtures/content/nonce-script-only.md | 1 + test/fixtures/content/nonce-style-only.html | 30 ++ test/fixtures/content/nonce-style-only.md | 1 + test/rendering.test.js | 265 +++++++++++++++++- 29 files changed, 961 insertions(+), 2 deletions(-) create mode 100644 src/steps/csp.js create mode 100644 test/fixtures/code/super-test/static-nonce-header.html create mode 100644 test/fixtures/code/super-test/static-nonce-header.ref.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta-different.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta-different.ref.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta-move-as-header.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta-move-as-header.ref.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta.html create mode 100644 test/fixtures/code/super-test/static-nonce-meta.ref.html create mode 100644 test/fixtures/content/nonce-headers-different.html create mode 100644 test/fixtures/content/nonce-headers-different.md create mode 100644 test/fixtures/content/nonce-headers-meta.html create mode 100644 test/fixtures/content/nonce-headers-meta.md create mode 100644 test/fixtures/content/nonce-headers.html create mode 100644 test/fixtures/content/nonce-headers.md create mode 100644 test/fixtures/content/nonce-meta-different.html create mode 100644 test/fixtures/content/nonce-meta-different.md create mode 100644 test/fixtures/content/nonce-meta-move-as-header.html create mode 100644 test/fixtures/content/nonce-meta-move-as-header.md create mode 100644 test/fixtures/content/nonce-meta.html create mode 100644 test/fixtures/content/nonce-meta.md create mode 100644 test/fixtures/content/nonce-script-only.html create mode 100644 test/fixtures/content/nonce-script-only.md create mode 100644 test/fixtures/content/nonce-style-only.html create mode 100644 test/fixtures/content/nonce-style-only.md 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..ef3b0ca9 --- /dev/null +++ b/src/steps/csp.js @@ -0,0 +1,116 @@ +/* + * 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 crypto from 'crypto'; +import { select, selectAll } from 'hast-util-select'; +import { remove } from 'unist-util-remove'; + +export const NONCE_AEM = '\'nonce-aem\''; + +function parseCSP(csp) { + const parts = csp.split(';'); + const result = {}; + parts.forEach((part) => { + const [directive, ...values] = part.trim().split(' '); + result[directive] = values.join(' '); + }); + return result; +} + +function shouldApplyNonce(csp) { + const parsedCSP = parseCSP(csp); + return { + scriptNonce: parsedCSP['script-src']?.includes(NONCE_AEM), + styleNonce: parsedCSP['style-src']?.includes(NONCE_AEM), + }; +} + +function createAndApplyNonce(res, tree, metaCSP, headersCSP) { + const nonce = crypto.randomBytes(16).toString('base64'); + let scriptNonceResult = false; + let styleNonceResult = false; + + if (metaCSP) { + const { scriptNonce, styleNonce } = shouldApplyNonce(metaCSP.properties.content); + scriptNonceResult ||= scriptNonce; + styleNonceResult ||= styleNonce; + metaCSP.properties.content = metaCSP.properties.content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`); + } + + if (headersCSP) { + const { scriptNonce, styleNonce } = shouldApplyNonce(headersCSP); + scriptNonceResult ||= scriptNonce; + styleNonceResult ||= styleNonce; + res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); + } + + if (scriptNonceResult) { + selectAll('script[nonce="aem"]', tree).forEach((el) => { + el.properties.nonce = nonce; + }); + } + + if (styleNonceResult) { + selectAll('style[nonce="aem"]', tree).forEach((el) => { + el.properties.nonce = nonce; + }); + selectAll('link[rel=stylesheet][nonce="aem"]', tree).forEach((el) => { + el.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) { + 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 getHeaderCSP(res) { + return res.headers?.get('content-security-policy'); +} + +export function contentSecurityPolicy(res, tree) { + const metaCSP = getMetaCSP(tree); + const headersCSP = getHeaderCSP(res); + + if (!metaCSP && !headersCSP) { + // No CSP defined + return; + } + + // CSP with nonce + if ( + (metaCSP && metaCSP.properties.content.includes(NONCE_AEM)) + || (headersCSP && headersCSP.includes(NONCE_AEM)) + ) { + createAndApplyNonce(res, tree, metaCSP, headersCSP); + } + + if (metaCSP && metaCSP.properties['move-as-header']) { + 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']; + } + } +} diff --git a/src/steps/render-code.js b/src/steps/render-code.js index 4c954568..e37b6ccf 100644 --- a/src/steps/render-code.js +++ b/src/steps/render-code.js @@ -10,6 +10,16 @@ * governing permissions and limitations under the License. */ import mime from 'mime'; +import { unified } from 'unified'; +import rehypeParse from 'rehype-parse'; +import { + contentSecurityPolicy, + getHeaderCSP, + checkResponseBodyForMetaBasedCSP, + NONCE_AEM, + checkResponseBodyForAEMNonce, +} from './csp.js'; +import tohtml from './stringify-response.js'; const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i; @@ -32,4 +42,19 @@ export default async function renderCode(state, req, res) { } } res.headers.set('content-type', contentType); + + const cspHeader = getHeaderCSP(res); + if (state.type === 'html' + && (cspHeader?.includes(NONCE_AEM) || ( + checkResponseBodyForAEMNonce(res) && checkResponseBodyForMetaBasedCSP(res)) + ) + ) { + res.document = await unified() + .use(rehypeParse) + .parse(res.body); + res.body = undefined; + + contentSecurityPolicy(res, res.document); + await tohtml(state, req, res); + } } diff --git a/src/steps/render.js b/src/steps/render.js index ecd560f3..771ffa62 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 { contentSecurityPolicy } 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); + contentSecurityPolicy(res, $headHtml); $head.children.push(...$headHtml.children); } 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..e4ff52db --- /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..21330d83 --- /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..e4ff52db --- /dev/null +++ b/test/fixtures/code/super-test/static-nonce-meta-move-as-header.ref.html @@ -0,0 +1,37 @@ + + + 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..56690522 --- /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..403888eb --- /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..1967c2c3 --- /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..9ad1f4e3 --- /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..8c1182d8 --- /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..970f7cb5 --- /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..c2db1023 --- /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 d1218e8e..af68ff03 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -11,6 +11,7 @@ */ /* eslint-env mocha */ import assert from 'assert'; +import crypto from 'crypto'; import path from 'path'; import { readFile } from 'fs/promises'; import { JSDOM } from 'jsdom'; @@ -186,13 +187,13 @@ 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); if (expStatus === 200) { const $actMain = new JSDOM(actHtml).window.document.querySelector(domSelector); const $expMain = new JSDOM(expHtml).window.document.querySelector(domSelector); @@ -220,6 +221,31 @@ describe('Rendering', () => { return response; } + async function testRenderCode(url, expStatus = 200) { + if (!(url instanceof URL)) { + // eslint-disable-next-line no-param-reassign + url = new URL(`https://helix-pages.com/${url}`); + } + const 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); + assert.strictEqual(response.status, expStatus); + const actHtml = response.body; + if (expStatus === 200) { + const $actMain = new JSDOM(actHtml).window.document.querySelector('html'); + const $expMain = new JSDOM(expHtml).window.document.querySelector('html'); + await assertHTMLEquals($actMain.outerHTML, $expMain.outerHTML); + } + return response; + } + describe('Section DIVS', () => { it('renders document with 1 section correctly', async () => { await testRender('one-section'); @@ -513,6 +539,190 @@ describe('Rendering', () => { await testRender('head-with-script', 'html'); }); + it('renders csp nonce meta', async () => { + const originalRandomBytes = crypto.randomBytes; + try { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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 { + crypto.randomBytes = originalRandomBytes; + } + }); + + it('renders csp nonce headers', async () => { + const originalRandomBytes = crypto.randomBytes; + try { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; 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('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; 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('rAnd0mmmrAnd0mmm'); + 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 { + crypto.randomBytes = originalRandomBytes; + } + }); + + it('renders csp nonce script only', async () => { + const originalRandomBytes = crypto.randomBytes; + try { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' '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 () => { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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 () => { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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) @@ -796,5 +1006,58 @@ 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 = crypto.randomBytes; + try { + crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; 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('rAnd0mmmrAnd0mmm'); + 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('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; 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')); + }); }); }); From 2489e056b4d2117ada841eb283730c0dbe11181a Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Thu, 19 Dec 2024 09:43:05 +0200 Subject: [PATCH 2/9] feat: Enable CSP with nonce --- src/steps/csp.js | 8 +++- .../super-test/static-nonce-header.ref.html | 20 +++++----- .../static-nonce-meta-move-as-header.ref.html | 20 +++++----- .../super-test/static-nonce-meta.ref.html | 22 +++++------ test/fixtures/content/nonce-headers-meta.html | 12 +++--- test/fixtures/content/nonce-headers.html | 10 ++--- .../content/nonce-meta-move-as-header.html | 10 ++--- test/fixtures/content/nonce-meta.html | 12 +++--- test/fixtures/content/nonce-script-only.html | 6 +-- test/fixtures/content/nonce-style-only.html | 4 +- test/rendering.test.js | 37 ++++++++++--------- 11 files changed, 84 insertions(+), 77 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index ef3b0ca9..a3f7f668 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -34,7 +34,7 @@ function shouldApplyNonce(csp) { } function createAndApplyNonce(res, tree, metaCSP, headersCSP) { - const nonce = crypto.randomBytes(16).toString('base64'); + const nonce = crypto.randomBytes(18).toString('base64'); let scriptNonceResult = false; let styleNonceResult = false; @@ -74,6 +74,12 @@ export function checkResponseBodyForMetaBasedCSP(res) { } 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); } diff --git a/test/fixtures/code/super-test/static-nonce-header.ref.html b/test/fixtures/code/super-test/static-nonce-header.ref.html index e4ff52db..b02f6be0 100644 --- a/test/fixtures/code/super-test/static-nonce-header.ref.html +++ b/test/fixtures/code/super-test/static-nonce-header.ref.html @@ -14,22 +14,22 @@ - - - - - + + + + +

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 index e4ff52db..b02f6be0 100644 --- 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 @@ -14,22 +14,22 @@ - - - - - + + + + +

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 index 56690522..182fb521 100644 --- a/test/fixtures/code/super-test/static-nonce-meta.ref.html +++ b/test/fixtures/code/super-test/static-nonce-meta.ref.html @@ -1,6 +1,6 @@ - + ACME CORP @@ -15,22 +15,22 @@ - - - - - + + + + +

Nonce Test

- - - - - + + + + +
diff --git a/test/fixtures/content/nonce-headers-meta.html b/test/fixtures/content/nonce-headers-meta.html index 403888eb..547995dc 100644 --- a/test/fixtures/content/nonce-headers-meta.html +++ b/test/fixtures/content/nonce-headers-meta.html @@ -14,12 +14,12 @@ - - - - - - + + + + + +
diff --git a/test/fixtures/content/nonce-headers.html b/test/fixtures/content/nonce-headers.html index 1967c2c3..f941a05b 100644 --- a/test/fixtures/content/nonce-headers.html +++ b/test/fixtures/content/nonce-headers.html @@ -14,11 +14,11 @@ - - - - - + + + + +
diff --git a/test/fixtures/content/nonce-meta-move-as-header.html b/test/fixtures/content/nonce-meta-move-as-header.html index 9ad1f4e3..8733139a 100644 --- a/test/fixtures/content/nonce-meta-move-as-header.html +++ b/test/fixtures/content/nonce-meta-move-as-header.html @@ -14,11 +14,11 @@ - - - - - + + + + +
diff --git a/test/fixtures/content/nonce-meta.html b/test/fixtures/content/nonce-meta.html index 8c1182d8..f6d3289e 100644 --- a/test/fixtures/content/nonce-meta.html +++ b/test/fixtures/content/nonce-meta.html @@ -14,12 +14,12 @@ - - - - - - + + + + + +
diff --git a/test/fixtures/content/nonce-script-only.html b/test/fixtures/content/nonce-script-only.html index 970f7cb5..50b5d03c 100644 --- a/test/fixtures/content/nonce-script-only.html +++ b/test/fixtures/content/nonce-script-only.html @@ -14,10 +14,10 @@ - - + + - + diff --git a/test/fixtures/content/nonce-style-only.html b/test/fixtures/content/nonce-style-only.html index c2db1023..a21c0bd0 100644 --- a/test/fixtures/content/nonce-style-only.html +++ b/test/fixtures/content/nonce-style-only.html @@ -16,9 +16,9 @@ - + - +
diff --git a/test/rendering.test.js b/test/rendering.test.js index af68ff03..d9c7ef59 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -187,13 +187,14 @@ describe('Rendering', () => { } catch { // ignore } - console.log(expHtml); + // 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); if (expStatus === 200) { const $actMain = new JSDOM(actHtml).window.document.querySelector(domSelector); const $expMain = new JSDOM(expHtml).window.document.querySelector(domSelector); @@ -542,7 +543,7 @@ describe('Rendering', () => { it('renders csp nonce meta', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -565,7 +566,7 @@ describe('Rendering', () => { it('renders csp nonce headers', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -587,7 +588,7 @@ describe('Rendering', () => { }; const { headers } = await testRender('nonce-headers', 'html'); // eslint-disable-next-line quotes - assert.strictEqual(headers.get('content-security-policy'), `script-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; base-uri 'self'; object-src 'none';`); + 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; } @@ -596,7 +597,7 @@ describe('Rendering', () => { it('renders csp nonce metadata - move as header', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -605,13 +606,13 @@ describe('Rendering', () => { + '\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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; base-uri 'self'; object-src 'none';`); + 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; } @@ -620,7 +621,7 @@ describe('Rendering', () => { it('renders csp nonce headers and metadata - move as header', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -651,7 +652,7 @@ describe('Rendering', () => { it('renders csp nonce script only', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -667,20 +668,20 @@ describe('Rendering', () => { html: '\n' + '\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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; base-uri 'self'; object-src 'none';`); + 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 () => { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -698,7 +699,7 @@ describe('Rendering', () => { }); it('does not alter csp nonce if already set to a different value by header', async () => { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -1010,7 +1011,7 @@ describe('Rendering', () => { it('renders static html from the codebus and applies csp from header with nonce', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -1026,7 +1027,7 @@ describe('Rendering', () => { 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; base-uri 'self'; object-src 'none';`); + 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; } @@ -1035,7 +1036,7 @@ describe('Rendering', () => { it('renders static html from the codebus and applies csp from meta with nonce', async () => { const originalRandomBytes = crypto.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rAnd0mmmrAnd0mmm'); + 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 { @@ -1046,10 +1047,10 @@ describe('Rendering', () => { 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('rAnd0mmmrAnd0mmm'); + 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-ckFuZDBtbW1yQW5kMG1tbQ==' 'strict-dynamic'; style-src 'nonce-ckFuZDBtbW1yQW5kMG1tbQ=='; base-uri 'self'; object-src 'none';`); + 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; } From fdb2aeb79407e938025267c686b76b98b1039553 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 15 Jan 2025 14:50:22 +0100 Subject: [PATCH 3/9] feat: Enable CSP with nonce - use SAX Parser for static HTML processing --- package-lock.json | 20 ++- package.json | 1 + src/steps/csp.js | 145 ++++++++++++++---- src/steps/fetch-404.js | 2 + src/steps/render-code.js | 24 +-- src/steps/render.js | 4 +- .../code/super-test/404-csp-nonce.html | 69 +++++++++ .../code/super-test/404-csp-nonce.ref.html | 69 +++++++++ .../super-test/static-nonce-fragment.html | 4 + .../super-test/static-nonce-fragment.ref.html | 4 + .../super-test/static-nonce-header.ref.html | 4 +- .../static-nonce-meta-different.ref.html | 4 +- .../static-nonce-meta-move-as-header.ref.html | 5 +- .../super-test/static-nonce-meta.ref.html | 4 +- test/rendering.test.js | 45 +++++- 15 files changed, 335 insertions(+), 69 deletions(-) create mode 100644 test/fixtures/code/super-test/404-csp-nonce.html create mode 100644 test/fixtures/code/super-test/404-csp-nonce.ref.html create mode 100644 test/fixtures/code/super-test/static-nonce-fragment.html create mode 100644 test/fixtures/code/super-test/static-nonce-fragment.ref.html diff --git a/package-lock.json b/package-lock.json index fdea7209..7abd7785 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adobe/helix-html-pipeline", - "version": "6.17.2", + "version": "6.17.3", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adobe/helix-html-pipeline", - "version": "6.17.2", + "version": "6.17.3", "license": "Apache-2.0", "dependencies": { "@adobe/helix-markdown-support": "7.1.9", @@ -24,6 +24,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.3", "mime": "4.0.4", + "parse5-html-rewriting-stream": "7.0.0", "rehype-format": "5.0.1", "rehype-parse": "9.0.1", "remark-parse": "11.0.0", @@ -10752,6 +10753,20 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, + "node_modules/parse5-html-rewriting-stream": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", + "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", + "license": "MIT", + "dependencies": { + "entities": "^4.3.0", + "parse5": "^7.0.0", + "parse5-sax-parser": "^7.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", @@ -10773,7 +10788,6 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", - "dev": true, "license": "MIT", "dependencies": { "parse5": "^7.0.0" diff --git a/package.json b/package.json index 7d1f57ef..af52239c 100644 --- a/package.json +++ b/package.json @@ -57,6 +57,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.3", "mime": "4.0.4", + "parse5-html-rewriting-stream": "7.0.0", "rehype-format": "5.0.1", "rehype-parse": "9.0.1", "remark-parse": "11.0.0", diff --git a/src/steps/csp.js b/src/steps/csp.js index a3f7f668..efc34d8d 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -12,10 +12,20 @@ import crypto from 'crypto'; import { select, selectAll } from 'hast-util-select'; import { remove } from 'unist-util-remove'; +import { RewritingStream } from 'parse5-html-rewriting-stream'; 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) => { @@ -25,40 +35,66 @@ function parseCSP(csp) { return result; } -function shouldApplyNonce(csp) { - const parsedCSP = parseCSP(csp); +/** + * 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: parsedCSP['script-src']?.includes(NONCE_AEM), - styleNonce: parsedCSP['style-src']?.includes(NONCE_AEM), + 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), }; } -function createAndApplyNonce(res, tree, metaCSP, headersCSP) { - const nonce = crypto.randomBytes(18).toString('base64'); - let scriptNonceResult = false; - let styleNonceResult = false; +/** + * Create a nonce for CSP + * @returns {string} + */ +function createNonce() { + return crypto.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) { - const { scriptNonce, styleNonce } = shouldApplyNonce(metaCSP.properties.content); - scriptNonceResult ||= scriptNonce; - styleNonceResult ||= styleNonce; metaCSP.properties.content = metaCSP.properties.content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`); } if (headersCSP) { - const { scriptNonce, styleNonce } = shouldApplyNonce(headersCSP); - scriptNonceResult ||= scriptNonce; - styleNonceResult ||= styleNonce; res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); } - if (scriptNonceResult) { + if (scriptNonce) { selectAll('script[nonce="aem"]', tree).forEach((el) => { el.properties.nonce = nonce; }); } - if (styleNonceResult) { + if (styleNonce) { selectAll('style[nonce="aem"]', tree).forEach((el) => { el.properties.nonce = nonce; }); @@ -88,11 +124,7 @@ export function getMetaCSP(tree) { || select('meta[http-equiv="Content-Security-Policy"]', tree); } -export function getHeaderCSP(res) { - return res.headers?.get('content-security-policy'); -} - -export function contentSecurityPolicy(res, tree) { +export function contentSecurityPolicyOnAST(res, tree) { const metaCSP = getMetaCSP(tree); const headersCSP = getHeaderCSP(res); @@ -102,14 +134,11 @@ export function contentSecurityPolicy(res, tree) { } // CSP with nonce - if ( - (metaCSP && metaCSP.properties.content.includes(NONCE_AEM)) - || (headersCSP && headersCSP.includes(NONCE_AEM)) - ) { - createAndApplyNonce(res, tree, metaCSP, headersCSP); + if (metaCSP?.properties.content.includes(NONCE_AEM) || headersCSP?.includes(NONCE_AEM)) { + createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP); } - if (metaCSP && metaCSP.properties['move-as-header']) { + if (metaCSP?.properties['move-as-header']) { 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 @@ -120,3 +149,65 @@ export function contentSecurityPolicy(res, tree) { } } } + +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 rewriter = new RewritingStream(); + const chunks = []; + + rewriter.on('startTag', (tag, rawHTML) => { + 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(rawHTML.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); + return; + } + } + + if (scriptNonce && tag.tagName === 'script' && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) { + chunks.push(rawHTML.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(rawHTML.replace(/nonce="aem"/i, `nonce="${nonce}"`)); + return; + } + + chunks.push(rawHTML); + }); + + rewriter.on('data', (data) => { + chunks.push(data); + }); + + rewriter.write(res.body); + rewriter.end(); + 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 c22dd41a..bc2bfb6d 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 e37b6ccf..46a00592 100644 --- a/src/steps/render-code.js +++ b/src/steps/render-code.js @@ -10,16 +10,9 @@ * governing permissions and limitations under the License. */ import mime from 'mime'; -import { unified } from 'unified'; -import rehypeParse from 'rehype-parse'; import { - contentSecurityPolicy, - getHeaderCSP, - checkResponseBodyForMetaBasedCSP, - NONCE_AEM, - checkResponseBodyForAEMNonce, + contentSecurityPolicyOnCode, } from './csp.js'; -import tohtml from './stringify-response.js'; const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i; @@ -43,18 +36,5 @@ export default async function renderCode(state, req, res) { } res.headers.set('content-type', contentType); - const cspHeader = getHeaderCSP(res); - if (state.type === 'html' - && (cspHeader?.includes(NONCE_AEM) || ( - checkResponseBodyForAEMNonce(res) && checkResponseBodyForMetaBasedCSP(res)) - ) - ) { - res.document = await unified() - .use(rehypeParse) - .parse(res.body); - res.body = undefined; - - contentSecurityPolicy(res, res.document); - await tohtml(state, req, res); - } + contentSecurityPolicyOnCode(state, res); } diff --git a/src/steps/render.js b/src/steps/render.js index 771ffa62..6385af03 100644 --- a/src/steps/render.js +++ b/src/steps/render.js @@ -15,7 +15,7 @@ import { h } from 'hastscript'; import { unified } from 'unified'; import rehypeParse from 'rehype-parse'; import { cleanupHeaderValue } from '@adobe/helix-shared-utils'; -import { contentSecurityPolicy } from './csp.js'; +import { contentSecurityPolicyOnAST } from './csp.js'; function appendElement($parent, $el) { if ($el) { @@ -103,7 +103,7 @@ export default async function render(state, req, res) { const $headHtml = await unified() .use(rehypeParse, { fragment: true }) .parse(headHtml); - contentSecurityPolicy(res, $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 + + + + + + + + + + + + +
+
+
+ + 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-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.ref.html b/test/fixtures/code/super-test/static-nonce-header.ref.html index b02f6be0..5ee83800 100644 --- a/test/fixtures/code/super-test/static-nonce-header.ref.html +++ b/test/fixtures/code/super-test/static-nonce-header.ref.html @@ -17,7 +17,7 @@ - + @@ -28,7 +28,7 @@

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 index 21330d83..b660b86f 100644 --- a/test/fixtures/code/super-test/static-nonce-meta-different.ref.html +++ b/test/fixtures/code/super-test/static-nonce-meta-different.ref.html @@ -18,7 +18,7 @@ - + @@ -29,7 +29,7 @@

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 index b02f6be0..7a8b1c19 100644 --- 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 @@ -1,5 +1,6 @@ + ACME CORP @@ -17,7 +18,7 @@ - + @@ -28,7 +29,7 @@

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 index 182fb521..9fb09c12 100644 --- a/test/fixtures/code/super-test/static-nonce-meta.ref.html +++ b/test/fixtures/code/super-test/static-nonce-meta.ref.html @@ -18,7 +18,7 @@ - + @@ -29,7 +29,7 @@

Nonce Test

- + diff --git a/test/rendering.test.js b/test/rendering.test.js index d9c7ef59..f62aa773 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -222,12 +222,14 @@ describe('Rendering', () => { return response; } - async function testRenderCode(url, expStatus = 200) { + 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}`); } - const spec = url.pathname.split('/').pop().split('.')[0]; + + // 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 { @@ -236,13 +238,16 @@ describe('Rendering', () => { // ignore } - const response = await render(url); + const response = await render(url, '', expStatus); assert.strictEqual(response.status, expStatus); const actHtml = response.body; - if (expStatus === 200) { - const $actMain = new JSDOM(actHtml).window.document.querySelector('html'); - const $expMain = new JSDOM(expHtml).window.document.querySelector('html'); - await assertHTMLEquals($actMain.outerHTML, $expMain.outerHTML); + // 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; } @@ -1060,5 +1065,31 @@ describe('Rendering', () => { 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 = crypto.randomBytes; + try { + crypto.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 { + crypto.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 = 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; + } + }); }); }); From 58e9ba00201a621a34ae4ef24585a8451d14f2b3 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Mon, 10 Feb 2025 09:01:17 -0800 Subject: [PATCH 4/9] feat: Enable CSP with nonce --- src/steps/csp.js | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index efc34d8d..12a87d85 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -10,9 +10,10 @@ * governing permissions and limitations under the License. */ import crypto from 'crypto'; -import { select, selectAll } from 'hast-util-select'; +import { select } from 'hast-util-select'; import { remove } from 'unist-util-remove'; import { RewritingStream } from 'parse5-html-rewriting-stream'; +import { visit } from 'unist-util-visit'; export const NONCE_AEM = '\'nonce-aem\''; @@ -88,20 +89,23 @@ function createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP) { res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); } - if (scriptNonce) { - selectAll('script[nonce="aem"]', tree).forEach((el) => { - el.properties.nonce = nonce; - }); - } + visit(tree, (node) => { + if (!['script', 'style', 'link'].includes(node.tagName)) { + return; + } - if (styleNonce) { - selectAll('style[nonce="aem"]', tree).forEach((el) => { - el.properties.nonce = nonce; - }); - selectAll('link[rel=stylesheet][nonce="aem"]', tree).forEach((el) => { - el.properties.nonce = nonce; - }); - } + 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) { From c8a84af83cc461ed9ae527a47941efefec4228b0 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Mon, 10 Feb 2025 09:20:57 -0800 Subject: [PATCH 5/9] feat: Enable CSP with nonce --- src/steps/csp.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index 12a87d85..153d6cb3 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -142,7 +142,7 @@ export function contentSecurityPolicyOnAST(res, tree) { createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP); } - if (metaCSP?.properties['move-as-header']) { + 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 From 1a06aa9a52d30e3b4ec195bdf571f2e54edd9594 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Mon, 10 Feb 2025 10:01:42 -0800 Subject: [PATCH 6/9] feat: Enable CSP with nonce --- src/steps/csp.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index 153d6cb3..058f4775 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -90,10 +90,6 @@ function createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP) { } visit(tree, (node) => { - if (!['script', 'style', 'link'].includes(node.tagName)) { - return; - } - if (scriptNonce && node.tagName === 'script' && node.properties?.nonce === 'aem') { node.properties.nonce = nonce; return; From 6a237aef608e45b29ad8f6a4f215203c11bab713 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Tue, 11 Feb 2025 07:01:50 -0800 Subject: [PATCH 7/9] feat: Enable CSP with nonce --- src/steps/csp.js | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index 058f4775..4c4b6b5d 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -156,9 +156,10 @@ export function contentSecurityPolicyOnCode(state, res) { } const cspHeader = getHeaderCSP(res); - if (!cspHeader?.includes(NONCE_AEM) - && !checkResponseBodyForMetaBasedCSP(res) - && !checkResponseBodyForAEMNonce(res)) { + if (!( + cspHeader?.includes(NONCE_AEM) + || (checkResponseBodyForMetaBasedCSP(res) && checkResponseBodyForAEMNonce(res)) + )) { return; } From b1d2d6b095323cca91670bf4f731b96a47215418 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Tue, 11 Feb 2025 13:27:40 -0800 Subject: [PATCH 8/9] feat: Enable CSP with nonce - use correct crypto --- src/steps/csp.js | 5 ++-- test/rendering.test.js | 67 +++++++++++++++++++++--------------------- 2 files changed, 37 insertions(+), 35 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index 4c4b6b5d..f20e155f 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -9,11 +9,12 @@ * OF ANY KIND, either express or implied. See the License for the specific language * governing permissions and limitations under the License. */ -import crypto from 'crypto'; import { select } from 'hast-util-select'; import { remove } from 'unist-util-remove'; import { RewritingStream } from 'parse5-html-rewriting-stream'; import { visit } from 'unist-util-visit'; +// eslint-disable-next-line import/no-unresolved +import cryptoImpl from '#crypto'; export const NONCE_AEM = '\'nonce-aem\''; @@ -58,7 +59,7 @@ function shouldApplyNonce(metaCSPText, headersCSPText) { * @returns {string} */ function createNonce() { - return crypto.randomBytes(18).toString('base64'); + return cryptoImpl.randomBytes(18).toString('base64'); } /** diff --git a/test/rendering.test.js b/test/rendering.test.js index 6ec6166a..a70ff1f5 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -11,7 +11,6 @@ */ /* eslint-env mocha */ import assert from 'assert'; -import crypto from 'crypto'; import path from 'path'; import { readFile } from 'fs/promises'; import { JSDOM } from 'jsdom'; @@ -19,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: { @@ -546,9 +547,9 @@ describe('Rendering', () => { }); it('renders csp nonce meta', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -564,14 +565,14 @@ describe('Rendering', () => { const { headers } = await testRender('nonce-meta', 'html'); assert.ok(!headers.get('content-security-policy')); } finally { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders csp nonce headers', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -595,14 +596,14 @@ describe('Rendering', () => { // 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; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders csp nonce metadata - move as header', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -619,14 +620,14 @@ describe('Rendering', () => { // 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; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders csp nonce headers and metadata - move as header', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -650,14 +651,14 @@ describe('Rendering', () => { const { headers } = await testRender('nonce-headers-meta', 'html'); assert.strictEqual(headers.get('content-security-policy'), 'frame-ancestors \'self\''); } finally { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders csp nonce script only', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -681,12 +682,12 @@ describe('Rendering', () => { // 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; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('does not alter csp nonce if already set to a different value by meta', async () => { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, head: { @@ -704,7 +705,7 @@ describe('Rendering', () => { }); it('does not alter csp nonce if already set to a different value by header', async () => { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -1026,9 +1027,9 @@ describe('Rendering', () => { }); it('renders static html from the codebus and applies csp from header with nonce', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + cryptoImpl.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); config = { ...DEFAULT_CONFIG, headers: { @@ -1046,30 +1047,30 @@ describe('Rendering', () => { // 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; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders static html from the codebus and applies csp from meta with nonce', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + 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 { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); it('renders static html from the codebus and applies csp from meta with nonce moved as header', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + 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 { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); @@ -1079,13 +1080,13 @@ describe('Rendering', () => { }); it('renders static html from the codebus and applies csp without altering the HTML structure', async () => { - const originalRandomBytes = crypto.randomBytes; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + 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 { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); @@ -1093,14 +1094,14 @@ describe('Rendering', () => { 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; + const originalRandomBytes = cryptoImpl.randomBytes; try { - crypto.randomBytes = () => Buffer.from('rA4nd0mmmrA4nd0mmm'); + 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 { - crypto.randomBytes = originalRandomBytes; + cryptoImpl.randomBytes = originalRandomBytes; } }); }); From a98a2ebdab1e4f685400ea07662e26b34654f1f4 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Tue, 11 Feb 2025 15:50:00 -0800 Subject: [PATCH 9/9] feat: Enable CSP with nonce - use low-level tokenizer --- package-lock.json | 17 ++-------- package.json | 2 +- src/steps/csp.js | 86 ++++++++++++++++++++++++++++------------------- 3 files changed, 55 insertions(+), 50 deletions(-) diff --git a/package-lock.json b/package-lock.json index 4fc620a7..3eb332b7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.4", "mime": "4.0.6", - "parse5-html-rewriting-stream": "7.0.0", + "parse5": "7.2.1", "rehype-format": "5.0.1", "rehype-parse": "9.0.1", "remark-parse": "11.0.0", @@ -10783,20 +10783,6 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5-html-rewriting-stream": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/parse5-html-rewriting-stream/-/parse5-html-rewriting-stream-7.0.0.tgz", - "integrity": "sha512-mazCyGWkmCRWDI15Zp+UiCqMp/0dgEmkZRvhlsqqKYr4SsVm/TvnSpD9fCvqCA2zoWJcfRym846ejWBBHRiYEg==", - "license": "MIT", - "dependencies": { - "entities": "^4.3.0", - "parse5": "^7.0.0", - "parse5-sax-parser": "^7.0.0" - }, - "funding": { - "url": "https://github.com/inikulin/parse5?sponsor=1" - } - }, "node_modules/parse5-htmlparser2-tree-adapter": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/parse5-htmlparser2-tree-adapter/-/parse5-htmlparser2-tree-adapter-6.0.1.tgz", @@ -10818,6 +10804,7 @@ "version": "7.0.0", "resolved": "https://registry.npmjs.org/parse5-sax-parser/-/parse5-sax-parser-7.0.0.tgz", "integrity": "sha512-5A+v2SNsq8T6/mG3ahcz8ZtQ0OUFTatxPbeidoMB7tkJSGDY3tdfl4MHovtLQHkEn5CGxijNWRQHhRQ6IRpXKg==", + "dev": true, "license": "MIT", "dependencies": { "parse5": "^7.0.0" diff --git a/package.json b/package.json index f57b7f2e..33fdeee3 100644 --- a/package.json +++ b/package.json @@ -57,7 +57,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.4", "mime": "4.0.6", - "parse5-html-rewriting-stream": "7.0.0", + "parse5": "7.2.1", "rehype-format": "5.0.1", "rehype-parse": "9.0.1", "remark-parse": "11.0.0", diff --git a/src/steps/csp.js b/src/steps/csp.js index f20e155f..d6e48658 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -10,8 +10,8 @@ * governing permissions and limitations under the License. */ import { select } from 'hast-util-select'; +import { Tokenizer } from 'parse5'; import { remove } from 'unist-util-remove'; -import { RewritingStream } from 'parse5-html-rewriting-stream'; import { visit } from 'unist-util-visit'; // eslint-disable-next-line import/no-unresolved import cryptoImpl from '#crypto'; @@ -167,47 +167,65 @@ export function contentSecurityPolicyOnCode(state, res) { const nonce = createNonce(); let { scriptNonce, styleNonce } = shouldApplyNonce(null, cspHeader); - const rewriter = new RewritingStream(); + const html = res.body; const chunks = []; - - rewriter.on('startTag', (tag, rawHTML) => { - 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 + 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; + } } - chunks.push(rawHTML.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); - return; - } - } - if (scriptNonce && tag.tagName === 'script' && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) { - chunks.push(rawHTML.replace(/nonce="aem"/i, `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(rawHTML.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(rawHTML); + 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(_) {}, }); - rewriter.on('data', (data) => { - chunks.push(data); - }); + tokenizer.write(html); + chunks.push(html.slice(lastOffset)); - rewriter.write(res.body); - rewriter.end(); res.body = chunks.join(''); if (cspHeader) { res.headers.set('content-security-policy', cspHeader.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));