Skip to content

Commit 1eba69a

Browse files
authored
Merge pull request #658 from GalacticHypernova/patch-6
fix(cspSsrNonce): more robust tag replacement
2 parents 93e5dc2 + f123627 commit 1eba69a

File tree

4 files changed

+74
-25
lines changed

4 files changed

+74
-25
lines changed

src/runtime/nitro/plugins/40-cspSsrNonce.ts

Lines changed: 41 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,48 @@ import { defineNitroPlugin } from '#imports'
22
import { resolveSecurityRules } from '../context'
33
import { generateRandomNonce } from '../../../utils/crypto'
44

5-
const LINK_RE = /<link([^>]*?>)/gi
5+
const LINK_RE = /<link\b([^>]*?>)/gi
66
const NONCE_RE = /nonce="[^"]+"/i
7-
const SCRIPT_RE = /<script([^>]*?>)/gi
8-
const STYLE_RE = /<style([^>]*?>)/gi
7+
const SCRIPT_RE = /<script\b([^>]*?>)/gi
8+
const STYLE_RE = /<style\b([^>]*?>)/gi
9+
const QUOTE_MASK_RE = /"([^"]*)"/g
10+
const QUOTE_RESTORE_RE = /__QUOTE_PLACEHOLDER_(\d+)__/g
911

12+
function injectNonceToTags(element: string, nonce: string) {
13+
// Skip non-string elements
14+
if (typeof element !== 'string') {
15+
return element;
16+
}
17+
const quotes: string[] = [];
18+
19+
// Mask attributes to avoid manipulating stringified elements
20+
let maskedElement = element.replace(QUOTE_MASK_RE, (match) => {
21+
quotes.push(match);
22+
return `__QUOTE_PLACEHOLDER_${quotes.length - 1}__`;
23+
});
24+
// Add nonce to all link tags
25+
maskedElement = maskedElement.replace(LINK_RE, (match, rest) => {
26+
if (NONCE_RE.test(rest)) {
27+
return match.replace(NONCE_RE, `nonce="${nonce}"`);
28+
}
29+
return `<link nonce="${nonce}"` + rest
30+
})
31+
// Add nonce to all script tags
32+
maskedElement = maskedElement.replace(SCRIPT_RE, (match, rest) => {
33+
return `<script nonce="${nonce}"` + rest
34+
})
35+
// Add nonce to all style tags
36+
maskedElement = maskedElement.replace(STYLE_RE, (match, rest) => {
37+
return `<style nonce="${nonce}"` + rest
38+
})
39+
40+
// Restore the original quoted content.
41+
const restoredHtml = maskedElement.replace(QUOTE_RESTORE_RE, (match, index) => {
42+
return quotes[parseInt(index, 10)];
43+
});
44+
45+
return restoredHtml;
46+
}
1047

1148
/**
1249
* This plugin generates a nonce for the current request and adds it to the HTML.
@@ -52,28 +89,7 @@ export default defineNitroPlugin((nitroApp) => {
5289
type Section = 'body' | 'bodyAppend' | 'bodyPrepend' | 'head'
5390
const sections = ['body', 'bodyAppend', 'bodyPrepend', 'head'] as Section[]
5491
for (const section of sections) {
55-
html[section] = html[section].map((element) => {
56-
// Skip non-string elements
57-
if (typeof element !== 'string') {
58-
return element;
59-
}
60-
// Add nonce to all link tags
61-
element = element.replace(LINK_RE, (match, rest) => {
62-
if (NONCE_RE.test(rest)) {
63-
return match.replace(NONCE_RE, `nonce="${nonce}"`);
64-
}
65-
return `<link nonce="${nonce}"` + rest
66-
})
67-
// Add nonce to all script tags
68-
element = element.replace(SCRIPT_RE, (match, rest) => {
69-
return `<script nonce="${nonce}"` + rest
70-
})
71-
// Add nonce to all style tags
72-
element = element.replace(STYLE_RE, (match, rest) => {
73-
return `<style nonce="${nonce}"` + rest
74-
})
75-
return element
76-
})
92+
html[section] = html[section].map((element) => injectNonceToTags(element, nonce))
7793
}
7894

7995
// Add meta header for Vite in development
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
<template>
2+
<div class="<script>This should remain with no nonce">
3+
Hello
4+
</div>
5+
</template>
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
<template>
2+
<div>
3+
<div>Hello</div>
4+
<scripter>This should remain as is</scripter>
5+
</div>
6+
</template>

test/ssrNonce.test.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,28 @@ describe('[nuxt-security] Nonce', async () => {
9898
expect(cspNonces).toBe(null)
9999
})
100100

101+
/*it('does not modify custom elements', async () => {
102+
const res = await fetch('/with-custom-element')
103+
104+
const body = /<scripter>/.test(await res.text())
105+
106+
expect(res).toBeDefined()
107+
expect(res).toBeTruthy()
108+
expect(body).toBe(true)
109+
})*/
110+
111+
it('does not modify stringified elements', async () => {
112+
const res = await fetch('/string-script').then(res=>res.text())
113+
114+
const body = res.match(/<div class="(.+)Hello/)
115+
const hasNonce = body[1].includes('nonce=')
116+
117+
expect(res).toBeDefined()
118+
expect(res).toBeTruthy()
119+
expect(body[0]).toBeDefined()
120+
expect(hasNonce).toBe(false)
121+
})
122+
101123
// TODO: reenable if it's possible for island context to share the same `event.context.security.nonce`
102124
it.skip('works with server-only components', async () => {
103125
const res = await fetch('/server-component')

0 commit comments

Comments
 (0)