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:
翠 / green 2024-03-13 23:47:33 +09:00 committed by GitHub
parent f377a840ad
commit 1d5eec477e
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
19 changed files with 273 additions and 6 deletions

View File

@ -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:**

View File

@ -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.

View File

@ -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)

View File

@ -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.

View File

@ -24,6 +24,7 @@ export type {
AppType,
ConfigEnv,
ExperimentalOptions,
HTMLOptions,
InlineConfig,
LegacyOptions,
PluginHookUtils,

View File

@ -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,

View File

@ -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) => {

View File

@ -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 (

View 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('')
})

View File

@ -0,0 +1,3 @@
.dynamic {
color: red;
}

View File

@ -0,0 +1,3 @@
import './dynamic.css'
document.querySelector('.dynamic-js').textContent = 'dynamic-js: ok'

View File

@ -0,0 +1,3 @@
.from-js {
color: blue;
}

13
playground/csp/index.html Normal file
View 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
View File

@ -0,0 +1,5 @@
import './from-js.css'
document.querySelector('.js').textContent = 'js: ok'
import('./dynamic.js')

View File

@ -0,0 +1,3 @@
.linked {
color: blue;
}

View 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"
}
}

View 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),
)
}
},
},
],
})

View File

@ -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",
],

View File

@ -545,6 +545,8 @@ importers:
specifier: ^4.17.21
version: 4.17.21
playground/csp: {}
playground/css:
devDependencies:
'@vitejs/test-css-dep':