Skip to content

Commit dc245c7

Browse files
authored
fix: reject windows alternate paths (#22572)
1 parent 50b9512 commit dc245c7

7 files changed

Lines changed: 127 additions & 40 deletions

File tree

packages/vite/src/node/server/middlewares/static.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -286,6 +286,8 @@ export function isFileInTargetPath(
286286
)
287287
}
288288

289+
const windowsDriveRE = /^[A-Z]:/i
290+
289291
/**
290292
* Warning: parameters are not validated, only works with normalized absolute paths
291293
*/
@@ -297,6 +299,19 @@ export function isFileLoadingAllowed(
297299

298300
if (!fs.strict) return true
299301

302+
if (isWindows && filePath.includes('~')) {
303+
// `~` is used for Windows 8.3 short names, which can be used to bypass the check.
304+
// While is it valid to have files with `~` in the path, we disallow it to be safe.
305+
return false
306+
}
307+
308+
const hasDriveLetter = isWindows && windowsDriveRE.test(filePath)
309+
const hasColon = (hasDriveLetter ? filePath.slice(2) : filePath).includes(':')
310+
if (hasColon) {
311+
// the `:` is included in the path which may be used for NTFS ADS
312+
return false
313+
}
314+
300315
// NOTE: `fs.readFile('/foo.png/')` tries to load `'/foo.png'`
301316
// so we should check the path without trailing slash
302317
const filePathWithoutTrailingSlash = filePath.endsWith('/')

playground/fs-serve/__tests__/commonTests.ts

Lines changed: 65 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
import type { Page } from 'playwright-chromium'
1515
import WebSocket from 'ws'
1616
import testJSON from '../safe.json'
17+
import { getWindows83ShortNameForDotEnv as getWindows83ShortNameForDotEnv } from '../root/windows83Filename'
1718
import { browser, isServe, page, viteServer, viteTestUrl } from '~utils'
1819

1920
const getViteTestIndexHtmlUrl = () => {
@@ -51,6 +52,8 @@ describe.runIf(isServe)('normal', () => {
5152
})
5253

5354
describe.runIf(isServe)('matrix', () => {
55+
const dotEnvWindows83ShortName = getWindows83ShortNameForDotEnv()
56+
5457
const variants = [
5558
{ variantId: '', variantName: 'normal' },
5659
{ variantId: '-fs', variantName: '/@fs/' },
@@ -61,7 +64,8 @@ describe.runIf(isServe)('matrix', () => {
6164
testId: string
6265
content: string | RegExp
6366
status: string | string[]
64-
skipVariants?: VariantId[]
67+
disableVariants?: VariantId[]
68+
skip?: boolean
6569
isSPAFallback?: boolean
6670
}> = [
6771
{
@@ -99,14 +103,14 @@ describe.runIf(isServe)('matrix', () => {
99103
testId: 'safe-imported',
100104
content: safeJsonContent,
101105
status: '200',
102-
skipVariants: [''],
106+
disableVariants: [''],
103107
},
104108
{
105109
name: 'safe fetch imported with query',
106110
testId: 'safe-imported-query',
107111
content: safeJsonContent,
108112
status: '200',
109-
skipVariants: [''],
113+
disableVariants: [''],
110114
},
111115

112116
{
@@ -120,7 +124,7 @@ describe.runIf(isServe)('matrix', () => {
120124
testId: 'unsafe-json',
121125
content: /403 Restricted/,
122126
status: '403',
123-
skipVariants: [''],
127+
disableVariants: [''],
124128
},
125129
{
126130
name: 'unsafe HTML fetch',
@@ -133,7 +137,7 @@ describe.runIf(isServe)('matrix', () => {
133137
testId: 'unsafe-html-outside-root',
134138
content: /403 Restricted/,
135139
status: '403',
136-
skipVariants: [''],
140+
disableVariants: [''],
137141
},
138142
{
139143
name: 'unsafe fetch with special characters (#8498)',
@@ -164,21 +168,21 @@ describe.runIf(isServe)('matrix', () => {
164168
testId: 'unsafe-raw-import-raw-outside-root',
165169
content: /403 Restricted/,
166170
status: '403',
167-
skipVariants: [''],
171+
disableVariants: [''],
168172
},
169173
{
170174
name: 'unsafe fetch raw import raw outside root 1',
171175
testId: 'unsafe-raw-import-raw-outside-root1',
172176
content: /403 Restricted/,
173177
status: '403',
174-
skipVariants: [''],
178+
disableVariants: [''],
175179
},
176180
{
177181
name: 'unsafe fetch raw import raw outside root 2',
178182
testId: 'unsafe-raw-import-raw-outside-root2',
179183
content: /403 Restricted/,
180184
status: '403',
181-
skipVariants: [''],
185+
disableVariants: [''],
182186
},
183187
{
184188
name: 'unsafe fetch with ?url query',
@@ -255,48 +259,73 @@ describe.runIf(isServe)('matrix', () => {
255259
content: /403 Restricted/,
256260
status: '403',
257261
},
262+
// On NTFS, it exposes a file's default data stream through the `::$DATA` suffix,
263+
// so `.env::$DATA` resolves to the same content as `.env`.
264+
// It is 404 on non-NTFS.
265+
{
266+
name: 'denied .env with NTFS ADS suffix',
267+
testId: 'unsafe-dotenv-ntfs-ads',
268+
content: /403 Restricted|^$/,
269+
status: ['403', '404'],
270+
},
271+
// On Windows, the files can be accessed through the 8.3 short name if the feature is enabled.
272+
// For example, if the short name for `.env` is `ENV~1`, it can be accessed as `ENV~1`.
273+
{
274+
name: 'denied .env with 8.3 short name',
275+
testId: 'unsafe-dotenv-83-short-name',
276+
content: /403 Restricted/,
277+
status: '403',
278+
skip: dotEnvWindows83ShortName === undefined, // skip if 8.3 short name is not available
279+
},
258280
]
259281

260282
for (const {
261283
name,
262284
testId,
263285
content,
264286
status,
265-
skipVariants,
287+
disableVariants,
288+
skip,
266289
isSPAFallback,
267290
} of cases) {
268291
for (const { variantId, variantName } of variants) {
269-
if (skipVariants?.includes(variantId)) {
292+
if (disableVariants?.includes(variantId)) {
270293
continue
271294
}
272295

273-
test.concurrent(`${name} (${variantName})`, async ({ expect }) => {
274-
const baseSelector = `.fetch${variantId}-${testId}`
275-
const actualStatus = expect.poll(() =>
276-
page.textContent(`${baseSelector}-status`),
277-
)
278-
const actualContent = expect.poll(() =>
279-
page.textContent(`${baseSelector}-content`),
280-
)
296+
test.concurrent(
297+
`${name} (${variantName})`,
298+
{ skip },
299+
async ({ expect }) => {
300+
const baseSelector = `.fetch${variantId}-${testId}`
301+
const actualStatus = expect.poll(() =>
302+
page.textContent(`${baseSelector}-status`),
303+
)
304+
const actualContent = expect.poll(() =>
305+
page.textContent(`${baseSelector}-content`),
306+
)
281307

282-
if (variantName === 'normal' && isSPAFallback) {
283-
await actualStatus.toBe('200')
284-
await actualContent.toContain('<h1>FS Serve Matrix Test Summary</h1>')
285-
return
286-
}
287-
288-
if (typeof status === 'string') {
289-
await actualStatus.toBe(status)
290-
} else {
291-
await actualStatus.toBeOneOf(status)
292-
}
293-
294-
if (typeof content === 'string') {
295-
await actualContent.toBe(content)
296-
} else {
297-
await actualContent.toMatch(content)
298-
}
299-
})
308+
if (variantName === 'normal' && isSPAFallback) {
309+
await actualStatus.toBe('200')
310+
await actualContent.toContain(
311+
'<h1>FS Serve Matrix Test Summary</h1>',
312+
)
313+
return
314+
}
315+
316+
if (typeof status === 'string') {
317+
await actualStatus.toBe(status)
318+
} else {
319+
await actualStatus.toBeOneOf(status)
320+
}
321+
322+
if (typeof content === 'string') {
323+
await actualContent.toBe(content)
324+
} else {
325+
await actualContent.toMatch(content)
326+
}
327+
},
328+
)
300329
}
301330
}
302331
})

playground/fs-serve/root/matrixTestResultPlugin.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ const testIds = [
3232
'unsafe-dotenv-inline',
3333
'unsafe-dotenv-query-dot-svg-wasm-init',
3434
'unsafe-dotenv-import-raw',
35+
'unsafe-dotenv-ntfs-ads',
36+
'unsafe-dotenv-83-short-name',
3537
]
3638

3739
export default function matrixTestResultPlugin(): Plugin {

playground/fs-serve/root/src/index.html

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ <h2>Normal Import</h2>
3333

3434
const base = typeof BASE !== 'undefined' ? BASE : ''
3535
const fsBase = joinUrlSegments('/@fs/', ROOT)
36+
const dotEnvWindows83ShortName = DOTENV83SHORTNAME
3637

3738
function joinUrlSegments(a, b) {
3839
if (!a || !b) {
@@ -154,13 +155,26 @@ <h2>Normal Import</h2>
154155
},
155156
// .env with ?import&raw
156157
{ testId: 'unsafe-dotenv-import-raw', path: '/root/src/.env?import&raw' },
158+
// .env with NTFS ADS suffix
159+
{ testId: 'unsafe-dotenv-ntfs-ads', path: '/root/src/.env::$DATA' },
160+
// .env with 8.3 short name
161+
{
162+
testId: 'unsafe-dotenv-83-short-name',
163+
path:
164+
dotEnvWindows83ShortName === undefined
165+
? false
166+
: `/root/src/${dotEnvWindows83ShortName}`,
167+
},
157168
]
158169
const variants = {
159170
'': (path) =>
160-
path.startsWith('/root/')
161-
? joinUrlSegments(base, path.replace(/^\/root/, ''))
162-
: false,
163-
'-fs': (path) => joinUrlSegments(base, fsBase + path),
171+
path === false
172+
? path
173+
: path.startsWith('/root/')
174+
? joinUrlSegments(base, path.replace(/^\/root/, ''))
175+
: false,
176+
'-fs': (path) =>
177+
path === false ? path : joinUrlSegments(base, fsBase + path),
164178
}
165179

166180
for (const { testId, path } of paths) {

playground/fs-serve/root/vite.config-base.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'node:path'
22
import { defineConfig } from 'vite'
33
import svgVirtualModulePlugin from './svgVirtualModulePlugin'
44
import matrixTestResultPlugin from './matrixTestResultPlugin'
5+
import { getWindows83ShortNameForDotEnv } from './windows83Filename'
56

67
const BASE = '/base/'
78

@@ -35,6 +36,7 @@ export default defineConfig({
3536
define: {
3637
ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')),
3738
BASE: JSON.stringify(BASE),
39+
DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()),
3840
},
3941
plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()],
4042
})

playground/fs-serve/root/vite.config.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import path from 'node:path'
22
import { defineConfig } from 'vite'
33
import svgVirtualModulePlugin from './svgVirtualModulePlugin'
44
import matrixTestResultPlugin from './matrixTestResultPlugin'
5+
import { getWindows83ShortNameForDotEnv } from './windows83Filename'
56

67
export default defineConfig({
78
build: {
@@ -31,6 +32,7 @@ export default defineConfig({
3132
},
3233
define: {
3334
ROOT: JSON.stringify(path.dirname(import.meta.dirname).replace(/\\/g, '/')),
35+
DOTENV83SHORTNAME: JSON.stringify(getWindows83ShortNameForDotEnv()),
3436
},
3537
plugins: [svgVirtualModulePlugin(), matrixTestResultPlugin()],
3638
})
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { execSync } from 'node:child_process'
2+
import path from 'node:path'
3+
4+
function getWindows83ShortName(inputPath: string): string | undefined {
5+
try {
6+
const result = execSync(
7+
`powershell -Command "(New-Object -ComObject Scripting.FileSystemObject).GetFile('${inputPath}').ShortPath"`,
8+
{ encoding: 'utf-8' },
9+
).trim()
10+
return result !== inputPath && result.includes('~') ? result : undefined
11+
} catch {
12+
return undefined
13+
}
14+
}
15+
16+
export function getWindows83ShortNameForDotEnv(): string | undefined {
17+
const dotEnvPath = path.resolve(import.meta.dirname, '../root/src/.env')
18+
const dotEnvWindows83ShortName = getWindows83ShortName(dotEnvPath)
19+
if (dotEnvWindows83ShortName === undefined) {
20+
return undefined
21+
}
22+
return path.basename(dotEnvWindows83ShortName)
23+
}

0 commit comments

Comments
 (0)