|
9 | 9 | * OF ANY KIND, either express or implied. See the License for the specific language
|
10 | 10 | * governing permissions and limitations under the License.
|
11 | 11 | */
|
12 |
| -import crypto from 'crypto'; |
13 | 12 | import { select } from 'hast-util-select';
|
| 13 | +import { Tokenizer } from 'parse5'; |
14 | 14 | import { remove } from 'unist-util-remove';
|
15 |
| -import { RewritingStream } from 'parse5-html-rewriting-stream'; |
16 | 15 | import { visit } from 'unist-util-visit';
|
| 16 | +// eslint-disable-next-line import/no-unresolved |
| 17 | +import cryptoImpl from '#crypto'; |
17 | 18 |
|
18 | 19 | export const NONCE_AEM = '\'nonce-aem\'';
|
19 | 20 |
|
@@ -58,7 +59,7 @@ function shouldApplyNonce(metaCSPText, headersCSPText) {
|
58 | 59 | * @returns {string}
|
59 | 60 | */
|
60 | 61 | function createNonce() {
|
61 |
| - return crypto.randomBytes(18).toString('base64'); |
| 62 | + return cryptoImpl.randomBytes(18).toString('base64'); |
62 | 63 | }
|
63 | 64 |
|
64 | 65 | /**
|
@@ -166,47 +167,65 @@ export function contentSecurityPolicyOnCode(state, res) {
|
166 | 167 | const nonce = createNonce();
|
167 | 168 | let { scriptNonce, styleNonce } = shouldApplyNonce(null, cspHeader);
|
168 | 169 |
|
169 |
| - const rewriter = new RewritingStream(); |
| 170 | + const html = res.body; |
170 | 171 | const chunks = [];
|
171 |
| - |
172 |
| - rewriter.on('startTag', (tag, rawHTML) => { |
173 |
| - if (tag.tagName === 'meta' |
174 |
| - && tag.attrs.find( |
175 |
| - (attr) => attr.name.toLowerCase() === 'http-equiv' && attr.value.toLowerCase() === 'content-security-policy', |
176 |
| - ) |
177 |
| - ) { |
178 |
| - const contentAttr = tag.attrs.find((attr) => attr.name.toLowerCase() === 'content'); |
179 |
| - if (contentAttr) { |
180 |
| - ({ scriptNonce, styleNonce } = shouldApplyNonce(contentAttr.value, cspHeader)); |
181 |
| - |
182 |
| - if (!cspHeader && tag.attrs.find((attr) => attr.name === 'move-as-header' && attr.value === 'true')) { |
183 |
| - res.headers.set('content-security-policy', contentAttr.value.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); |
184 |
| - return; // don't push the chunk so it gets removed from the response body |
| 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 | + } |
185 | 198 | }
|
186 |
| - chunks.push(rawHTML.replaceAll(NONCE_AEM, `'nonce-${nonce}'`)); |
187 |
| - return; |
188 |
| - } |
189 |
| - } |
190 | 199 |
|
191 |
| - if (scriptNonce && tag.tagName === 'script' && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) { |
192 |
| - chunks.push(rawHTML.replace(/nonce="aem"/i, `nonce="${nonce}"`)); |
193 |
| - return; |
194 |
| - } |
| 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 | + } |
195 | 204 |
|
196 |
| - if (styleNonce && (tag.tagName === 'style' || tag.tagName === 'link') && tag.attrs.find((attr) => attr.name === 'nonce' && attr.value === 'aem')) { |
197 |
| - chunks.push(rawHTML.replace(/nonce="aem"/i, `nonce="${nonce}"`)); |
198 |
| - return; |
199 |
| - } |
| 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 | + } |
200 | 209 |
|
201 |
| - chunks.push(rawHTML); |
| 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(_) {}, |
202 | 224 | });
|
203 | 225 |
|
204 |
| - rewriter.on('data', (data) => { |
205 |
| - chunks.push(data); |
206 |
| - }); |
| 226 | + tokenizer.write(html); |
| 227 | + chunks.push(html.slice(lastOffset)); |
207 | 228 |
|
208 |
| - rewriter.write(res.body); |
209 |
| - rewriter.end(); |
210 | 229 | res.body = chunks.join('');
|
211 | 230 | if (cspHeader) {
|
212 | 231 | res.headers.set('content-security-policy', cspHeader.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
|
|
0 commit comments