Vite's `server.fs` settings were not applied to HTML files
Description
Vite is a frontend tooling framework for JavaScript. Prior to versions 7.1.5, 7.0.7, 6.3.6, and 5.4.20, any HTML files on the machine were served regardless of the server.fs settings. Only apps that explicitly expose the Vite dev server to the network (using --host or server.host config option) and use appType: 'spa' (default) or appType: 'mpa' are affected. This vulnerability also affects the preview server. The preview server allowed HTML files not under the output directory to be served. Versions 7.1.5, 7.0.7, 6.3.6, and 5.4.20 fix the issue.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
vitenpm | >= 7.1.0, < 7.1.5 | 7.1.5 |
vitenpm | >= 7.0.0, < 7.0.7 | 7.0.7 |
vitenpm | >= 6.0.0, < 6.3.6 | 6.3.6 |
vitenpm | < 5.4.20 | 5.4.20 |
Affected products
1Patches
40ab19ea9fcb6fix: apply `fs.strict` check to HTML files (#20736)
9 files changed · +138 −3
packages/vite/src/node/preview.ts+3 −1 modified@@ -26,6 +26,7 @@ import { notFoundMiddleware } from './server/middlewares/notFound' import { proxyMiddleware } from './server/middlewares/proxy' import { getServerUrlByHost, + normalizePath, resolveHostname, resolveServerUrls, setupSIGTERMListener, @@ -254,7 +255,8 @@ export async function preview( if (config.appType === 'spa' || config.appType === 'mpa') { // transform index.html - app.use(indexHtmlMiddleware(distDir, server)) + const normalizedDistDir = normalizePath(distDir) + app.use(indexHtmlMiddleware(normalizedDistDir, server)) // handle 404s app.use(notFoundMiddleware())
packages/vite/src/node/server/middlewares/indexHtml.ts+22 −1 modified@@ -34,6 +34,7 @@ import { injectQuery, isDevServer, isJSRequest, + isParentDirectory, joinUrlSegments, normalizePath, processSrcSetSync, @@ -44,6 +45,7 @@ import { isCSSRequest } from '../../plugins/css' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils' import { getNodeAssetAttributes } from '../../assetSource' +import { checkLoadingAccess, respondWithAccessDenied } from './static' interface AssetNode { start: number @@ -447,7 +449,26 @@ export function indexHtmlMiddleware( if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) } else { - filePath = path.join(root, decodeURIComponent(url)) + filePath = normalizePath( + path.resolve(path.join(root, decodeURIComponent(url))), + ) + } + + if (isDev) { + const servingAccessResult = checkLoadingAccess(server.config, filePath) + if (servingAccessResult === 'denied') { + return respondWithAccessDenied(filePath, server, res) + } + if (servingAccessResult === 'fallback') { + return next() + } + servingAccessResult satisfies 'allowed' + } else { + // `server.fs` options does not apply to the preview server. + // But we should disallow serving files outside the output directory. + if (!isParentDirectory(root, filePath)) { + return next() + } } if (fs.existsSync(filePath)) {
packages/vite/src/node/server/middlewares/static.ts+1 −1 modified@@ -262,7 +262,7 @@ export function isFileServingAllowed( return isFileLoadingAllowed(config, filePath) } -function isUriInFilePath(uri: string, filePath: string) { +export function isUriInFilePath(uri: string, filePath: string): boolean { return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath) }
packages/vite/src/node/server/middlewares/__tests__/static.spec.ts+29 −0 added@@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { isUriInFilePath } from '../static' + +describe('isUriInFilePath', () => { + const cases = { + '/parent': { + '/parent': true, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isUriInFilePath("${parent}", "${child}")`, () => { + expect(isUriInFilePath(parent, child)).toBe(expected) + }) + } + } +})
packages/vite/src/node/__tests__/utils.spec.ts+28 −0 modified@@ -15,6 +15,7 @@ import { getServerUrlByHost, injectQuery, isFileReadable, + isParentDirectory, mergeWithDefaults, normalizePath, posToNumber, @@ -43,6 +44,33 @@ describe('bareImportRE', () => { }) }) +describe('isParentDirectory', () => { + const cases = { + '/parent': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isParentDirectory("${parent}", "${child}")`, () => { + expect(isParentDirectory(parent, child)).toBe(expected) + }) + } + } +}) + describe('injectQuery', () => { if (isWindows) { // this test will work incorrectly on unix systems
playground/fs-serve/root/src/index.html+30 −0 modified@@ -19,6 +19,8 @@ <h2>Safe Fetch Subdirectory</h2> <h2>Unsafe Fetch</h2> <pre class="unsafe-fetch-status"></pre> <pre class="unsafe-fetch"></pre> +<pre class="unsafe-fetch-html-status"></pre> +<pre class="unsafe-fetch-html"></pre> <pre class="unsafe-fetch-8498-status"></pre> <pre class="unsafe-fetch-8498"></pre> <pre class="unsafe-fetch-8498-2-status"></pre> @@ -39,6 +41,8 @@ <h2>Safe /@fs/ Fetch</h2> <h2>Unsafe /@fs/ Fetch</h2> <pre class="unsafe-fs-fetch-status"></pre> <pre class="unsafe-fs-fetch"></pre> +<pre class="unsafe-fs-fetch-html-status"></pre> +<pre class="unsafe-fs-fetch-html"></pre> <pre class="unsafe-fs-fetch-raw-status"></pre> <pre class="unsafe-fs-fetch-raw"></pre> <pre class="unsafe-fs-fetch-raw-query1-status"></pre> @@ -142,6 +146,19 @@ <h2>Denied</h2> console.error(e) }) + // outside of allowed dir, treated as unsafe + fetch(joinUrlSegments(base, '/unsafe.html')) + .then((r) => { + text('.unsafe-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // outside of allowed dir with special characters #8498 fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt')) .then((r) => { @@ -239,6 +256,19 @@ <h2>Denied</h2> console.error(e) }) + // not imported before, outside of root, treated as unsafe + fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html')) + .then((r) => { + text('.unsafe-fs-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fs-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // not imported before, outside of root, treated as unsafe fetch( joinUrlSegments(
playground/fs-serve/root/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe</p>
playground/fs-serve/__tests__/fs-serve.spec.ts+23 −0 modified@@ -62,6 +62,15 @@ describe.runIf(isServe)('main', () => { expect(await page.textContent('.unsafe-fetch-status')).toBe('403') }) + test('unsafe HTML fetch', async () => { + await expect + .poll(() => page.textContent('.unsafe-fetch-html')) + .toMatch('403 Restricted') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('403') + }) + test('unsafe fetch with special characters (#8498)', async () => { expect(await page.textContent('.unsafe-fetch-8498')).toBe('') expect(await page.textContent('.unsafe-fetch-8498-status')).toBe('404') @@ -491,4 +500,18 @@ describe.runIf(isServe)('invalid request', () => { ) expect(response).toContain('HTTP/1.1 403 Forbidden') }) + + test('should deny request to HTML file outside root by default with relative path', async () => { + const response = await sendRawRequest(viteTestUrl, '/../unsafe.html') + expect(response).toContain('HTTP/1.1 403 Forbidden') + }) +}) + +describe.runIf(!isServe)('preview HTML', () => { + test('unsafe HTML fetch', async () => { + await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('404') + }) })
playground/fs-serve/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe outside root</p>
482000f57f56fix: apply `fs.strict` check to HTML files (#20736)
9 files changed · +138 −3
packages/vite/src/node/preview.ts+3 −1 modified@@ -26,6 +26,7 @@ import { indexHtmlMiddleware } from './server/middlewares/indexHtml' import { notFoundMiddleware } from './server/middlewares/notFound' import { proxyMiddleware } from './server/middlewares/proxy' import { + normalizePath, resolveHostname, resolveServerUrls, setupSIGTERMListener, @@ -248,7 +249,8 @@ export async function preview( if (config.appType === 'spa' || config.appType === 'mpa') { // transform index.html - app.use(indexHtmlMiddleware(distDir, server)) + const normalizedDistDir = normalizePath(distDir) + app.use(indexHtmlMiddleware(normalizedDistDir, server)) // handle 404s app.use(notFoundMiddleware())
packages/vite/src/node/server/middlewares/indexHtml.ts+22 −1 modified@@ -34,6 +34,7 @@ import { injectQuery, isDevServer, isJSRequest, + isParentDirectory, joinUrlSegments, normalizePath, processSrcSetSync, @@ -44,6 +45,7 @@ import { checkPublicFile } from '../../publicDir' import { isCSSRequest } from '../../plugins/css' import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap' import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils' +import { checkLoadingAccess, respondWithAccessDenied } from './static' interface AssetNode { start: number @@ -431,7 +433,26 @@ export function indexHtmlMiddleware( if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) } else { - filePath = path.join(root, decodeURIComponent(url)) + filePath = normalizePath( + path.resolve(path.join(root, decodeURIComponent(url))), + ) + } + + if (isDev) { + const servingAccessResult = checkLoadingAccess(server, filePath) + if (servingAccessResult === 'denied') { + return respondWithAccessDenied(filePath, server, res) + } + if (servingAccessResult === 'fallback') { + return next() + } + servingAccessResult satisfies 'allowed' + } else { + // `server.fs` options does not apply to the preview server. + // But we should disallow serving files outside the output directory. + if (!isParentDirectory(root, filePath)) { + return next() + } } if (fsUtils.existsSync(filePath)) {
packages/vite/src/node/server/middlewares/static.ts+1 −1 modified@@ -247,7 +247,7 @@ export function isFileServingAllowed( return isFileLoadingAllowed(server, filePath) } -function isUriInFilePath(uri: string, filePath: string) { +export function isUriInFilePath(uri: string, filePath: string): boolean { return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath) }
packages/vite/src/node/server/middlewares/__tests__/static.spec.ts+29 −0 added@@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { isUriInFilePath } from '../static' + +describe('isUriInFilePath', () => { + const cases = { + '/parent': { + '/parent': true, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isUriInFilePath("${parent}", "${child}")`, () => { + expect(isUriInFilePath(parent, child)).toBe(expected) + }) + } + } +})
packages/vite/src/node/__tests__/utils.spec.ts+28 −0 modified@@ -10,6 +10,7 @@ import { getLocalhostAddressIfDiffersFromDNS, injectQuery, isFileReadable, + isParentDirectory, posToNumber, processSrcSetSync, resolveHostname, @@ -35,6 +36,33 @@ describe('bareImportRE', () => { }) }) +describe('isParentDirectory', () => { + const cases = { + '/parent': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isParentDirectory("${parent}", "${child}")`, () => { + expect(isParentDirectory(parent, child)).toBe(expected) + }) + } + } +}) + describe('injectQuery', () => { if (isWindows) { // this test will work incorrectly on unix systems
playground/fs-serve/root/src/index.html+30 −0 modified@@ -19,6 +19,8 @@ <h2>Safe Fetch Subdirectory</h2> <h2>Unsafe Fetch</h2> <pre class="unsafe-fetch-status"></pre> <pre class="unsafe-fetch"></pre> +<pre class="unsafe-fetch-html-status"></pre> +<pre class="unsafe-fetch-html"></pre> <pre class="unsafe-fetch-8498-status"></pre> <pre class="unsafe-fetch-8498"></pre> <pre class="unsafe-fetch-8498-2-status"></pre> @@ -39,6 +41,8 @@ <h2>Safe /@fs/ Fetch</h2> <h2>Unsafe /@fs/ Fetch</h2> <pre class="unsafe-fs-fetch-status"></pre> <pre class="unsafe-fs-fetch"></pre> +<pre class="unsafe-fs-fetch-html-status"></pre> +<pre class="unsafe-fs-fetch-html"></pre> <pre class="unsafe-fs-fetch-raw-status"></pre> <pre class="unsafe-fs-fetch-raw"></pre> <pre class="unsafe-fs-fetch-raw-query1-status"></pre> @@ -142,6 +146,19 @@ <h2>Denied</h2> console.error(e) }) + // outside of allowed dir, treated as unsafe + fetch(joinUrlSegments(base, '/unsafe.html')) + .then((r) => { + text('.unsafe-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // outside of allowed dir with special characters #8498 fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt')) .then((r) => { @@ -239,6 +256,19 @@ <h2>Denied</h2> console.error(e) }) + // not imported before, outside of root, treated as unsafe + fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html')) + .then((r) => { + text('.unsafe-fs-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fs-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // not imported before, outside of root, treated as unsafe fetch( joinUrlSegments(
playground/fs-serve/root/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe</p>
playground/fs-serve/__tests__/fs-serve.spec.ts+23 −0 modified@@ -62,6 +62,15 @@ describe.runIf(isServe)('main', () => { expect(await page.textContent('.unsafe-fetch-status')).toBe('403') }) + test('unsafe HTML fetch', async () => { + await expect + .poll(() => page.textContent('.unsafe-fetch-html')) + .toMatch('403 Restricted') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('403') + }) + test('unsafe fetch with special characters (#8498)', async () => { expect(await page.textContent('.unsafe-fetch-8498')).toBe('') expect(await page.textContent('.unsafe-fetch-8498-status')).toBe('404') @@ -478,4 +487,18 @@ describe.runIf(isServe)('invalid request', () => { ) expect(response).toContain('HTTP/1.1 403 Forbidden') }) + + test('should deny request to HTML file outside root by default with relative path', async () => { + const response = await sendRawRequest(viteTestUrl, '/../unsafe.html') + expect(response).toContain('HTTP/1.1 403 Forbidden') + }) +}) + +describe.runIf(!isServe)('preview HTML', () => { + test('unsafe HTML fetch', async () => { + await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('404') + }) })
playground/fs-serve/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe outside root</p>
6f01ff4fe072fix: apply `fs.strict` check to HTML files (#20736)
9 files changed · +138 −3
packages/vite/src/node/preview.ts+3 −1 modified@@ -26,6 +26,7 @@ import { notFoundMiddleware } from './server/middlewares/notFound' import { proxyMiddleware } from './server/middlewares/proxy' import { getServerUrlByHost, + normalizePath, resolveHostname, resolveServerUrls, setupSIGTERMListener, @@ -263,7 +264,8 @@ export async function preview( if (config.appType === 'spa' || config.appType === 'mpa') { // transform index.html - app.use(indexHtmlMiddleware(distDir, server)) + const normalizedDistDir = normalizePath(distDir) + app.use(indexHtmlMiddleware(normalizedDistDir, server)) // handle 404s app.use(notFoundMiddleware())
packages/vite/src/node/server/middlewares/indexHtml.ts+22 −1 modified@@ -35,6 +35,7 @@ import { isCSSRequest, isDevServer, isJSRequest, + isParentDirectory, joinUrlSegments, normalizePath, processSrcSetSync, @@ -48,6 +49,7 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from '../pluginContainer' +import { checkLoadingAccess, respondWithAccessDenied } from './static' interface AssetNode { start: number @@ -454,7 +456,26 @@ export function indexHtmlMiddleware( if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) } else { - filePath = path.join(root, decodeURIComponent(url)) + filePath = normalizePath( + path.resolve(path.join(root, decodeURIComponent(url))), + ) + } + + if (isDev) { + const servingAccessResult = checkLoadingAccess(server.config, filePath) + if (servingAccessResult === 'denied') { + return respondWithAccessDenied(filePath, server, res) + } + if (servingAccessResult === 'fallback') { + return next() + } + servingAccessResult satisfies 'allowed' + } else { + // `server.fs` options does not apply to the preview server. + // But we should disallow serving files outside the output directory. + if (!isParentDirectory(root, filePath)) { + return next() + } } if (fs.existsSync(filePath)) {
packages/vite/src/node/server/middlewares/static.ts+1 −1 modified@@ -262,7 +262,7 @@ export function isFileServingAllowed( return isFileLoadingAllowed(config, filePath) } -function isUriInFilePath(uri: string, filePath: string) { +export function isUriInFilePath(uri: string, filePath: string): boolean { return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath) }
packages/vite/src/node/server/middlewares/__tests__/static.spec.ts+29 −0 added@@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { isUriInFilePath } from '../static' + +describe('isUriInFilePath', () => { + const cases = { + '/parent': { + '/parent': true, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isUriInFilePath("${parent}", "${child}")`, () => { + expect(isUriInFilePath(parent, child)).toBe(expected) + }) + } + } +})
packages/vite/src/node/__tests__/utils.spec.ts+28 −0 modified@@ -15,6 +15,7 @@ import { getServerUrlByHost, injectQuery, isFileReadable, + isParentDirectory, mergeWithDefaults, normalizePath, numberToPos, @@ -44,6 +45,33 @@ describe('bareImportRE', () => { }) }) +describe('isParentDirectory', () => { + const cases = { + '/parent': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isParentDirectory("${parent}", "${child}")`, () => { + expect(isParentDirectory(parent, child)).toBe(expected) + }) + } + } +}) + describe('injectQuery', () => { if (isWindows) { // this test will work incorrectly on unix systems
playground/fs-serve/root/src/index.html+30 −0 modified@@ -19,6 +19,8 @@ <h2>Safe Fetch Subdirectory</h2> <h2>Unsafe Fetch</h2> <pre class="unsafe-fetch-status"></pre> <pre class="unsafe-fetch"></pre> +<pre class="unsafe-fetch-html-status"></pre> +<pre class="unsafe-fetch-html"></pre> <pre class="unsafe-fetch-8498-status"></pre> <pre class="unsafe-fetch-8498"></pre> <pre class="unsafe-fetch-8498-2-status"></pre> @@ -39,6 +41,8 @@ <h2>Safe /@fs/ Fetch</h2> <h2>Unsafe /@fs/ Fetch</h2> <pre class="unsafe-fs-fetch-status"></pre> <pre class="unsafe-fs-fetch"></pre> +<pre class="unsafe-fs-fetch-html-status"></pre> +<pre class="unsafe-fs-fetch-html"></pre> <pre class="unsafe-fs-fetch-raw-status"></pre> <pre class="unsafe-fs-fetch-raw"></pre> <pre class="unsafe-fs-fetch-raw-query1-status"></pre> @@ -149,6 +153,19 @@ <h2>Denied</h2> console.error(e) }) + // outside of allowed dir, treated as unsafe + fetch(joinUrlSegments(base, '/unsafe.html')) + .then((r) => { + text('.unsafe-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // outside of allowed dir with special characters #8498 fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt')) .then((r) => { @@ -246,6 +263,19 @@ <h2>Denied</h2> console.error(e) }) + // not imported before, outside of root, treated as unsafe + fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html')) + .then((r) => { + text('.unsafe-fs-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fs-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // not imported before, outside of root, treated as unsafe fetch( joinUrlSegments(
playground/fs-serve/root/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe</p>
playground/fs-serve/__tests__/fs-serve.spec.ts+23 −0 modified@@ -76,6 +76,15 @@ describe.runIf(isServe)('main', () => { .toBe('403') }) + test('unsafe HTML fetch', async () => { + await expect + .poll(() => page.textContent('.unsafe-fetch-html')) + .toMatch('403 Restricted') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('403') + }) + test('unsafe fetch with special characters (#8498)', async () => { await expect.poll(() => page.textContent('.unsafe-fetch-8498')).toBe('') await expect @@ -536,4 +545,18 @@ describe.runIf(isServe)('invalid request', () => { ) expect(response).toContain('HTTP/1.1 403 Forbidden') }) + + test('should deny request to HTML file outside root by default with relative path', async () => { + const response = await sendRawRequest(viteTestUrl, '/../unsafe.html') + expect(response).toContain('HTTP/1.1 403 Forbidden') + }) +}) + +describe.runIf(!isServe)('preview HTML', () => { + test('unsafe HTML fetch', async () => { + await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('404') + }) })
playground/fs-serve/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe outside root</p>
14015d794f69fix: apply `fs.strict` check to HTML files (#20736)
9 files changed · +141 −3
packages/vite/src/node/preview.ts+3 −1 modified@@ -26,6 +26,7 @@ import { notFoundMiddleware } from './server/middlewares/notFound' import { proxyMiddleware } from './server/middlewares/proxy' import { getServerUrlByHost, + normalizePath, resolveHostname, resolveServerUrls, setupSIGTERMListener, @@ -263,7 +264,8 @@ export async function preview( if (config.appType === 'spa' || config.appType === 'mpa') { // transform index.html - app.use(indexHtmlMiddleware(distDir, server)) + const normalizedDistDir = normalizePath(distDir) + app.use(indexHtmlMiddleware(normalizedDistDir, server)) // handle 404s app.use(notFoundMiddleware())
packages/vite/src/node/server/middlewares/indexHtml.ts+22 −1 modified@@ -35,6 +35,7 @@ import { isCSSRequest, isDevServer, isJSRequest, + isParentDirectory, joinUrlSegments, normalizePath, processSrcSetSync, @@ -48,6 +49,7 @@ import { BasicMinimalPluginContext, basePluginContextMeta, } from '../pluginContainer' +import { checkLoadingAccess, respondWithAccessDenied } from './static' interface AssetNode { start: number @@ -454,7 +456,26 @@ export function indexHtmlMiddleware( if (isDev && url.startsWith(FS_PREFIX)) { filePath = decodeURIComponent(fsPathFromId(url)) } else { - filePath = path.join(root, decodeURIComponent(url)) + filePath = normalizePath( + path.resolve(path.join(root, decodeURIComponent(url))), + ) + } + + if (isDev) { + const servingAccessResult = checkLoadingAccess(server.config, filePath) + if (servingAccessResult === 'denied') { + return respondWithAccessDenied(filePath, server, res) + } + if (servingAccessResult === 'fallback') { + return next() + } + servingAccessResult satisfies 'allowed' + } else { + // `server.fs` options does not apply to the preview server. + // But we should disallow serving files outside the output directory. + if (!isParentDirectory(root, filePath)) { + return next() + } } if (fs.existsSync(filePath)) {
packages/vite/src/node/server/middlewares/static.ts+4 −1 modified@@ -268,7 +268,10 @@ export function isFileServingAllowed( * @param targetPath - normalized absolute path * @param filePath - normalized absolute path */ -function isFileInTargetPath(targetPath: string, filePath: string) { +export function isFileInTargetPath( + targetPath: string, + filePath: string, +): boolean { return ( isSameFilePath(targetPath, filePath) || isParentDirectory(targetPath, filePath)
packages/vite/src/node/server/middlewares/__tests__/static.spec.ts+29 −0 added@@ -0,0 +1,29 @@ +import { describe, expect, test } from 'vitest' +import { isFileInTargetPath } from '../static' + +describe('isFileInTargetPath', () => { + const cases = { + '/parent': { + '/parent': true, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isFileInTargetPath("${parent}", "${child}")`, () => { + expect(isFileInTargetPath(parent, child)).toBe(expected) + }) + } + } +})
packages/vite/src/node/__tests__/utils.spec.ts+28 −0 modified@@ -15,6 +15,7 @@ import { getServerUrlByHost, injectQuery, isFileReadable, + isParentDirectory, mergeWithDefaults, normalizePath, numberToPos, @@ -44,6 +45,33 @@ describe('bareImportRE', () => { }) }) +describe('isParentDirectory', () => { + const cases = { + '/parent': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + '/parent/': { + '/parent': false, + '/parenta': false, + '/parent/': true, + '/parent/child': true, + '/parent/child/child2': true, + }, + } + + for (const [parent, children] of Object.entries(cases)) { + for (const [child, expected] of Object.entries(children)) { + test(`isParentDirectory("${parent}", "${child}")`, () => { + expect(isParentDirectory(parent, child)).toBe(expected) + }) + } + } +}) + describe('injectQuery', () => { if (isWindows) { // this test will work incorrectly on unix systems
playground/fs-serve/root/src/index.html+30 −0 modified@@ -19,6 +19,8 @@ <h2>Safe Fetch Subdirectory</h2> <h2>Unsafe Fetch</h2> <pre class="unsafe-fetch-status"></pre> <pre class="unsafe-fetch"></pre> +<pre class="unsafe-fetch-html-status"></pre> +<pre class="unsafe-fetch-html"></pre> <pre class="unsafe-fetch-8498-status"></pre> <pre class="unsafe-fetch-8498"></pre> <pre class="unsafe-fetch-8498-2-status"></pre> @@ -39,6 +41,8 @@ <h2>Safe /@fs/ Fetch</h2> <h2>Unsafe /@fs/ Fetch</h2> <pre class="unsafe-fs-fetch-status"></pre> <pre class="unsafe-fs-fetch"></pre> +<pre class="unsafe-fs-fetch-html-status"></pre> +<pre class="unsafe-fs-fetch-html"></pre> <pre class="unsafe-fs-fetch-raw-status"></pre> <pre class="unsafe-fs-fetch-raw"></pre> <pre class="unsafe-fs-fetch-raw-query1-status"></pre> @@ -149,6 +153,19 @@ <h2>Denied</h2> console.error(e) }) + // outside of allowed dir, treated as unsafe + fetch(joinUrlSegments(base, '/unsafe.html')) + .then((r) => { + text('.unsafe-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // outside of allowed dir with special characters #8498 fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt')) .then((r) => { @@ -246,6 +263,19 @@ <h2>Denied</h2> console.error(e) }) + // not imported before, outside of root, treated as unsafe + fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html')) + .then((r) => { + text('.unsafe-fs-fetch-html-status', r.status) + return r.text() + }) + .then((data) => { + text('.unsafe-fs-fetch-html', data) + }) + .catch((e) => { + console.error(e) + }) + // not imported before, outside of root, treated as unsafe fetch( joinUrlSegments(
playground/fs-serve/root/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe</p>
playground/fs-serve/__tests__/fs-serve.spec.ts+23 −0 modified@@ -76,6 +76,15 @@ describe.runIf(isServe)('main', () => { .toBe('403') }) + test('unsafe HTML fetch', async () => { + await expect + .poll(() => page.textContent('.unsafe-fetch-html')) + .toMatch('403 Restricted') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('403') + }) + test('unsafe fetch with special characters (#8498)', async () => { await expect.poll(() => page.textContent('.unsafe-fetch-8498')).toBe('') await expect @@ -536,4 +545,18 @@ describe.runIf(isServe)('invalid request', () => { ) expect(response).toContain('HTTP/1.1 403 Forbidden') }) + + test('should deny request to HTML file outside root by default with relative path', async () => { + const response = await sendRawRequest(viteTestUrl, '/../unsafe.html') + expect(response).toContain('HTTP/1.1 403 Forbidden') + }) +}) + +describe.runIf(!isServe)('preview HTML', () => { + test('unsafe HTML fetch', async () => { + await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('') + await expect + .poll(() => page.textContent('.unsafe-fetch-html-status')) + .toBe('404') + }) })
playground/fs-serve/unsafe.html+1 −0 added@@ -0,0 +1 @@ +<p>unsafe outside root</p>
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
8- github.com/advisories/GHSA-jqfw-vq24-v9c3ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-58752ghsaADVISORY
- github.com/vitejs/vite/blob/v7.1.5/packages/vite/CHANGELOG.mdghsaWEB
- github.com/vitejs/vite/commit/0ab19ea9fcb66f544328f442cf6e70f7c0528d5fghsax_refsource_MISCWEB
- github.com/vitejs/vite/commit/14015d794f69accba68798bd0e15135bc51c9c1eghsax_refsource_MISCWEB
- github.com/vitejs/vite/commit/482000f57f56fe6ff2e905305100cfe03043ddeaghsax_refsource_MISCWEB
- github.com/vitejs/vite/commit/6f01ff4fe072bcfcd4e2a84811772b818cd51fe6ghsax_refsource_MISCWEB
- github.com/vitejs/vite/security/advisories/GHSA-jqfw-vq24-v9c3ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.