mirror of
https://github.com/vitejs/vite.git
synced 2024-11-21 22:59:10 +00:00
feat: csp nonce support (#16052)
Co-authored-by: Andrew <8158705+maccuaa@users.noreply.github.com> Co-authored-by: Justin Tay <49700559+justin-tay@users.noreply.github.com>
This commit is contained in:
parent
f377a840ad
commit
1d5eec477e
@ -163,6 +163,13 @@ Enabling this setting causes vite to determine file identity by the original fil
|
||||
- **Related:** [esbuild#preserve-symlinks](https://esbuild.github.io/api/#preserve-symlinks), [webpack#resolve.symlinks
|
||||
](https://webpack.js.org/configuration/resolve/#resolvesymlinks)
|
||||
|
||||
## html.cspNonce
|
||||
|
||||
- **Type:** `string`
|
||||
- **Related:** [Content Security Policy (CSP)](/guide/features#content-security-policy-csp)
|
||||
|
||||
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.
|
||||
|
||||
## css.modules
|
||||
|
||||
- **Type:**
|
||||
|
@ -642,6 +642,28 @@ import MyWorker from './worker?worker&url'
|
||||
|
||||
See [Worker Options](/config/worker-options.md) for details on configuring the bundling of all workers.
|
||||
|
||||
## Content Security Policy (CSP)
|
||||
|
||||
To deploy CSP, certain directives or configs must be set due to Vite's internals.
|
||||
|
||||
### [`'nonce-{RANDOM}'`](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#nonce-base64-value)
|
||||
|
||||
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" />`).
|
||||
|
||||
The nonce value of a meta tag with `property="csp-nonce"` will be used by Vite whenever necessary during both dev and after build.
|
||||
|
||||
:::warning
|
||||
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.
|
||||
:::
|
||||
|
||||
### [`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>)
|
||||
|
||||
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.
|
||||
|
||||
:::warning
|
||||
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.
|
||||
:::
|
||||
|
||||
## Build Optimizations
|
||||
|
||||
> 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.
|
||||
|
@ -383,6 +383,11 @@ if ('document' in globalThis) {
|
||||
})
|
||||
}
|
||||
|
||||
const cspNonce =
|
||||
'document' in globalThis
|
||||
? document.querySelector<HTMLMetaElement>('meta[property=csp-nonce]')?.nonce
|
||||
: undefined
|
||||
|
||||
// all css imports should be inserted at the same position
|
||||
// because after build it will be a single css file
|
||||
let lastInsertedStyle: HTMLStyleElement | undefined
|
||||
@ -394,6 +399,9 @@ export function updateStyle(id: string, content: string): void {
|
||||
style.setAttribute('type', 'text/css')
|
||||
style.setAttribute('data-vite-dev-id', id)
|
||||
style.textContent = content
|
||||
if (cspNonce) {
|
||||
style.setAttribute('nonce', cspNonce)
|
||||
}
|
||||
|
||||
if (!lastInsertedStyle) {
|
||||
document.head.appendChild(style)
|
||||
|
@ -173,6 +173,10 @@ export interface UserConfig {
|
||||
* Configure resolver
|
||||
*/
|
||||
resolve?: ResolveOptions & { alias?: AliasOptions }
|
||||
/**
|
||||
* HTML related options
|
||||
*/
|
||||
html?: HTMLOptions
|
||||
/**
|
||||
* CSS related options (preprocessors and CSS modules)
|
||||
*/
|
||||
@ -281,6 +285,15 @@ export interface UserConfig {
|
||||
appType?: AppType
|
||||
}
|
||||
|
||||
export interface HTMLOptions {
|
||||
/**
|
||||
* A nonce value placeholder that will be used when generating script/style tags.
|
||||
*
|
||||
* Make sure that this placeholder will be replaced with a unique value for each request by the server.
|
||||
*/
|
||||
cspNonce?: string
|
||||
}
|
||||
|
||||
export interface ExperimentalOptions {
|
||||
/**
|
||||
* Append fake `&lang.(ext)` when queries are specified, to preserve the file extension for following plugins to process.
|
||||
|
@ -24,6 +24,7 @@ export type {
|
||||
AppType,
|
||||
ConfigEnv,
|
||||
ExperimentalOptions,
|
||||
HTMLOptions,
|
||||
InlineConfig,
|
||||
LegacyOptions,
|
||||
PluginHookUtils,
|
||||
|
@ -309,8 +309,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
|
||||
config.plugins,
|
||||
config.logger,
|
||||
)
|
||||
preHooks.unshift(injectCspNonceMetaTagHook(config))
|
||||
preHooks.unshift(preImportMapHook(config))
|
||||
preHooks.push(htmlEnvHook(config))
|
||||
postHooks.push(injectNonceAttributeTagHook(config))
|
||||
postHooks.push(postImportMapHook())
|
||||
const processedHtml = new Map<string, string>()
|
||||
|
||||
@ -546,11 +548,9 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
|
||||
node.attrs.some(
|
||||
(p) =>
|
||||
p.name === 'rel' &&
|
||||
p.value
|
||||
.split(spaceRe)
|
||||
.some((v) =>
|
||||
noInlineLinkRels.has(v.toLowerCase()),
|
||||
),
|
||||
parseRelAttr(p.value).some((v) =>
|
||||
noInlineLinkRels.has(v),
|
||||
),
|
||||
)
|
||||
const shouldInline = isNoInlineLink ? false : undefined
|
||||
assetUrlsPromises.push(
|
||||
@ -939,6 +939,10 @@ export function buildHtmlPlugin(config: ResolvedConfig): Plugin {
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRelAttr(attr: string): string[] {
|
||||
return attr.split(spaceRe).map((v) => v.toLowerCase())
|
||||
}
|
||||
|
||||
// <tag style="... url(...) or image-set(...) ..."></tag>
|
||||
// extract inline styles as virtual css
|
||||
export function findNeedTransformStyleAttribute(
|
||||
@ -1088,6 +1092,24 @@ export function postImportMapHook(): IndexHtmlTransformHook {
|
||||
}
|
||||
}
|
||||
|
||||
export function injectCspNonceMetaTagHook(
|
||||
config: ResolvedConfig,
|
||||
): IndexHtmlTransformHook {
|
||||
return () => {
|
||||
if (!config.html?.cspNonce) return
|
||||
|
||||
return [
|
||||
{
|
||||
tag: 'meta',
|
||||
injectTo: 'head',
|
||||
// use nonce attribute so that it's hidden
|
||||
// https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce#accessing_nonces_and_nonce_hiding
|
||||
attrs: { property: 'csp-nonce', nonce: config.html.cspNonce },
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Support `%ENV_NAME%` syntax in html files
|
||||
*/
|
||||
@ -1137,6 +1159,42 @@ export function htmlEnvHook(config: ResolvedConfig): IndexHtmlTransformHook {
|
||||
}
|
||||
}
|
||||
|
||||
export function injectNonceAttributeTagHook(
|
||||
config: ResolvedConfig,
|
||||
): IndexHtmlTransformHook {
|
||||
const processRelType = new Set(['stylesheet', 'modulepreload', 'preload'])
|
||||
|
||||
return async (html, { filename }) => {
|
||||
const nonce = config.html?.cspNonce
|
||||
if (!nonce) return
|
||||
|
||||
const s = new MagicString(html)
|
||||
|
||||
await traverseHtml(html, filename, (node) => {
|
||||
if (!nodeIsElement(node)) {
|
||||
return
|
||||
}
|
||||
|
||||
if (
|
||||
node.nodeName === 'script' ||
|
||||
(node.nodeName === 'link' &&
|
||||
node.attrs.some(
|
||||
(attr) =>
|
||||
attr.name === 'rel' &&
|
||||
parseRelAttr(attr.value).some((a) => processRelType.has(a)),
|
||||
))
|
||||
) {
|
||||
s.appendRight(
|
||||
node.sourceCodeLocation!.startTag!.endOffset - 1,
|
||||
` nonce="${nonce}"`,
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
return s.toString()
|
||||
}
|
||||
}
|
||||
|
||||
export function resolveHtmlTransforms(
|
||||
plugins: readonly Plugin[],
|
||||
logger: Logger,
|
||||
|
@ -80,6 +80,13 @@ function preload(
|
||||
// @ts-expect-error __VITE_IS_MODERN__ will be replaced with boolean later
|
||||
if (__VITE_IS_MODERN__ && deps && deps.length > 0) {
|
||||
const links = document.getElementsByTagName('link')
|
||||
const cspNonceMeta = document.querySelector<HTMLMetaElement>(
|
||||
'meta[property=csp-nonce]',
|
||||
)
|
||||
// `.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)
|
||||
// Firefox 67-74 uses modern chunks and supports CSP nonce, but does not support `.nonce`
|
||||
// in that case fallback to getAttribute
|
||||
const cspNonce = cspNonceMeta?.nonce || cspNonceMeta?.getAttribute('nonce')
|
||||
|
||||
promise = Promise.all(
|
||||
deps.map((dep) => {
|
||||
@ -116,6 +123,9 @@ function preload(
|
||||
link.crossOrigin = ''
|
||||
}
|
||||
link.href = dep
|
||||
if (cspNonce) {
|
||||
link.setAttribute('nonce', cspNonce)
|
||||
}
|
||||
document.head.appendChild(link)
|
||||
if (isCss) {
|
||||
return new Promise((res, rej) => {
|
||||
|
@ -15,6 +15,8 @@ import {
|
||||
getScriptInfo,
|
||||
htmlEnvHook,
|
||||
htmlProxyResult,
|
||||
injectCspNonceMetaTagHook,
|
||||
injectNonceAttributeTagHook,
|
||||
nodeIsElement,
|
||||
overwriteAttrValue,
|
||||
postImportMapHook,
|
||||
@ -69,11 +71,13 @@ export function createDevHtmlTransformFn(
|
||||
)
|
||||
const transformHooks = [
|
||||
preImportMapHook(config),
|
||||
injectCspNonceMetaTagHook(config),
|
||||
...preHooks,
|
||||
htmlEnvHook(config),
|
||||
devHtmlHook,
|
||||
...normalHooks,
|
||||
...postHooks,
|
||||
injectNonceAttributeTagHook(config),
|
||||
postImportMapHook(),
|
||||
]
|
||||
return (
|
||||
|
33
playground/csp/__tests__/csp.spec.ts
Normal file
33
playground/csp/__tests__/csp.spec.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { expect, test } from 'vitest'
|
||||
import { expectWithRetry, getColor, page } from '~utils'
|
||||
|
||||
test('linked css', async () => {
|
||||
expect(await getColor('.linked')).toBe('blue')
|
||||
})
|
||||
|
||||
test('inline style tag', async () => {
|
||||
expect(await getColor('.inline')).toBe('green')
|
||||
})
|
||||
|
||||
test('imported css', async () => {
|
||||
expect(await getColor('.from-js')).toBe('blue')
|
||||
})
|
||||
|
||||
test('dynamic css', async () => {
|
||||
expect(await getColor('.dynamic')).toBe('red')
|
||||
})
|
||||
|
||||
test('script tag', async () => {
|
||||
await expectWithRetry(() => page.textContent('.js')).toBe('js: ok')
|
||||
})
|
||||
|
||||
test('dynamic js', async () => {
|
||||
await expectWithRetry(() => page.textContent('.dynamic-js')).toBe(
|
||||
'dynamic-js: ok',
|
||||
)
|
||||
})
|
||||
|
||||
test('meta[property=csp-nonce] is injected', async () => {
|
||||
const meta = await page.$('meta[property=csp-nonce]')
|
||||
expect(await (await meta.getProperty('nonce')).jsonValue()).not.toBe('')
|
||||
})
|
3
playground/csp/dynamic.css
Normal file
3
playground/csp/dynamic.css
Normal file
@ -0,0 +1,3 @@
|
||||
.dynamic {
|
||||
color: red;
|
||||
}
|
3
playground/csp/dynamic.js
Normal file
3
playground/csp/dynamic.js
Normal file
@ -0,0 +1,3 @@
|
||||
import './dynamic.css'
|
||||
|
||||
document.querySelector('.dynamic-js').textContent = 'dynamic-js: ok'
|
3
playground/csp/from-js.css
Normal file
3
playground/csp/from-js.css
Normal file
@ -0,0 +1,3 @@
|
||||
.from-js {
|
||||
color: blue;
|
||||
}
|
13
playground/csp/index.html
Normal file
13
playground/csp/index.html
Normal file
@ -0,0 +1,13 @@
|
||||
<link rel="stylesheet" href="./linked.css" />
|
||||
<style nonce="#$NONCE$#">
|
||||
.inline {
|
||||
color: green;
|
||||
}
|
||||
</style>
|
||||
<script type="module" src="./index.js"></script>
|
||||
<p class="linked">direct</p>
|
||||
<p class="inline">inline</p>
|
||||
<p class="from-js">from-js</p>
|
||||
<p class="dynamic">dynamic</p>
|
||||
<p class="js">js: error</p>
|
||||
<p class="dynamic-js">dynamic-js: error</p>
|
5
playground/csp/index.js
Normal file
5
playground/csp/index.js
Normal file
@ -0,0 +1,5 @@
|
||||
import './from-js.css'
|
||||
|
||||
document.querySelector('.js').textContent = 'js: ok'
|
||||
|
||||
import('./dynamic.js')
|
3
playground/csp/linked.css
Normal file
3
playground/csp/linked.css
Normal file
@ -0,0 +1,3 @@
|
||||
.linked {
|
||||
color: blue;
|
||||
}
|
12
playground/csp/package.json
Normal file
12
playground/csp/package.json
Normal file
@ -0,0 +1,12 @@
|
||||
{
|
||||
"name": "@vitejs/test-csp",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"debug": "node --inspect-brk ../../packages/vite/bin/vite",
|
||||
"dev": "vite",
|
||||
"build": "vite build",
|
||||
"preview": "vite preview"
|
||||
}
|
||||
}
|
67
playground/csp/vite.config.js
Normal file
67
playground/csp/vite.config.js
Normal file
@ -0,0 +1,67 @@
|
||||
import fs from 'node:fs/promises'
|
||||
import url from 'node:url'
|
||||
import path from 'node:path'
|
||||
import crypto from 'node:crypto'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
const __dirname = path.dirname(url.fileURLToPath(import.meta.url))
|
||||
|
||||
const noncePlaceholder = '#$NONCE$#'
|
||||
const createNonce = () => crypto.randomBytes(16).toString('base64')
|
||||
|
||||
/**
|
||||
* @param {import('node:http').ServerResponse} res
|
||||
* @param {string} nonce
|
||||
*/
|
||||
const setNonceHeader = (res, nonce) => {
|
||||
res.setHeader(
|
||||
'Content-Security-Policy',
|
||||
`default-src 'nonce-${nonce}'; connect-src 'self'`,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} file
|
||||
* @param {(input: string, originalUrl: string) => Promise<string>} transform
|
||||
* @returns {import('vite').Connect.NextHandleFunction}
|
||||
*/
|
||||
const createMiddleware = (file, transform) => async (req, res) => {
|
||||
const nonce = createNonce()
|
||||
setNonceHeader(res, nonce)
|
||||
const content = await fs.readFile(path.join(__dirname, file), 'utf8')
|
||||
const transformedContent = await transform(content, req.originalUrl)
|
||||
res.setHeader('Content-Type', 'text/html')
|
||||
res.end(transformedContent.replaceAll(noncePlaceholder, nonce))
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
{
|
||||
name: 'nonce-inject',
|
||||
config() {
|
||||
return {
|
||||
appType: 'custom',
|
||||
html: {
|
||||
cspNonce: noncePlaceholder,
|
||||
},
|
||||
}
|
||||
},
|
||||
configureServer({ transformIndexHtml, middlewares }) {
|
||||
return () => {
|
||||
middlewares.use(
|
||||
createMiddleware('./index.html', (input, originalUrl) =>
|
||||
transformIndexHtml(originalUrl, input),
|
||||
),
|
||||
)
|
||||
}
|
||||
},
|
||||
configurePreviewServer({ middlewares }) {
|
||||
return () => {
|
||||
middlewares.use(
|
||||
createMiddleware('./dist/index.html', async (input) => input),
|
||||
)
|
||||
}
|
||||
},
|
||||
},
|
||||
],
|
||||
})
|
@ -138,7 +138,7 @@ describe.runIf(isBuild)('build tests', () => {
|
||||
expect(formatSourcemapForSnapshot(JSON.parse(map))).toMatchInlineSnapshot(`
|
||||
{
|
||||
"ignoreList": [],
|
||||
"mappings": ";;;;;;i3BAAA,OAAO,2BAAuB,EAAC,wBAE/B,QAAQ,IAAI,uBAAuB",
|
||||
"mappings": ";;;;;;w+BAAA,OAAO,2BAAuB,EAAC,wBAE/B,QAAQ,IAAI,uBAAuB",
|
||||
"sources": [
|
||||
"../../after-preload-dynamic.js",
|
||||
],
|
||||
|
@ -545,6 +545,8 @@ importers:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
|
||||
playground/csp: {}
|
||||
|
||||
playground/css:
|
||||
devDependencies:
|
||||
'@vitejs/test-css-dep':
|
||||
|
Loading…
Reference in New Issue
Block a user