VYPR
Low severityNVD Advisory· Published Sep 8, 2025· Updated Sep 9, 2025

Vite's `server.fs` settings were not applied to HTML files

CVE-2025-58752

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.

PackageAffected versionsPatched versions
vitenpm
>= 7.1.0, < 7.1.57.1.5
vitenpm
>= 7.0.0, < 7.0.77.0.7
vitenpm
>= 6.0.0, < 6.3.66.3.6
vitenpm
< 5.4.205.4.20

Affected products

1

Patches

4
0ab19ea9fcb6

fix: apply `fs.strict` check to HTML files (#20736)

https://github.com/vitejs/viteSep 8, 2025via ghsa
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>
    
482000f57f56

fix: apply `fs.strict` check to HTML files (#20736)

https://github.com/vitejs/viteSep 8, 2025via ghsa
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>
    
6f01ff4fe072

fix: apply `fs.strict` check to HTML files (#20736)

https://github.com/vitejs/viteSep 8, 2025via ghsa
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>
    
14015d794f69

fix: apply `fs.strict` check to HTML files (#20736)

https://github.com/vitejs/viteSep 8, 2025via ghsa
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

News mentions

0

No linked articles in our index yet.