Skip to content

Commit 7849eeb

Browse files
committed
feat: Enable CSP with nonce - use low-level tokenizer and correct crypto
1 parent 9abfad5 commit 7849eeb

File tree

3 files changed

+58
-52
lines changed

3 files changed

+58
-52
lines changed

package-lock.json

Lines changed: 2 additions & 15 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 & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,7 @@
6060
"mdast-util-to-string": "4.0.0",
6161
"micromark-util-subtokenize": "2.0.4",
6262
"mime": "4.0.6",
63-
"parse5-html-rewriting-stream": "7.0.0",
63+
"parse5": "7.2.1",
6464
"rehype-format": "5.0.1",
6565
"rehype-parse": "9.0.1",
6666
"remark-parse": "11.0.0",

src/steps/csp.js

Lines changed: 55 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,12 @@
99
* OF ANY KIND, either express or implied. See the License for the specific language
1010
* governing permissions and limitations under the License.
1111
*/
12-
import crypto from 'crypto';
1312
import { select } from 'hast-util-select';
13+
import { Tokenizer } from 'parse5';
1414
import { remove } from 'unist-util-remove';
15-
import { RewritingStream } from 'parse5-html-rewriting-stream';
1615
import { visit } from 'unist-util-visit';
16+
// eslint-disable-next-line import/no-unresolved
17+
import cryptoImpl from '#crypto';
1718

1819
export const NONCE_AEM = '\'nonce-aem\'';
1920

@@ -58,7 +59,7 @@ function shouldApplyNonce(metaCSPText, headersCSPText) {
5859
* @returns {string}
5960
*/
6061
function createNonce() {
61-
return crypto.randomBytes(18).toString('base64');
62+
return cryptoImpl.randomBytes(18).toString('base64');
6263
}
6364

6465
/**
@@ -166,47 +167,65 @@ export function contentSecurityPolicyOnCode(state, res) {
166167
const nonce = createNonce();
167168
let { scriptNonce, styleNonce } = shouldApplyNonce(null, cspHeader);
168169

169-
const rewriter = new RewritingStream();
170+
const html = res.body;
170171
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+
}
185198
}
186-
chunks.push(rawHTML.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));
187-
return;
188-
}
189-
}
190199

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+
}
195204

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+
}
200209

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(_) {},
202224
});
203225

204-
rewriter.on('data', (data) => {
205-
chunks.push(data);
206-
});
226+
tokenizer.write(html);
227+
chunks.push(html.slice(lastOffset));
207228

208-
rewriter.write(res.body);
209-
rewriter.end();
210229
res.body = chunks.join('');
211230
if (cspHeader) {
212231
res.headers.set('content-security-policy', cspHeader.replaceAll(NONCE_AEM, `'nonce-${nonce}'`));

0 commit comments

Comments
 (0)