@adobe/helix-html-pipeline 6.19.1 → 6.20.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,10 @@
1
+ # [6.20.0](https://github.com/adobe/helix-html-pipeline/compare/v6.19.1...v6.20.0) (2025-02-12)
2
+
3
+
4
+ ### Features
5
+
6
+ * Enable CSP with nonce for Helix 5 ([#816](https://github.com/adobe/helix-html-pipeline/issues/816)) ([3f4895f](https://github.com/adobe/helix-html-pipeline/commit/3f4895ffc975ea4c35b3ba139bf0ead56a2d0000))
7
+
1
8
  ## [6.19.1](https://github.com/adobe/helix-html-pipeline/compare/v6.19.0...v6.19.1) (2025-02-11)
2
9
 
3
10
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adobe/helix-html-pipeline",
3
- "version": "6.19.1",
3
+ "version": "6.20.0",
4
4
  "description": "Helix HTML Pipeline",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -57,6 +57,7 @@
57
57
  "mdast-util-to-string": "4.0.0",
58
58
  "micromark-util-subtokenize": "2.0.4",
59
59
  "mime": "4.0.6",
60
+ "parse5": "7.2.1",
60
61
  "rehype-format": "5.0.1",
61
62
  "rehype-parse": "9.0.1",
62
63
  "remark-parse": "11.0.0",
package/src/html-pipe.js CHANGED
@@ -149,6 +149,7 @@ export async function htmlPipe(state, req) {
149
149
 
150
150
  if (state.content.sourceBus === 'code' || state.info.originalExtension === '.md') {
151
151
  state.timer?.update('serialize');
152
+ await setCustomResponseHeaders(state, req, res);
152
153
  await renderCode(state, req, res);
153
154
  } else {
154
155
  state.timer?.update('parse');
@@ -165,6 +166,7 @@ export async function htmlPipe(state, req) {
165
166
  await createPictures(state);
166
167
  await extractMetaData(state, req);
167
168
  await addHeadingIds(state);
169
+ await setCustomResponseHeaders(state, req, res);
168
170
  await render(state, req, res);
169
171
  state.timer?.update('serialize');
170
172
  await tohtml(state, req, res);
@@ -172,7 +174,6 @@ export async function htmlPipe(state, req) {
172
174
  }
173
175
 
174
176
  setLastModified(state, res);
175
- await setCustomResponseHeaders(state, req, res);
176
177
  await setXSurrogateKeyHeader(state, req, res);
177
178
  } catch (e) {
178
179
  res.error = e.message;
@@ -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
+ }
@@ -10,6 +10,7 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  import { extractLastModified, recordLastModified } from '../utils/last-modified.js';
13
+ import { contentSecurityPolicyOnCode } from './csp.js';
13
14
  import { computeContentPathKey, computeCodePathKey } from './set-x-surrogate-key-header.js';
14
15
 
15
16
  /**
@@ -34,6 +35,7 @@ export default async function fetch404(state, req, res) {
34
35
 
35
36
  // keep 404 response status
36
37
  res.body = ret.body;
38
+ contentSecurityPolicyOnCode(state, res);
37
39
  res.headers.set('last-modified', ret.headers.get('last-modified'));
38
40
  res.headers.set('content-type', 'text/html; charset=utf-8');
39
41
  }
@@ -10,6 +10,9 @@
10
10
  * governing permissions and limitations under the License.
11
11
  */
12
12
  import mime from 'mime';
13
+ import {
14
+ contentSecurityPolicyOnCode,
15
+ } from './csp.js';
13
16
 
14
17
  const CHARSET_RE = /charset=([^()<>@,;:"/[\]?.=\s]*)/i;
15
18
 
@@ -32,4 +35,6 @@ export default async function renderCode(state, req, res) {
32
35
  }
33
36
  }
34
37
  res.headers.set('content-type', contentType);
38
+
39
+ contentSecurityPolicyOnCode(state, res);
35
40
  }
@@ -15,6 +15,7 @@ import { h } from 'hastscript';
15
15
  import { unified } from 'unified';
16
16
  import rehypeParse from 'rehype-parse';
17
17
  import { cleanupHeaderValue } from '@adobe/helix-shared-utils';
18
+ import { contentSecurityPolicyOnAST } from './csp.js';
18
19
 
19
20
  function appendElement($parent, $el) {
20
21
  if ($el) {
@@ -102,6 +103,7 @@ export default async function render(state, req, res) {
102
103
  const $headHtml = await unified()
103
104
  .use(rehypeParse, { fragment: true })
104
105
  .parse(headHtml);
106
+ contentSecurityPolicyOnAST(res, $headHtml);
105
107
  $head.children.push(...$headHtml.children);
106
108
  }
107
109