Skip to content

Commit

Permalink
feat: csp nonce support (#16052)
Browse files Browse the repository at this point in the history
Co-authored-by: Andrew <8158705+maccuaa@users.noreply.github.com>
Co-authored-by: Justin Tay <49700559+justin-tay@users.noreply.github.com>
  • Loading branch information
3 people committed Mar 13, 2024
1 parent f377a84 commit 1d5eec4
Show file tree
Hide file tree
Showing 19 changed files with 273 additions and 6 deletions.
7 changes: 7 additions & 0 deletions docs/config/shared-options.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:**
Expand Down
22 changes: 22 additions & 0 deletions docs/guide/features.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 8 additions & 0 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down
13 changes: 13 additions & 0 deletions packages/vite/src/node/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
*/
Expand Down Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions packages/vite/src/node/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export type {
AppType,
ConfigEnv,
ExperimentalOptions,
HTMLOptions,
InlineConfig,
LegacyOptions,
PluginHookUtils,
Expand Down
68 changes: 63 additions & 5 deletions packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>()

Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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,
Expand Down
10 changes: 10 additions & 0 deletions packages/vite/src/node/plugins/importAnalysisBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down Expand Up @@ -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) => {
Expand Down
4 changes: 4 additions & 0 deletions packages/vite/src/node/server/middlewares/indexHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ import {
getScriptInfo,
htmlEnvHook,
htmlProxyResult,
injectCspNonceMetaTagHook,
injectNonceAttributeTagHook,
nodeIsElement,
overwriteAttrValue,
postImportMapHook,
Expand Down Expand Up @@ -69,11 +71,13 @@ export function createDevHtmlTransformFn(
)
const transformHooks = [
preImportMapHook(config),
injectCspNonceMetaTagHook(config),
...preHooks,
htmlEnvHook(config),
devHtmlHook,
...normalHooks,
...postHooks,
injectNonceAttributeTagHook(config),
postImportMapHook(),
]
return (
Expand Down
33 changes: 33 additions & 0 deletions playground/csp/__tests__/csp.spec.ts
Original file line number Diff line number Diff line change
@@ -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 changes: 3 additions & 0 deletions playground/csp/dynamic.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.dynamic {
color: red;
}
3 changes: 3 additions & 0 deletions playground/csp/dynamic.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import './dynamic.css'

document.querySelector('.dynamic-js').textContent = 'dynamic-js: ok'
3 changes: 3 additions & 0 deletions playground/csp/from-js.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.from-js {
color: blue;
}
13 changes: 13 additions & 0 deletions playground/csp/index.html
Original file line number Diff line number Diff line change
@@ -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 changes: 5 additions & 0 deletions playground/csp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import './from-js.css'

document.querySelector('.js').textContent = 'js: ok'

import('./dynamic.js')
3 changes: 3 additions & 0 deletions playground/csp/linked.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.linked {
color: blue;
}
12 changes: 12 additions & 0 deletions playground/csp/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}

0 comments on commit 1d5eec4

Please sign in to comment.