From af3b567957f12886f1e513ffe5faae9e917db1ef Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Thu, 9 Jan 2025 10:45:47 +0200 Subject: [PATCH 1/9] feat: Enable CSP with nonce for Helix 4 --- src/html-pipe.js | 3 +- src/steps/csp.js | 122 +++++++ src/steps/render-code.js | 25 ++ src/steps/render.js | 3 + test/FileS3Loader.js | 24 +- .../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 | 343 ++++++++++++++++++ 30 files changed, 1067 insertions(+), 5 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 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..a3f7f668 --- /dev/null +++ b/src/steps/csp.js @@ -0,0 +1,122 @@ +/* + * 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(18).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) { + /* + 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 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 0f72f868..52623b19 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,8 @@ 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); } 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/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..b02f6be0 --- /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..b02f6be0 --- /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..182fb521 --- /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..27f0f8ca 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'; @@ -50,6 +51,43 @@ 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) { + 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; + } + // eslint-disable-next-line default-param-last async function testRender(url, domSelector = 'main', expStatus) { if (!(url instanceof URL)) { @@ -353,6 +391,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 +729,65 @@ describe('Rendering', () => { assert.strictEqual(body.trim(), ''); }); + 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); From fbcbd5d92469d741183cf1f5114ff7071b8e5fe5 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Thu, 9 Jan 2025 13:35:00 +0200 Subject: [PATCH 2/9] feat: Enable CSP with nonce for Helix 4 - 404 page --- src/steps/fetch-404.js | 2 ++ src/steps/render-code.js | 32 ++++++++++++++++++-------------- 2 files changed, 20 insertions(+), 14 deletions(-) diff --git a/src/steps/fetch-404.js b/src/steps/fetch-404.js index 018506e5..d4b0b1ac 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 { renderCodeCSP } from './render-code.js'; import { getPathKey } from './set-x-surrogate-key-header.js'; /** @@ -55,4 +56,5 @@ export default async function fetch404(state, req, res) { } res.headers.set('x-surrogate-key', keys.join(' ')); + await renderCodeCSP(state, req, res); } diff --git a/src/steps/render-code.js b/src/steps/render-code.js index e37b6ccf..40368bbc 100644 --- a/src/steps/render-code.js +++ b/src/steps/render-code.js @@ -23,6 +23,23 @@ import tohtml from './stringify-response.js'; const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i; +export async function renderCodeCSP(state, req, res) { + 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); + } +} + /** * "Renders" the content from the code-bus as-is * @type PipelineStep @@ -43,18 +60,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); - } + await renderCodeCSP(state, req, res); } From 960b51d18448ad23f32fc22d18dae284d9931ac2 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 15 Jan 2025 20:54:58 +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 | 4 +- src/steps/render-code.js | 28 +--- 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-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 | 34 +++- 13 files changed, 316 insertions(+), 75 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 diff --git a/package-lock.json b/package-lock.json index a432b791..1d4f6be6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@adobe/helix-html-pipeline", - "version": "5.13.16", + "version": "5.13.18", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@adobe/helix-html-pipeline", - "version": "5.13.16", + "version": "5.13.18", "license": "Apache-2.0", "dependencies": { "@adobe/helix-markdown-support": "7.1.9", @@ -26,6 +26,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.3", "mime": "4.0.6", + "parse5-html-rewriting-stream": "7.0.0", "rehype-format": "5.0.1", "rehype-parse": "9.0.1", "remark-parse": "11.0.0", @@ -10772,6 +10773,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", @@ -10793,7 +10808,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 f1196457..e47d49aa 100644 --- a/package.json +++ b/package.json @@ -60,6 +60,7 @@ "mdast-util-to-string": "4.0.0", "micromark-util-subtokenize": "2.0.3", "mime": "4.0.6", + "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 d4b0b1ac..01d3bfd5 100644 --- a/src/steps/fetch-404.js +++ b/src/steps/fetch-404.js @@ -10,7 +10,7 @@ * governing permissions and limitations under the License. */ import { extractLastModified, recordLastModified } from '../utils/last-modified.js'; -import { renderCodeCSP } from './render-code.js'; +import { contentSecurityPolicyOnCode } from './csp.js'; import { getPathKey } from './set-x-surrogate-key-header.js'; /** @@ -35,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'); } @@ -56,5 +57,4 @@ export default async function fetch404(state, req, res) { } res.headers.set('x-surrogate-key', keys.join(' ')); - await renderCodeCSP(state, req, res); } diff --git a/src/steps/render-code.js b/src/steps/render-code.js index 40368bbc..46a00592 100644 --- a/src/steps/render-code.js +++ b/src/steps/render-code.js @@ -10,36 +10,12 @@ * 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; -export async function renderCodeCSP(state, req, res) { - 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); - } -} - /** * "Renders" the content from the code-bus as-is * @type PipelineStep @@ -60,5 +36,5 @@ export default async function renderCode(state, req, res) { } res.headers.set('content-type', contentType); - await renderCodeCSP(state, req, res); + contentSecurityPolicyOnCode(state, res); } diff --git a/src/steps/render.js b/src/steps/render.js index 52623b19..fe898516 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) { @@ -104,7 +104,7 @@ export default async function render(state, req, res) { .use(rehypeParse, { fragment: true }) .parse(headHtml); - contentSecurityPolicy(res, $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/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.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 27f0f8ca..da51e21a 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -63,12 +63,14 @@ describe('Rendering', () => { return JSON.parse(config); } - 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 { @@ -77,13 +79,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; } @@ -729,6 +734,21 @@ 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 { From ecb7a3f4108be8843cd8ce149e0098f56d3f7a46 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Mon, 10 Feb 2025 09:28:58 -0800 Subject: [PATCH 4/9] feat: Enable CSP with nonce --- src/steps/csp.js | 34 +++++++++++++++++++--------------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index efc34d8d..153d6cb3 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) { @@ -138,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 abee3ed9a82a65312ba38f9707e63dcbfe36b597 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Mon, 10 Feb 2025 10:00:32 -0800 Subject: [PATCH 5/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 9abfad5bddd0a86d3def0e3eff4ad9012686f161 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Tue, 11 Feb 2025 06:49:21 -0800 Subject: [PATCH 6/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 7849eeb129ccbf32b87a06cf0fe568799011be6e Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 12 Feb 2025 07:14:39 -0800 Subject: [PATCH 7/9] feat: Enable CSP with nonce - use low-level tokenizer and correct crypto --- package-lock.json | 17 ++------- package.json | 2 +- src/steps/csp.js | 91 ++++++++++++++++++++++++++++------------------- 3 files changed, 58 insertions(+), 52 deletions(-) diff --git a/package-lock.json b/package-lock.json index 786fb395..5be38e36 100644 --- a/package-lock.json +++ b/package-lock.json @@ -26,7 +26,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", @@ -10803,20 +10803,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", @@ -10838,6 +10824,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 53e0a501..2abb7159 100644 --- a/package.json +++ b/package.json @@ -60,7 +60,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 4c4b6b5d..d6e48658 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 { 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'; 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'); } /** @@ -166,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}'`)); From df9014aafbf11dce4fa02e11d8ff9e92068f1295 Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 26 Feb 2025 10:06:07 +0100 Subject: [PATCH 8/9] feat: Enable CSP with nonce - use Web Crypto compatible function for nonce generation --- src/steps/csp.js | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/steps/csp.js b/src/steps/csp.js index d6e48658..eebf527b 100644 --- a/src/steps/csp.js +++ b/src/steps/csp.js @@ -59,7 +59,9 @@ function shouldApplyNonce(metaCSPText, headersCSPText) { * @returns {string} */ function createNonce() { - return cryptoImpl.randomBytes(18).toString('base64'); + const array = new Uint8Array(18); + cryptoImpl.getRandomValues(array); + return btoa(String.fromCharCode(...array)); } /** From 0a1a97bfec45049c67064793d882581a6e04dc6c Mon Sep 17 00:00:00 2001 From: Andrei Tuicu Date: Wed, 26 Feb 2025 10:15:31 +0100 Subject: [PATCH 9/9] feat: Enable CSP with nonce - use Web Crypto compatible function for nonce generation --- test/rendering.test.js | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/test/rendering.test.js b/test/rendering.test.js index da51e21a..7da20719 100644 --- a/test/rendering.test.js +++ b/test/rendering.test.js @@ -11,15 +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; @@ -112,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);