@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 +7 -0
- package/package.json +2 -1
- package/src/html-pipe.js +2 -1
- package/src/steps/csp.js +233 -0
- package/src/steps/fetch-404.js +2 -0
- package/src/steps/render-code.js +5 -0
- package/src/steps/render.js +2 -0
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.
|
|
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;
|
package/src/steps/csp.js
ADDED
|
@@ -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
|
+
}
|
package/src/steps/fetch-404.js
CHANGED
|
@@ -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
|
}
|
package/src/steps/render-code.js
CHANGED
|
@@ -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
|
}
|
package/src/steps/render.js
CHANGED
|
@@ -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
|
|