vite: `server.fs.deny` bypass on Windows alternate paths
Description
On Windows, Vite dev server fails to block sensitive files specified in server.fs.deny via NTFS alternate data streams or 8.3 short names, leading to information disclosure.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
On Windows, Vite dev server fails to block sensitive files specified in `server.fs.deny` via NTFS alternate data streams or 8.3 short names, leading to information disclosure.
Vulnerability
In Vite dev server configurations that explicitly expose the server to the network (using --host or server.host) and where the sensitive file resides in an allowed directory (per server.fs.allow), the server.fs.deny mechanism does not correctly normalize Windows paths using NTFS alternate data stream (ADS) syntax (e.g., ::$DATA) or 8.3 short name alternatives. This affects all Vite versions prior to the fix, only when running on Windows with either NTFS volumes or volumes with 8.3 short name generation enabled (default on system volumes). [1][2]
Exploitation
An attacker needs network access to the exposed Vite dev server. No authentication or additional privileges are required. The attacker sends a specially crafted HTTP request, such as /.env::$DATA?raw for the default data stream of an .env file, or uses the 8.3 short name variant of a sensitive file (e.g., TLS~1.PEM). Because Vite's path matching logic does not normalize these alternate representations, the request bypasses the server.fs.deny block and the file contents are returned. [1][2]
Impact
Successful exploitation allows an attacker to read the contents of sensitive files that were intended to be blocked by server.fs.deny, including .env, .env.*, *.crt, *.pem, and similar entries. The attacker gains unauthorized information disclosure, potentially exposing credentials, secrets, or private keys stored in those files. [1][2]
Mitigation
The Vite maintainers have released a fix for this vulnerability. The advisory [1] and [2] do not yet specify the exact patched version number; users should monitor the Vite repository and update to the latest version as soon as it is available. As a workaround, do not expose the Vite dev server to untrusted networks when running on Windows, or ensure sensitive files are not placed in directories allowed by server.fs.allow. If the volume supports it, disabling 8.3 short name generation may reduce the attack surface but does not address the ADS bypass. [1][2]
AI Insight generated on Jun 15, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected products
1Patches
296b0c10162e9fix: backport #22572, reject windows alternate paths (#22576)
6 files changed · +90 −0
packages/vite/src/node/server/middlewares/static.ts+15 −0 modified@@ -266,6 +266,8 @@ export function isUriInFilePath(uri: string, filePath: string): boolean { return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath) } +const windowsDriveRE = /^[A-Z]:/i + export function isFileLoadingAllowed( config: ResolvedConfig, filePath: string, @@ -274,6 +276,19 @@ export function isFileLoadingAllowed( if (!fs.strict) return true + if (isWindows && filePath.includes('~')) { + // `~` is used for Windows 8.3 short names, which can be used to bypass the check. + // While is it valid to have files with `~` in the path, we disallow it to be safe. + return false + } + + const hasDriveLetter = isWindows && windowsDriveRE.test(filePath) + const hasColon = (hasDriveLetter ? filePath.slice(2) : filePath).includes(':') + if (hasColon) { + // the `:` is included in the path which may be used for NTFS ADS + return false + } + // NOTE: `fs.readFile('/foo.png/')` tries to load `'/foo.png'` // so we should check the path without trailing slash const filePathWithoutTrailingSlash = filePath.endsWith('/')
playground/fs-serve/root/src/index.html+30 −0 modified@@ -64,6 +64,8 @@ <h2>Denied</h2> <pre class="unsafe-dotenv"></pre> <pre class="unsafe-dotEnV-casing"></pre> <pre class="unsafe-dotenv-query-dot-svg-wasm-init"></pre> +<pre class="unsafe-dotenv-ntfs-ads"></pre> +<pre class="unsafe-dotenv-83-short-name"></pre> <script type="module"> import '../../entry' @@ -87,6 +89,7 @@ <h2>Denied</h2> text('.named', msg) const base = typeof BASE !== 'undefined' ? BASE : '' + const dotEnvWindows83ShortName = DOTENV83SHORTNAME // inside allowed dir, safe fetch fetch(joinUrlSegments(base, '/src/safe.txt')) @@ -449,6 +452,33 @@ <h2>Denied</h2> console.error(e) }) + fetch( + joinUrlSegments( + base, + joinUrlSegments('/@fs/', ROOT) + '/root/src/.env::$DATA', + ), + ) + .then((r) => { + text('.unsafe-dotenv-ntfs-ads', r.status) + }) + .catch((e) => { + console.error(e) + }) + + fetch( + joinUrlSegments( + base, + joinUrlSegments('/@fs/', ROOT) + + `/root/src/${dotEnvWindows83ShortName}.json`, + ), + ) + .then((r) => { + text('.unsafe-dotenv-83-short-name', r.status) + }) + .catch((e) => { + console.error(e) + }) + function text(sel, text) { document.querySelector(sel).textContent = text }
playground/fs-serve/root/vite.config-base.js+2 −0 modified@@ -1,5 +1,6 @@ import path from 'node:path' import { defineConfig } from 'vite' +import { getWindows83ShortNameForDotEnv } from './windows83Filename' const BASE = '/base/' @@ -33,5 +34,6 @@ export default defineConfig({ define: { ROOT: JSON.stringify(path.dirname(__dirname).replace(/\\/g, '/')), BASE: JSON.stringify(BASE), + DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()), }, })
playground/fs-serve/root/vite.config.js+2 −0 modified@@ -1,5 +1,6 @@ import path from 'node:path' import { defineConfig } from 'vite' +import { getWindows83ShortNameForDotEnv } from './windows83Filename' export default defineConfig({ build: { @@ -29,5 +30,6 @@ export default defineConfig({ }, define: { ROOT: JSON.stringify(path.dirname(__dirname).replace(/\\/g, '/')), + DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()), }, })
playground/fs-serve/root/windows83Filename.ts+23 −0 added@@ -0,0 +1,23 @@ +import { execSync } from 'node:child_process' +import path from 'node:path' + +function getWindows83ShortName(inputPath: string): string | undefined { + try { + const result = execSync( + `powershell -Command "(New-Object -ComObject Scripting.FileSystemObject).GetFile('${inputPath}').ShortPath"`, + { encoding: 'utf-8' }, + ).trim() + return result !== inputPath && result.includes('~') ? result : undefined + } catch { + return undefined + } +} + +export function getWindows83ShortNameForDotEnv(): string | undefined { + const dotEnvPath = path.resolve(__dirname, '../root/src/.env') + const dotEnvWindows83ShortName = getWindows83ShortName(dotEnvPath) + if (dotEnvWindows83ShortName === undefined) { + return undefined + } + return path.basename(dotEnvWindows83ShortName) +}
playground/fs-serve/__tests__/commonTests.ts+18 −0 modified@@ -13,6 +13,7 @@ import { import type { Page } from 'playwright-chromium' import WebSocket from 'ws' import testJSON from '../safe.json' +import { getWindows83ShortNameForDotEnv as getWindows83ShortNameForDotEnv } from '../root/windows83Filename' import { browser, isServe, page, viteServer, viteTestUrl } from '~utils' const getViteTestIndexHtmlUrl = () => { @@ -234,6 +235,23 @@ describe.runIf(isServe)('main', () => { .poll(() => page.textContent('.unsafe-dotenv-query-dot-svg-wasm-init')) .toBe('403') }) + + test('denied .env with NTFS ADS suffix', async () => { + // It is 403 on NTFS, 404 on others + await expect + .poll(() => page.textContent('.unsafe-dotenv-ntfs-ads')) + .toStrictEqual(expect.toBeOneOf(['403', '404'])) + }) + + const dotEnvWindows83ShortName = getWindows83ShortNameForDotEnv() + test.skipIf(dotEnvWindows83ShortName === undefined)( + 'denied .env with 8.3 short name', + async () => { + await expect + .poll(() => page.textContent('.unsafe-dotenv-83-short-name')) + .toBe('403') + }, + ) }) describe('fetch', () => {
dc245c71e500fix: reject windows alternate paths (#22572)
7 files changed · +127 −40
packages/vite/src/node/server/middlewares/static.ts+15 −0 modified@@ -286,6 +286,8 @@ export function isFileInTargetPath( ) } +const windowsDriveRE = /^[A-Z]:/i + /** * Warning: parameters are not validated, only works with normalized absolute paths */ @@ -297,6 +299,19 @@ export function isFileLoadingAllowed( if (!fs.strict) return true + if (isWindows && filePath.includes('~')) { + // `~` is used for Windows 8.3 short names, which can be used to bypass the check. + // While is it valid to have files with `~` in the path, we disallow it to be safe. + return false + } + + const hasDriveLetter = isWindows && windowsDriveRE.test(filePath) + const hasColon = (hasDriveLetter ? filePath.slice(2) : filePath).includes(':') + if (hasColon) { + // the `:` is included in the path which may be used for NTFS ADS + return false + } + // NOTE: `fs.readFile('/foo.png/')` tries to load `'/foo.png'` // so we should check the path without trailing slash const filePathWithoutTrailingSlash = filePath.endsWith('/')
playground/fs-serve/root/matrixTestResultPlugin.ts+2 −0 modified@@ -32,6 +32,8 @@ const testIds = [ 'unsafe-dotenv-inline', 'unsafe-dotenv-query-dot-svg-wasm-init', 'unsafe-dotenv-import-raw', + 'unsafe-dotenv-ntfs-ads', + 'unsafe-dotenv-83-short-name', ] export default function matrixTestResultPlugin(): Plugin {
playground/fs-serve/root/src/index.html+18 −4 modified@@ -33,6 +33,7 @@ <h2>Normal Import</h2> const base = typeof BASE !== 'undefined' ? BASE : '' const fsBase = joinUrlSegments('/@fs/', ROOT) + const dotEnvWindows83ShortName = DOTENV83SHORTNAME function joinUrlSegments(a, b) { if (!a || !b) { @@ -154,13 +155,26 @@ <h2>Normal Import</h2> }, // .env with ?import&raw { testId: 'unsafe-dotenv-import-raw', path: '/root/src/.env?import&raw' }, + // .env with NTFS ADS suffix + { testId: 'unsafe-dotenv-ntfs-ads', path: '/root/src/.env::$DATA' }, + // .env with 8.3 short name + { + testId: 'unsafe-dotenv-83-short-name', + path: + dotEnvWindows83ShortName === undefined + ? false + : `/root/src/${dotEnvWindows83ShortName}`, + }, ] const variants = { '': (path) => - path.startsWith('/root/') - ? joinUrlSegments(base, path.replace(/^\/root/, '')) - : false, - '-fs': (path) => joinUrlSegments(base, fsBase + path), + path === false + ? path + : path.startsWith('/root/') + ? joinUrlSegments(base, path.replace(/^\/root/, '')) + : false, + '-fs': (path) => + path === false ? path : joinUrlSegments(base, fsBase + path), } for (const { testId, path } of paths) {
playground/fs-serve/root/vite.config-base.js+2 −0 modified@@ -2,6 +2,7 @@ import path from 'node:path' import { defineConfig } from 'vite' import svgVirtualModulePlugin from './svgVirtualModulePlugin' import matrixTestResultPlugin from './matrixTestResultPlugin' +import { getWindows83ShortNameForDotEnv } from './windows83Filename' const BASE = '/base/' @@ -35,6 +36,7 @@ export default defineConfig({ define: { ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')), BASE: JSON.stringify(BASE), + DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()), }, plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()], })
playground/fs-serve/root/vite.config.js+2 −0 modified@@ -2,6 +2,7 @@ import path from 'node:path' import { defineConfig } from 'vite' import svgVirtualModulePlugin from './svgVirtualModulePlugin' import matrixTestResultPlugin from './matrixTestResultPlugin' +import { getWindows83ShortNameForDotEnv } from './windows83Filename' export default defineConfig({ build: { @@ -31,6 +32,7 @@ export default defineConfig({ }, define: { ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')), + DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()), }, plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()], })
playground/fs-serve/root/windows83Filename.ts+23 −0 added@@ -0,0 +1,23 @@ +import { execSync } from 'node:child_process' +import path from 'node:path' + +function getWindows83ShortName(inputPath: string): string | undefined { + try { + const result = execSync( + `powershell -Command "(New-Object -ComObject Scripting.FileSystemObject).GetFile('${inputPath}').ShortPath"`, + { encoding: 'utf-8' }, + ).trim() + return result !== inputPath && result.includes('~') ? result : undefined + } catch { + return undefined + } +} + +export function getWindows83ShortNameForDotEnv(): string | undefined { + const dotEnvPath = path.resolve(import.meta.dirname, '../root/src/.env') + const dotEnvWindows83ShortName = getWindows83ShortName(dotEnvPath) + if (dotEnvWindows83ShortName === undefined) { + return undefined + } + return path.basename(dotEnvWindows83ShortName) +}
playground/fs-serve/__tests__/commonTests.ts+65 −36 modified@@ -14,6 +14,7 @@ import { import type { Page } from 'playwright-chromium' import WebSocket from 'ws' import testJSON from '../safe.json' +import { getWindows83ShortNameForDotEnv as getWindows83ShortNameForDotEnv } from '../root/windows83Filename' import { browser, isServe, page, viteServer, viteTestUrl } from '~utils' const getViteTestIndexHtmlUrl = () => { @@ -51,6 +52,8 @@ describe.runIf(isServe)('normal', () => { }) describe.runIf(isServe)('matrix', () => { + const dotEnvWindows83ShortName = getWindows83ShortNameForDotEnv() + const variants = [ { variantId: '', variantName: 'normal' }, { variantId: '-fs', variantName: '/@fs/' }, @@ -61,7 +64,8 @@ describe.runIf(isServe)('matrix', () => { testId: string content: string | RegExp status: string | string[] - skipVariants?: VariantId[] + disableVariants?: VariantId[] + skip?: boolean isSPAFallback?: boolean }> = [ { @@ -99,14 +103,14 @@ describe.runIf(isServe)('matrix', () => { testId: 'safe-imported', content: safeJsonContent, status: '200', - skipVariants: [''], + disableVariants: [''], }, { name: 'safe fetch imported with query', testId: 'safe-imported-query', content: safeJsonContent, status: '200', - skipVariants: [''], + disableVariants: [''], }, { @@ -120,7 +124,7 @@ describe.runIf(isServe)('matrix', () => { testId: 'unsafe-json', content: /403 Restricted/, status: '403', - skipVariants: [''], + disableVariants: [''], }, { name: 'unsafe HTML fetch', @@ -133,7 +137,7 @@ describe.runIf(isServe)('matrix', () => { testId: 'unsafe-html-outside-root', content: /403 Restricted/, status: '403', - skipVariants: [''], + disableVariants: [''], }, { name: 'unsafe fetch with special characters (#8498)', @@ -164,21 +168,21 @@ describe.runIf(isServe)('matrix', () => { testId: 'unsafe-raw-import-raw-outside-root', content: /403 Restricted/, status: '403', - skipVariants: [''], + disableVariants: [''], }, { name: 'unsafe fetch raw import raw outside root 1', testId: 'unsafe-raw-import-raw-outside-root1', content: /403 Restricted/, status: '403', - skipVariants: [''], + disableVariants: [''], }, { name: 'unsafe fetch raw import raw outside root 2', testId: 'unsafe-raw-import-raw-outside-root2', content: /403 Restricted/, status: '403', - skipVariants: [''], + disableVariants: [''], }, { name: 'unsafe fetch with ?url query', @@ -255,48 +259,73 @@ describe.runIf(isServe)('matrix', () => { content: /403 Restricted/, status: '403', }, + // On NTFS, it exposes a file's default data stream through the `::$DATA` suffix, + // so `.env::$DATA` resolves to the same content as `.env`. + // It is 404 on non-NTFS. + { + name: 'denied .env with NTFS ADS suffix', + testId: 'unsafe-dotenv-ntfs-ads', + content: /403 Restricted|^$/, + status: ['403', '404'], + }, + // On Windows, the files can be accessed through the 8.3 short name if the feature is enabled. + // For example, if the short name for `.env` is `ENV~1`, it can be accessed as `ENV~1`. + { + name: 'denied .env with 8.3 short name', + testId: 'unsafe-dotenv-83-short-name', + content: /403 Restricted/, + status: '403', + skip: dotEnvWindows83ShortName === undefined, // skip if 8.3 short name is not available + }, ] for (const { name, testId, content, status, - skipVariants, + disableVariants, + skip, isSPAFallback, } of cases) { for (const { variantId, variantName } of variants) { - if (skipVariants?.includes(variantId)) { + if (disableVariants?.includes(variantId)) { continue } - test.concurrent(`${name} (${variantName})`, async ({ expect }) => { - const baseSelector = `.fetch${variantId}-${testId}` - const actualStatus = expect.poll(() => - page.textContent(`${baseSelector}-status`), - ) - const actualContent = expect.poll(() => - page.textContent(`${baseSelector}-content`), - ) + test.concurrent( + `${name} (${variantName})`, + { skip }, + async ({ expect }) => { + const baseSelector = `.fetch${variantId}-${testId}` + const actualStatus = expect.poll(() => + page.textContent(`${baseSelector}-status`), + ) + const actualContent = expect.poll(() => + page.textContent(`${baseSelector}-content`), + ) - if (variantName === 'normal' && isSPAFallback) { - await actualStatus.toBe('200') - await actualContent.toContain('<h1>FS Serve Matrix Test Summary</h1>') - return - } - - if (typeof status === 'string') { - await actualStatus.toBe(status) - } else { - await actualStatus.toBeOneOf(status) - } - - if (typeof content === 'string') { - await actualContent.toBe(content) - } else { - await actualContent.toMatch(content) - } - }) + if (variantName === 'normal' && isSPAFallback) { + await actualStatus.toBe('200') + await actualContent.toContain( + '<h1>FS Serve Matrix Test Summary</h1>', + ) + return + } + + if (typeof status === 'string') { + await actualStatus.toBe(status) + } else { + await actualStatus.toBeOneOf(status) + } + + if (typeof content === 'string') { + await actualContent.toBe(content) + } else { + await actualContent.toMatch(content) + } + }, + ) } } })
Vulnerability mechanics
Root cause
"Missing normalization of NTFS Alternate Data Stream (ADS) path forms and 8.3 short names in Vite's server.fs.deny access-check logic on Windows."
Attack vector
An attacker who can reach the Vite dev server (exposed via `--host` or `server.host`) sends a crafted HTTP request that appends an NTFS ADS suffix such as `::$DATA` to a denied file path (e.g., `/.env::$DATA?raw`). Because Vite's deny logic does not normalize this Windows-specific path form, the request passes the access check, and Windows resolves it to the original file's default data stream, returning the sensitive content. A similar bypass is possible using 8.3 short names on volumes where that feature is enabled. [ref_id=1][ref_id=2]
Affected code
The Vite dev server's file-access check logic in the `server.fs.deny` feature does not normalize NTFS Alternate Data Stream (ADS) path forms (e.g., `::$DATA`) or 8.3 short-name variants before applying deny rules. This affects the path validation code that runs on Windows when the dev server is exposed to the network.
What the fix does
The advisory does not include a published patch diff. The recommended remediation is to normalize NTFS ADS path components (such as `::$DATA`) and 8.3 short-name aliases before applying `server.fs.deny` checks on Windows, so that requests using these alternate path forms are correctly rejected. [ref_id=1][ref_id=2]
Preconditions
- configThe Vite dev server must be explicitly exposed to the network via --host or server.host config.
- configThe sensitive file must reside within a directory allowed by server.fs.allow.
- inputThe sensitive file must be on an NTFS volume (or, for the 8.3 bypass, on a Windows volume with short name generation enabled).
Reproduction
```bash $ npm create vite@latest $ cd vite-project/ $ npm install $ npm run dev ``` Access via browser at `http://localhost:5173/.env::$DATA?raw`
Expected result: `/.env::$DATA?raw` returns the contents of `.env`
Generated on Jun 15, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
2News mentions
0No linked articles in our index yet.