Skip to content

Commit 1d5eec4

Browse files
sapphi-redmaccuaajustin-tay
authored
feat: csp nonce support (#16052)
Co-authored-by: Andrew <[email protected]> Co-authored-by: Justin Tay <[email protected]>
1 parent f377a84 commit 1d5eec4

File tree

19 files changed

+273
-6
lines changed

19 files changed

+273
-6
lines changed

docs/config/shared-options.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,13 @@ Enabling this setting causes vite to determine file identity by the original fil
163163
- **Related:** [esbuild#preserve-symlinks](https://esbuild.github.io/api/#preserve-symlinks), [webpack#resolve.symlinks
164164
](https://webpack.js.org/configuration/resolve/#resolvesymlinks)
165165

166+
## html.cspNonce
167+
168+
- **Type:** `string`
169+
- **Related:** [Content Security Policy (CSP)](/guide/features#content-security-policy-csp)
170+
171+
A nonce value placeholder that will be used when generating script / style tags. Setting this value will also generate a meta tag with nonce value.
172+
166173
## css.modules
167174

168175
- **Type:**

docs/guide/features.md

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -642,6 +642,28 @@ import MyWorker from './worker?worker&url'
642642

643643
See [Worker Options](/config/worker-options.md) for details on configuring the bundling of all workers.
644644

645+
## Content Security Policy (CSP)
646+
647+
To deploy CSP, certain directives or configs must be set due to Vite's internals.
648+
649+
### [`'nonce-{RANDOM}'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#nonce-base64-value)
650+
651+
When [`html.cspNonce`](/config/shared-options#html-cspnonce) is set, Vite adds a nonce attribute with the specified value to the output script tag and link tag for stylesheets. Note that Vite will not add a nonce attribute to other tags, such as `<style>`. Additionally, when this option is set, Vite will inject a meta tag (`<meta property="csp-nonce" nonce="PLACEHOLDER" />`).
652+
653+
The nonce value of a meta tag with `property="csp-nonce"` will be used by Vite whenever necessary during both dev and after build.
654+
655+
:::warning
656+
Ensure that you replace the placeholder with a unique value for each request. This is important to prevent bypassing a resource's policy, which can otherwise be easily done.
657+
:::
658+
659+
### [`data:`](<https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#scheme-source:~:text=schemes%20(not%20recommended).-,data%3A,-Allows%20data%3A>)
660+
661+
By default, during build, Vite inlines small assets as data URIs. Allowing `data:` for related directives (e.g. [`img-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/img-src), [`font-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/font-src)), or, disabling it by setting [`build.assetsInlineLimit: 0`](/config/build-options#build-assetsinlinelimit) is necessary.
662+
663+
:::warning
664+
Do not allow `data:` for [`script-src`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src). It will allow injection of arbitrary scripts.
665+
:::
666+
645667
## Build Optimizations
646668

647669
> Features listed below are automatically applied as part of the build process and there is no need for explicit configuration unless you want to disable them.

packages/vite/src/client/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,6 +383,11 @@ if ('document' in globalThis) {
383383
})
384384
}
385385

386+
const cspNonce =
387+
'document' in globalThis
388+
? document.querySelector<HTMLMetaElement>('meta[property=csp-nonce]')?.nonce
389+
: undefined
390+
386391
// all css imports should be inserted at the same position
387392
// because after build it will be a single css file
388393
let lastInsertedStyle: HTMLStyleElement | undefined
@@ -394,6 +399,9 @@ export function updateStyle(id: string, content: string): void {
394399
style.setAttribute('type', 'text/css')
395400
style.setAttribute('data-vite-dev-id', id)
396401
style.textContent = content
402+
if (cspNonce) {
403+
style.setAttribute('nonce', cspNonce)
404+
}
397405

398406
if (!lastInsertedStyle) {
399407
document.head.appendChild(style)

packages/vite/src/node/config.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -173,6 +173,10 @@ export interface UserConfig {
173173
* Configure resolver
174174
*/
175175
resolve?: ResolveOptions & { alias?: AliasOptions }
176+
/**
177+
* HTML related options
178+
*/
179+
html?: HTMLOptions
176180
/**
177181
* CSS related options (preprocessors and CSS modules)
178182
*/
@@ -281,6 +285,15 @@ export interface UserConfig {
281285
appType?: AppType
282286
}
283287

288+
export interface HTMLOptions {
289+
/**
290+
* A nonce value placeholder that will be used when generating script/style tags.
291+
*
292+
* Make sure that this placeholder will be replaced with a unique value for each request by the server.
293+
*/
294+
cspNonce?: string
295+
}
296+
284297
export interface ExperimentalOptions {
285298
/**
286299
* Append fake `&lang.(ext)` when queries are specified, to preserve the file extension for following plugins to process.

packages/vite/src/node/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ export type {
2424
AppType,
2525
ConfigEnv,
2626
ExperimentalOptions,
27+
HTMLOptions,
2728
InlineConfig,
2829
LegacyOptions,
2930
PluginHookUtils,

packages/vite/src/node/plugins/html.ts

Lines changed: 63 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -309,8 +309,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
309309
config.plugins,
310310
config.logger,
311311
)
312+
preHooks.unshift(injectCspNonceMetaTagHook(config))
312313
preHooks.unshift(preImportMapHook(config))
313314
preHooks.push(htmlEnvHook(config))
315+
postHooks.push(injectNonceAttributeTagHook(config))
314316
postHooks.push(postImportMapHook())
315317
const processedHtml = new Map<string, string>()
316318

@@ -546,11 +548,9 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
546548
node.attrs.some(
547549
(p) =>
548550
p.name === 'rel' &&
549-
p.value
550-
.split(spaceRe)
551-
.some((v) =>
552-
noInlineLinkRels.has(v.toLowerCase()),
553-
),
551+
parseRelAttr(p.value).some((v) =>
552+
noInlineLinkRels.has(v),
553+
),
554554
)
555555
const shouldInline = isNoInlineLink ? false : undefined
556556
assetUrlsPromises.push(
@@ -939,6 +939,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
939939
}
940940
}
941941

942+
export function parseRelAttr(attr: string): string[] {
943+
return attr.split(spaceRe).map((v) => v.toLowerCase())
944+
}
945+
942946
// <tag style="... url(...) or image-set(...) ..."></tag>
943947
// extract inline styles as virtual css
944948
export function findNeedTransformStyleAttribute(
@@ -1088,6 +1092,24 @@ export function postImportMapHook(): IndexHtmlTransformHook {
10881092
}
10891093
}
10901094

1095+
export function injectCspNonceMetaTagHook(
1096+
config: ResolvedConfig,
1097+
): IndexHtmlTransformHook {
1098+
return () => {
1099+
if (!config.html?.cspNonce) return
1100+
1101+
return [
1102+
{
1103+
tag: 'meta',
1104+
injectTo: 'head',
1105+
// use nonce attribute so that it's hidden
1106+
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce#accessing_nonces_and_nonce_hiding
1107+
attrs: { property: 'csp-nonce', nonce: config.html.cspNonce },
1108+
},
1109+
]
1110+
}
1111+
}
1112+
10911113
/**
10921114
* Support `%ENV_NAME%` syntax in html files
10931115
*/
@@ -1137,6 +1159,42 @@ export function htmlEnvHook(config: ResolvedConfig): IndexHtmlTransformHook {
11371159
}
11381160
}
11391161

1162+
export function injectNonceAttributeTagHook(
1163+
config: ResolvedConfig,
1164+
): IndexHtmlTransformHook {
1165+
const processRelType = new Set(['stylesheet', 'modulepreload', 'preload'])
1166+
1167+
return async (html, { filename }) => {
1168+
const nonce = config.html?.cspNonce
1169+
if (!nonce) return
1170+
1171+
const s = new MagicString(html)
1172+
1173+
await traverseHtml(html, filename, (node) => {
1174+
if (!nodeIsElement(node)) {
1175+
return
1176+
}
1177+
1178+
if (
1179+
node.nodeName === 'script' ||
1180+
(node.nodeName === 'link' &&
1181+
node.attrs.some(
1182+
(attr) =>
1183+
attr.name === 'rel' &&
1184+
parseRelAttr(attr.value).some((a) => processRelType.has(a)),
1185+
))
1186+
) {
1187+
s.appendRight(
1188+
node.sourceCodeLocation!.startTag!.endOffset - 1,
1189+
` nonce="${nonce}"`,
1190+
)
1191+
}
1192+
})
1193+
1194+
return s.toString()
1195+
}
1196+
}
1197+
11401198
export function resolveHtmlTransforms(
11411199
plugins: readonly Plugin[],
11421200
logger: Logger,

packages/vite/src/node/plugins/importAnalysisBuild.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,13 @@ function preload(
8080
// @ts-expect-error __VITE_IS_MODERN__ will be replaced with boolean later
8181
if (__VITE_IS_MODERN__ && deps && deps.length > 0) {
8282
const links = document.getElementsByTagName('link')
83+
const cspNonceMeta = document.querySelector<HTMLMetaElement>(
84+
'meta[property=csp-nonce]',
85+
)
86+
// `.nonce` should be used to get along with nonce hiding (https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce#accessing_nonces_and_nonce_hiding)
87+
// Firefox 67-74 uses modern chunks and supports CSP nonce, but does not support `.nonce`
88+
// in that case fallback to getAttribute
89+
const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute('nonce')
8390

8491
promise = Promise.all(
8592
deps.map((dep) => {
@@ -116,6 +123,9 @@ function preload(
116123
link.crossOrigin = ''
117124
}
118125
link.href = dep
126+
if (cspNonce) {
127+
link.setAttribute('nonce', cspNonce)
128+
}
119129
document.head.appendChild(link)
120130
if (isCss) {
121131
return new Promise((res, rej) => {

packages/vite/src/node/server/middlewares/indexHtml.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,8 @@ import {
1515
getScriptInfo,
1616
htmlEnvHook,
1717
htmlProxyResult,
18+
injectCspNonceMetaTagHook,
19+
injectNonceAttributeTagHook,
1820
nodeIsElement,
1921
overwriteAttrValue,
2022
postImportMapHook,
@@ -69,11 +71,13 @@ export function createDevHtmlTransformFn(
6971
)
7072
const transformHooks = [
7173
preImportMapHook(config),
74+
injectCspNonceMetaTagHook(config),
7275
...preHooks,
7376
htmlEnvHook(config),
7477
devHtmlHook,
7578
...normalHooks,
7679
...postHooks,
80+
injectNonceAttributeTagHook(config),
7781
postImportMapHook(),
7882
]
7983
return (

playground/csp/__tests__/csp.spec.ts

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import { expect, test } from 'vitest'
2+
import { expectWithRetry, getColor, page } from '~utils'
3+
4+
test('linked css', async () => {
5+
expect(await getColor('.linked')).toBe('blue')
6+
})
7+
8+
test('inline style tag', async () => {
9+
expect(await getColor('.inline')).toBe('green')
10+
})
11+
12+
test('imported css', async () => {
13+
expect(await getColor('.from-js')).toBe('blue')
14+
})
15+
16+
test('dynamic css', async () => {
17+
expect(await getColor('.dynamic')).toBe('red')
18+
})
19+
20+
test('script tag', async () => {
21+
await expectWithRetry(() => page.textContent('.js')).toBe('js: ok')
22+
})
23+
24+
test('dynamic js', async () => {
25+
await expectWithRetry(() => page.textContent('.dynamic-js')).toBe(
26+
'dynamic-js: ok',
27+
)
28+
})
29+
30+
test('meta[property=csp-nonce] is injected', async () => {
31+
const meta = await page.$('meta[property=csp-nonce]')
32+
expect(await (await meta.getProperty('nonce')).jsonValue()).not.toBe('')
33+
})

playground/csp/dynamic.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.dynamic {
2+
color: red;
3+
}

playground/csp/dynamic.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
import './dynamic.css'
2+
3+
document.querySelector('.dynamic-js').textContent = 'dynamic-js: ok'

playground/csp/from-js.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.from-js {
2+
color: blue;
3+
}

playground/csp/index.html

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
<link rel="stylesheet" href="./linked.css" />
2+
<style nonce="#$NONCE$#">
3+
.inline {
4+
color: green;
5+
}
6+
</style>
7+
<script type="module" src="./index.js"></script>
8+
<p class="linked">direct</p>
9+
<p class="inline">inline</p>
10+
<p class="from-js">from-js</p>
11+
<p class="dynamic">dynamic</p>
12+
<p class="js">js: error</p>
13+
<p class="dynamic-js">dynamic-js: error</p>

playground/csp/index.js

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
import './from-js.css'
2+
3+
document.querySelector('.js').textContent = 'js: ok'
4+
5+
import('./dynamic.js')

playground/csp/linked.css

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
.linked {
2+
color: blue;
3+
}

playground/csp/package.json

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
{
2+
"name": "@vitejs/test-csp",
3+
"private": true,
4+
"version": "0.0.0",
5+
"type": "module",
6+
"scripts": {
7+
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
8+
"dev": "vite",
9+
"build": "vite build",
10+
"preview": "vite preview"
11+
}
12+
}

0 commit comments

Comments
 (0)