diff --git a/docs/config/shared-options.md b/docs/config/shared-options.md index d8b81b4531f571..95ec386e3b811a 100644 --- a/docs/config/shared-options.md +++ b/docs/config/shared-options.md @@ -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:** diff --git a/docs/guide/features.md b/docs/guide/features.md index 19e4e3adc22617..cbb981a0e14282 100644 --- a/docs/guide/features.md +++ b/docs/guide/features.md @@ -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 ` + +

direct

+

inline

+

from-js

+

dynamic

+

js: error

+

dynamic-js: error

diff --git a/playground/csp/index.js b/playground/csp/index.js new file mode 100644 index 00000000000000..465359baca8297 --- /dev/null +++ b/playground/csp/index.js @@ -0,0 +1,5 @@ +import './from-js.css' + +document.querySelector('.js').textContent = 'js: ok' + +import('./dynamic.js') diff --git a/playground/csp/linked.css b/playground/csp/linked.css new file mode 100644 index 00000000000000..51636e6cfad81f --- /dev/null +++ b/playground/csp/linked.css @@ -0,0 +1,3 @@ +.linked { + color: blue; +} diff --git a/playground/csp/package.json b/playground/csp/package.json new file mode 100644 index 00000000000000..e8a834d93abd25 --- /dev/null +++ b/playground/csp/package.json @@ -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" + } +} diff --git a/playground/csp/vite.config.js b/playground/csp/vite.config.js new file mode 100644 index 00000000000000..08d2b74f9dde3c --- /dev/null +++ b/playground/csp/vite.config.js @@ -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} 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), + ) + } + }, + }, + ], +}) diff --git a/playground/js-sourcemap/__tests__/js-sourcemap.spec.ts b/playground/js-sourcemap/__tests__/js-sourcemap.spec.ts index 145e1f0b35b5ab..fd5d91a26af1b1 100644 --- a/playground/js-sourcemap/__tests__/js-sourcemap.spec.ts +++ b/playground/js-sourcemap/__tests__/js-sourcemap.spec.ts @@ -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", ], diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 421f405d232e43..03b925b3a34c11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -545,6 +545,8 @@ importers: specifier: ^4.17.21 version: 4.17.21 + playground/csp: {} + playground/css: devDependencies: '@vitejs/test-css-dep':