Skip to content

Commit

Permalink
feat: csp support
Browse files Browse the repository at this point in the history
  • Loading branch information
sapphi-red committed Oct 16, 2023
1 parent c81f3da commit 452f8a8
Show file tree
Hide file tree
Showing 12 changed files with 212 additions and 67 deletions.
15 changes: 15 additions & 0 deletions packages/vite/src/client/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -392,6 +392,18 @@ if ('document' in globalThis) {
})
}

function getNonceFromElements(selectors: 'style' | 'script') {
return [...document.querySelectorAll(selectors)].find(
(element) => !!element.nonce,
)?.nonce
}

const nonceValue =
'document' in globalThis
? // prioritize style tag's nonce as that will work with style-src
getNonceFromElements('style') ?? getNonceFromElements('script')
: 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 @@ -402,6 +414,9 @@ export function updateStyle(id: string, content: string): void {
style = document.createElement('style')
style.setAttribute('type', 'text/css')
style.setAttribute('data-vite-dev-id', id)
if (nonceValue) {
style.setAttribute('nonce', nonceValue)
}
style.textContent = content

if (!lastInsertedStyle) {
Expand Down
2 changes: 1 addition & 1 deletion packages/vite/src/client/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
"compilerOptions": {
"types": [],
"target": "ES2019",
"lib": ["ESNext", "DOM"],
"lib": ["ESNext", "DOM", "DOM.Iterable"],
"declaration": false
}
}
2 changes: 2 additions & 0 deletions packages/vite/src/node/plugins/html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -930,6 +930,8 @@ export interface IndexHtmlTransformContext {
bundle?: OutputBundle
chunk?: OutputChunk
originalUrl?: string
/** nonce value extracted from script tag only available in normal/post hooks during dev */
nonce?: string
}

export type IndexHtmlTransformHook = (
Expand Down
144 changes: 82 additions & 62 deletions packages/vite/src/node/plugins/importAnalysisBuild.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,72 +84,92 @@ function detectScriptRel() {

declare const scriptRel: string
declare const seen: Record<string, boolean>
function preload(
baseModule: () => Promise<{}>,
deps?: string[],
importerUrl?: string,
) {
// @ts-expect-error __VITE_IS_MODERN__ will be replaced with boolean later
if (!__VITE_IS_MODERN__ || !deps || deps.length === 0) {
return baseModule()
function createPreload() {
function getCspNonce(tagName: 'script' | 'style'): string | undefined {
const elements = document.getElementsByTagName(tagName)
for (let i = 0; i < elements.length; i++) {
const element = elements[i]
if (element.nonce) {
return element.nonce
}
}
}

const links = document.getElementsByTagName('link')

return Promise.all(
deps.map((dep) => {
// @ts-expect-error assetsURL is declared before preload.toString()
dep = assetsURL(dep, importerUrl)
if (dep in seen) return
seen[dep] = true
const isCss = dep.endsWith('.css')
const cssSelector = isCss ? '[rel="stylesheet"]' : ''
const isBaseRelative = !!importerUrl

// check if the file is already preloaded by SSR markup
if (isBaseRelative) {
// When isBaseRelative is true then we have `importerUrl` and `dep` is
// already converted to an absolute URL by the `assetsURL` function
for (let i = links.length - 1; i >= 0; i--) {
const link = links[i]
// The `links[i].href` is an absolute URL thanks to browser doing the work
// for us. See https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes:idl-domstring-5
if (link.href === dep && (!isCss || link.rel === 'stylesheet')) {
return
const scriptNonceRawValue = getCspNonce('script')
const styleNonceRawValue = getCspNonce('style')
const scriptNonceValue = scriptNonceRawValue ?? styleNonceRawValue
const styleNonceValue = styleNonceRawValue ?? scriptNonceRawValue

return function preload(
baseModule: () => Promise<{}>,
deps?: string[],
importerUrl?: string,
) {
// @ts-expect-error __VITE_IS_MODERN__ will be replaced with boolean later
if (!__VITE_IS_MODERN__ || !deps || deps.length === 0) {
return baseModule()
}

const links = document.getElementsByTagName('link')

return Promise.all(
deps.map((dep) => {
// @ts-expect-error assetsURL is declared before preload.toString()
dep = assetsURL(dep, importerUrl)
if (dep in seen) return
seen[dep] = true
const isCss = dep.endsWith('.css')
const cssSelector = isCss ? '[rel="stylesheet"]' : ''
const isBaseRelative = !!importerUrl

// check if the file is already preloaded by SSR markup
if (isBaseRelative) {
// When isBaseRelative is true then we have `importerUrl` and `dep` is
// already converted to an absolute URL by the `assetsURL` function
for (let i = links.length - 1; i >= 0; i--) {
const link = links[i]
// The `links[i].href` is an absolute URL thanks to browser doing the work
// for us. See https://html.spec.whatwg.org/multipage/common-dom-interfaces.html#reflecting-content-attributes-in-idl-attributes:idl-domstring-5
if (link.href === dep && (!isCss || link.rel === 'stylesheet')) {
return
}
}
} else if (
document.querySelector(`link[href="${dep}"]${cssSelector}`)
) {
return
}
} else if (document.querySelector(`link[href="${dep}"]${cssSelector}`)) {
return
}

const link = document.createElement('link')
link.rel = isCss ? 'stylesheet' : scriptRel
if (!isCss) {
link.as = 'script'
link.crossOrigin = ''
}
link.href = dep
document.head.appendChild(link)
if (isCss) {
return new Promise((res, rej) => {
link.addEventListener('load', res)
link.addEventListener('error', () =>
rej(new Error(`Unable to preload CSS for ${dep}`)),
)
})
}
}),
)
.then(() => baseModule())
.catch((err) => {
const e = new Event('vite:preloadError', { cancelable: true })
// @ts-expect-error custom payload
e.payload = err
window.dispatchEvent(e)
if (!e.defaultPrevented) {
throw err
}
})
const link = document.createElement('link')
link.rel = isCss ? 'stylesheet' : scriptRel
if (!isCss) {
link.as = 'script'
link.crossOrigin = ''
}
link.href = dep
link.nonce = isCss ? styleNonceValue : scriptNonceValue
document.head.appendChild(link)
if (isCss) {
return new Promise((res, rej) => {
link.addEventListener('load', res)
link.addEventListener('error', () =>
rej(new Error(`Unable to preload CSS for ${dep}`)),
)
})
}
}),
)
.then(() => baseModule())
.catch((err) => {
const e = new Event('vite:preloadError', { cancelable: true })
// @ts-expect-error custom payload
e.payload = err
window.dispatchEvent(e)
if (!e.defaultPrevented) {
throw err
}
})
}
}

/**
Expand Down Expand Up @@ -196,7 +216,7 @@ export function buildImportAnalysisPlugin(config: ResolvedConfig): Plugin {
: // If the base isn't relative, then the deps are relative to the projects `outDir` and the base
// is appended inside __vitePreload too.
`function(dep) { return ${JSON.stringify(config.base)}+dep }`
const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = ${preload.toString()}`
const preloadCode = `const scriptRel = ${scriptRel};const assetsURL = ${assetsURL};const seen = {};export const ${preloadMethod} = (${createPreload.toString()})()`

return {
name: 'vite:build-import-analysis',
Expand Down
14 changes: 10 additions & 4 deletions packages/vite/src/node/server/middlewares/indexHtml.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,7 @@ export function createDevHtmlTransformFn(
filename: getHtmlFilename(url, server),
server,
originalUrl,
nonce: undefined,
},
)
}
Expand Down Expand Up @@ -154,10 +155,8 @@ const processNodeUrl = (
return processedUrl
}
}
const devHtmlHook: IndexHtmlTransformHook = async (
html,
{ path: htmlPath, filename, server, originalUrl },
) => {
const devHtmlHook: IndexHtmlTransformHook = async (html, ctx) => {
let { path: htmlPath, filename, server, originalUrl } = ctx
const { config, moduleGraph, watcher } = server!
const base = config.base || '/'
htmlPath = decodeURI(htmlPath)
Expand Down Expand Up @@ -274,6 +273,12 @@ const devHtmlHook: IndexHtmlTransformHook = async (
}
}
}

if (!ctx.nonce) {
ctx.nonce = node.attrs.find(
(attr) => attr.prefix === undefined && attr.name === 'nonce',
)?.value
}
}

const inlineStyle = findNeedTransformStyleAttribute(node)
Expand Down Expand Up @@ -371,6 +376,7 @@ const devHtmlHook: IndexHtmlTransformHook = async (
attrs: {
type: 'module',
src: path.posix.join(base, CLIENT_PUBLIC_PATH),
nonce: ctx.nonce,
},
injectTo: 'head-prepend',
},
Expand Down
3 changes: 3 additions & 0 deletions playground/csp/direct.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
.direct {
color: blue;
}
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;
}
4 changes: 4 additions & 0 deletions playground/csp/index.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
<script type="module" src="./index.js"></script>
<link rel="stylesheet" href="./direct.css" />
<p class="direct">direct</p>
<p class="from-js">from-js</p>
2 changes: 2 additions & 0 deletions playground/csp/index.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
import './from-js.css'
console.log('foo')
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-assets",
"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"
}
}
76 changes: 76 additions & 0 deletions playground/csp/vite.config.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
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 createNonce = () => crypto.randomUUID().replaceAll('-', '')

/**
* @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} htmlFile
* @param {string} nonce
*/
const getNonceInjectedHtml = async (htmlFile, nonce) => {
const content = await fs.readFile(htmlFile, 'utf8')
const tranformedContent = content
.replace(/<script\s*/g, `$&nonce="${nonce}" `)
.replace(/<link\s*/g, `$&nonce="${nonce}" `)
return tranformedContent
}

export default defineConfig({
plugins: [
{
name: 'nonce-inject',
config() {
return { appType: 'custom' }
},
configureServer({ transformIndexHtml, middlewares }) {
return () => {
middlewares.use(async (req, res) => {
const nonce = createNonce()
setNonceHeader(res, nonce)
const content = await getNonceInjectedHtml(
path.join(__dirname, './index.html'),
nonce,
)
res.end(await transformIndexHtml(req.originalUrl, content))
})
}
},
configurePreviewServer({ middlewares }) {
middlewares.use(async (req, res, next) => {
const { pathname } = new URL(req.url, 'http://example.com')
const assetPath = path.join(__dirname, 'dist', `.${pathname}`)
try {
if ((await fs.stat(assetPath)).isFile()) {
next()
return
}
} catch {}

const nonce = createNonce()
setNonceHeader(res, nonce)
const content = await getNonceInjectedHtml(
path.join(__dirname, './dist/index.html'),
nonce,
)
res.end(content)
})
},
},
],
})
2 changes: 2 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

0 comments on commit 452f8a8

Please sign in to comment.