Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: csp nonce support #16052

Merged
merged 15 commits into from
Mar 13, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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.

patak-dev marked this conversation as resolved.
Show resolved Hide resolved
## 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"
}
}
Loading