Skip to content

Commit 3f4895f

Browse files
authored
feat: Enable CSP with nonce for Helix 5 (#816)
1 parent fac3ed7 commit 3f4895f

36 files changed

+1242
-2
lines changed

package-lock.json

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@
5757
"mdast-util-to-string": "4.0.0",
5858
"micromark-util-subtokenize": "2.0.4",
5959
"mime": "4.0.6",
60+
"parse5": "7.2.1",
6061
"rehype-format": "5.0.1",
6162
"rehype-parse": "9.0.1",
6263
"remark-parse": "11.0.0",

src/html-pipe.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@ export async function htmlPipe(state, req) {
149149

150150
if (state.content.sourceBus === 'code' || state.info.originalExtension === '.md') {
151151
state.timer?.update('serialize');
152+
await setCustomResponseHeaders(state, req, res);
152153
await renderCode(state, req, res);
153154
} else {
154155
state.timer?.update('parse');
@@ -165,14 +166,14 @@ export async function htmlPipe(state, req) {
165166
await createPictures(state);
166167
await extractMetaData(state, req);
167168
await addHeadingIds(state);
169+
await setCustomResponseHeaders(state, req, res);
168170
await render(state, req, res);
169171
state.timer?.update('serialize');
170172
await tohtml(state, req, res);
171173
await applyMetaLastModified(state, res);
172174
}
173175

174176
setLastModified(state, res);
175-
await setCustomResponseHeaders(state, req, res);
176177
await setXSurrogateKeyHeader(state, req, res);
177178
} catch (e) {
178179
res.error = e.message;

src/steps/csp.js

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/*
2+
* Copyright 2024 Adobe. All rights reserved.
3+
* This file is licensed to you under the Apache License, Version 2.0 (the "License");
4+
* you may not use this file except in compliance with the License. You may obtain a copy
5+
* of the License at http://www.apache.org/licenses/LICENSE-2.0
6+
*
7+
* Unless required by applicable law or agreed to in writing, software distributed under
8+
* the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR REPRESENTATIONS
9+
* OF ANY KIND, either express or implied. See the License for the specific language
10+
* governing permissions and limitations under the License.
11+
*/
12+
import { select } from 'hast-util-select';
13+
import { Tokenizer } from 'parse5';
14+
import { remove } from 'unist-util-remove';
15+
import { visit } from 'unist-util-visit';
16+
// eslint-disable-next-line import/no-unresolved
17+
import cryptoImpl from '#crypto';
18+
19+
export const NONCE_AEM = '\'nonce-aem\'';
20+
21+
/**
22+
* Parse a CSP string into its directives
23+
* @param {string | undefined | null} csp
24+
* @returns {Object}
25+
*/
26+
function parseCSP(csp) {
27+
if (!csp) {
28+
return {};
29+
}
30+
31+
const parts = csp.split(';');
32+
const result = {};
33+
parts.forEach((part) => {
34+
const [directive, ...values] = part.trim().split(' ');
35+
result[directive] = values.join(' ');
36+
});
37+
return result;
38+
}
39+
40+
/**
41+
* Computes where nonces should be applied
42+
* @param {string | null | undefined} metaCSPText The actual CSP value from the meta tag
43+
* @param {string | null | undefined} headersCSPText The actual CSP value from the headers
44+
* @returns {scriptNonce: boolean, styleNonce: boolean}
45+
*/
46+
function shouldApplyNonce(metaCSPText, headersCSPText) {
47+
const metaBased = parseCSP(metaCSPText);
48+
const headersBased = parseCSP(headersCSPText);
49+
return {
50+
scriptNonce: metaBased['script-src']?.includes(NONCE_AEM)
51+
|| headersBased['script-src']?.includes(NONCE_AEM),
52+
styleNonce: metaBased['style-src']?.includes(NONCE_AEM)
53+
|| headersBased['style-src']?.includes(NONCE_AEM),
54+
};
55+
}
56+
57+
/**
58+
* Create a nonce for CSP
59+
* @returns {string}
60+
*/
61+
function createNonce() {
62+
return cryptoImpl.randomBytes(18).toString('base64');
63+
}
64+
65+
/**
66+
* Get the applied CSP header from a response
67+
* @param {PipelineResponse} res
68+
* @returns {string}
69+
*/
70+
export function getHeaderCSP(res) {
71+
return res.headers?.get('content-security-policy');
72+
}
73+
74+
/**
75+
* Apply CSP with nonces on an AST
76+
* @param {PipelineResponse} res
77+
* @param {Object} tree
78+
* @param {Object} metaCSP
79+
* @param {string} headersCSP
80+
*/
81+
function createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP) {
82+
const nonce = createNonce();
83+
const { scriptNonce, styleNonce } = shouldApplyNonce(metaCSP?.properties.content, headersCSP);
84+
85+
if (metaCSP) {
86+
metaCSP.properties.content = metaCSP.properties.content.replaceAll(NONCE_AEM, `'nonce-${nonce}'`);
87+
}
88+
89+
if (headersCSP) {
90+
res.headers.set('content-security-policy', headersCSP.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
91+
}
92+
93+
visit(tree, (node) => {
94+
if (scriptNonce && node.tagName === 'script' && node.properties?.nonce === 'aem') {
95+
node.properties.nonce = nonce;
96+
return;
97+
}
98+
99+
if (styleNonce
100+
&& (node.tagName === 'style' || (node.tagName === 'link' && node.properties?.rel?.[0] === 'stylesheet'))
101+
&& node.properties?.nonce === 'aem'
102+
) {
103+
node.properties.nonce = nonce;
104+
}
105+
});
106+
}
107+
108+
export function checkResponseBodyForMetaBasedCSP(res) {
109+
return res.body?.includes('http-equiv="content-security-policy"')
110+
|| res.body?.includes('http-equiv="Content-Security-Policy"');
111+
}
112+
113+
export function checkResponseBodyForAEMNonce(res) {
114+
/*
115+
we only look for 'nonce-aem' (single quote) to see if there is a meta CSP with nonce
116+
we don't want to generate nonces if they appear just on script/style tags,
117+
as those have no effect without the actual CSP meta (or header).
118+
this means it is ok to not check for the "nonce-aem" (double quotes)
119+
*/
120+
return res.body?.includes(NONCE_AEM);
121+
}
122+
123+
export function getMetaCSP(tree) {
124+
return select('meta[http-equiv="content-security-policy"]', tree)
125+
|| select('meta[http-equiv="Content-Security-Policy"]', tree);
126+
}
127+
128+
export function contentSecurityPolicyOnAST(res, tree) {
129+
const metaCSP = getMetaCSP(tree);
130+
const headersCSP = getHeaderCSP(res);
131+
132+
if (!metaCSP && !headersCSP) {
133+
// No CSP defined
134+
return;
135+
}
136+
137+
// CSP with nonce
138+
if (metaCSP?.properties.content.includes(NONCE_AEM) || headersCSP?.includes(NONCE_AEM)) {
139+
createAndApplyNonceOnAST(res, tree, metaCSP, headersCSP);
140+
}
141+
142+
if (metaCSP?.properties['move-as-header'] === 'true') {
143+
if (!headersCSP) {
144+
// if we have a CSP in meta but no CSP in headers
145+
// we can move the CSP from meta to headers, if requested
146+
res.headers.set('content-security-policy', metaCSP.properties.content);
147+
remove(tree, null, metaCSP);
148+
} else {
149+
delete metaCSP.properties['move-as-header'];
150+
}
151+
}
152+
}
153+
154+
export function contentSecurityPolicyOnCode(state, res) {
155+
if (state.type !== 'html') {
156+
return;
157+
}
158+
159+
const cspHeader = getHeaderCSP(res);
160+
if (!(
161+
cspHeader?.includes(NONCE_AEM)
162+
|| (checkResponseBodyForMetaBasedCSP(res) && checkResponseBodyForAEMNonce(res))
163+
)) {
164+
return;
165+
}
166+
167+
const nonce = createNonce();
168+
let { scriptNonce, styleNonce } = shouldApplyNonce(null, cspHeader);
169+
170+
const html = res.body;
171+
const chunks = [];
172+
let lastOffset = 0;
173+
174+
const getRawHTML = (token) => html.slice(token.location.startOffset, token.location.endOffset);
175+
176+
const tokenizer = new Tokenizer({
177+
sourceCodeLocationInfo: true,
178+
}, {
179+
onStartTag(tag) {
180+
chunks.push(html.slice(lastOffset, tag.location.startOffset));
181+
try {
182+
if (tag.tagName === 'meta'
183+
&& tag.attrs.find(
184+
(attr) => attr.name.toLowerCase() === 'http-equiv' && attr.value.toLowerCase() === 'content-security-policy',
185+
)
186+
) {
187+
const contentAttr = tag.attrs.find((attr) => attr.name.toLowerCase() === 'content');
188+
if (contentAttr) {
189+
({ scriptNonce, styleNonce } = shouldApplyNonce(contentAttr.value, cspHeader));
190+
191+
if (!cspHeader && tag.attrs.find((attr) => attr.name === 'move-as-header' && attr.value === 'true')) {
192+
res.headers.set('content-security-policy', contentAttr.value.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
193+
return; // don't push the chunk so it gets removed from the response body
194+
}
195+
chunks.push(getRawHTML(tag).replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
196+
return;
197+
}
198+
}
199+
200+
if (scriptNonce && tag.tagName === 'script' && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) {
201+
chunks.push(getRawHTML(tag).replace(/nonce="aem"/i, `nonce="${nonce}"`));
202+
return;
203+
}
204+
205+
if (styleNonce && (tag.tagName === 'style' || tag.tagName === 'link') && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) {
206+
chunks.push(getRawHTML(tag).replace(/nonce="aem"/i, `nonce="${nonce}"`));
207+
return;
208+
}
209+
210+
chunks.push(getRawHTML(tag));
211+
} finally {
212+
lastOffset = tag.location.endOffset;
213+
}
214+
},
215+
// no-op callbacks. onStartTag will take care of these
216+
onComment(_) {},
217+
onDoctype(_) {},
218+
onEndTag(_) {},
219+
onEof(_) {},
220+
onCharacter(_) {},
221+
onNullCharacter(_) {},
222+
onWhitespaceCharacter(_) {},
223+
onParseError(_) {},
224+
});
225+
226+
tokenizer.write(html);
227+
chunks.push(html.slice(lastOffset));
228+
229+
res.body = chunks.join('');
230+
if (cspHeader) {
231+
res.headers.set('content-security-policy', cspHeader.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
232+
}
233+
}

src/steps/fetch-404.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
import { extractLastModified, recordLastModified } from '../utils/last-modified.js';
13+
import { contentSecurityPolicyOnCode } from './csp.js';
1314
import { computeContentPathKey, computeCodePathKey } from './set-x-surrogate-key-header.js';
1415

1516
/**
@@ -34,6 +35,7 @@ export default async function fetch404(state, req, res) {
3435

3536
// keep 404 response status
3637
res.body = ret.body;
38+
contentSecurityPolicyOnCode(state, res);
3739
res.headers.set('last-modified', ret.headers.get('last-modified'));
3840
res.headers.set('content-type', 'text/html; charset=utf-8');
3941
}

src/steps/render-code.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010
* governing permissions and limitations under the License.
1111
*/
1212
import mime from 'mime';
13+
import {
14+
contentSecurityPolicyOnCode,
15+
} from './csp.js';
1316

1417
const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i;
1518

@@ -32,4 +35,6 @@ export default async function renderCode(state, req, res) {
3235
}
3336
}
3437
res.headers.set('content-type', contentType);
38+
39+
contentSecurityPolicyOnCode(state, res);
3540
}

src/steps/render.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import { h } from 'hastscript';
1515
import { unified } from 'unified';
1616
import rehypeParse from 'rehype-parse';
1717
import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
18+
import { contentSecurityPolicyOnAST } from './csp.js';
1819

1920
function appendElement($parent, $el) {
2021
if ($el) {
@@ -102,6 +103,7 @@ export default async function render(state, req, res) {
102103
const $headHtml = await unified()
103104
.use(rehypeParse, { fragment: true })
104105
.parse(headHtml);
106+
contentSecurityPolicyOnAST(res, $headHtml);
105107
$head.children.push(...$headHtml.children);
106108
}
107109

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
<!DOCTYPE html>
2+
<html>
3+
4+
<head>
5+
<meta http-equiv="Content-Security-Policy" content="script-src 'nonce-aem' 'strict-dynamic'; base-uri 'self'; object-src 'none';" move-as-header="true">
6+
<title>Page not found</title>
7+
<script nonce="aem" type="text/javascript">
8+
window.isErrorPage = true;
9+
window.errorCode = '404';
10+
</script>
11+
<meta name="viewport" content="width=device-width, initial-scale=1">
12+
<meta property="og:title" content="Page not found">
13+
<script nonce="aem" src="/scripts/scripts.js" type="module" crossorigin="use-credentials"></script>
14+
<script nonce="aem" type="module">
15+
window.addEventListener('load', () => {
16+
if (document.referrer) {
17+
const { origin, pathname } = new URL(document.referrer);
18+
if (origin === window.location.origin) {
19+
const backBtn = document.createElement('a');
20+
backBtn.classList.add('button', 'error-button-back');
21+
backBtn.href = pathname;
22+
backBtn.textContent = 'Go back';
23+
backBtn.title = 'Go back';
24+
const btnContainer = document.querySelector('.button-container');
25+
btnContainer.append(backBtn);
26+
}
27+
}
28+
});
29+
</script>
30+
<script nonce="aem" type="module">
31+
import { sampleRUM } from '/scripts/aem.js';
32+
sampleRUM('404', { source: document.referrer });
33+
</script>
34+
<link rel="stylesheet" href="/styles/styles.css">
35+
<style>
36+
main.error {
37+
min-height: calc(100vh - var(--nav-height));
38+
display: flex;
39+
align-items: center;
40+
}
41+
42+
main.error .error-number {
43+
width: 100%;
44+
}
45+
46+
main.error .error-number text {
47+
font-family: monospace;
48+
}
49+
</style>
50+
<link rel="stylesheet" href="/styles/lazy-styles.css">
51+
</head>
52+
53+
<body>
54+
<header></header>
55+
<main class="error">
56+
<div class="section">
57+
<svg viewBox="1 0 38 18" class="error-number">
58+
<text x="0" y="17">404</text>
59+
</svg>
60+
<h2 class="error-message">Page Not Found</h2>
61+
<p class="button-container">
62+
<a href="/" class="button secondary error-button-home">Go home</a>
63+
</p>
64+
</div>
65+
</main>
66+
<footer></footer>
67+
</body>
68+
69+
</html>

0 commit comments

Comments
 (0)