Skip to content

Commit 482000f

Browse files
committed
fix: apply fs.strict check to HTML files (#20736)
1 parent a0a835f commit 482000f

File tree

9 files changed

+138
-3
lines changed

9 files changed

+138
-3
lines changed

packages/vite/src/node/__tests__/utils.spec.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
getLocalhostAddressIfDiffersFromDNS,
1111
injectQuery,
1212
isFileReadable,
13+
isParentDirectory,
1314
posToNumber,
1415
processSrcSetSync,
1516
resolveHostname,
@@ -35,6 +36,33 @@ describe('bareImportRE', () => {
3536
})
3637
})
3738

39+
describe('isParentDirectory', () => {
40+
const cases = {
41+
'/parent': {
42+
'/parent': false,
43+
'/parenta': false,
44+
'/parent/': true,
45+
'/parent/child': true,
46+
'/parent/child/child2': true,
47+
},
48+
'/parent/': {
49+
'/parent': false,
50+
'/parenta': false,
51+
'/parent/': true,
52+
'/parent/child': true,
53+
'/parent/child/child2': true,
54+
},
55+
}
56+
57+
for (const [parent, children] of Object.entries(cases)) {
58+
for (const [child, expected] of Object.entries(children)) {
59+
test(`isParentDirectory("${parent}", "${child}")`, () => {
60+
expect(isParentDirectory(parent, child)).toBe(expected)
61+
})
62+
}
63+
}
64+
})
65+
3866
describe('injectQuery', () => {
3967
if (isWindows) {
4068
// this test will work incorrectly on unix systems

packages/vite/src/node/preview.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ import { indexHtmlMiddleware } from './server/middlewares/indexHtml'
2626
import { notFoundMiddleware } from './server/middlewares/notFound'
2727
import { proxyMiddleware } from './server/middlewares/proxy'
2828
import {
29+
normalizePath,
2930
resolveHostname,
3031
resolveServerUrls,
3132
setupSIGTERMListener,
@@ -248,7 +249,8 @@ export async function preview(
248249

249250
if (config.appType === 'spa' || config.appType === 'mpa') {
250251
// transform index.html
251-
app.use(indexHtmlMiddleware(distDir, server))
252+
const normalizedDistDir = normalizePath(distDir)
253+
app.use(indexHtmlMiddleware(normalizedDistDir, server))
252254

253255
// handle 404s
254256
app.use(notFoundMiddleware())
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
1+
import { describe, expect, test } from 'vitest'
2+
import { isUriInFilePath } from '../static'
3+
4+
describe('isUriInFilePath', () => {
5+
const cases = {
6+
'/parent': {
7+
'/parent': true,
8+
'/parenta': false,
9+
'/parent/': true,
10+
'/parent/child': true,
11+
'/parent/child/child2': true,
12+
},
13+
'/parent/': {
14+
'/parent': false,
15+
'/parenta': false,
16+
'/parent/': true,
17+
'/parent/child': true,
18+
'/parent/child/child2': true,
19+
},
20+
}
21+
22+
for (const [parent, children] of Object.entries(cases)) {
23+
for (const [child, expected] of Object.entries(children)) {
24+
test(`isUriInFilePath("${parent}", "${child}")`, () => {
25+
expect(isUriInFilePath(parent, child)).toBe(expected)
26+
})
27+
}
28+
}
29+
})

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

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
injectQuery,
3535
isDevServer,
3636
isJSRequest,
37+
isParentDirectory,
3738
joinUrlSegments,
3839
normalizePath,
3940
processSrcSetSync,
@@ -44,6 +45,7 @@ import { checkPublicFile } from '../../publicDir'
4445
import { isCSSRequest } from '../../plugins/css'
4546
import { getCodeWithSourcemap, injectSourcesContent } from '../sourcemap'
4647
import { cleanUrl, unwrapId, wrapId } from '../../../shared/utils'
48+
import { checkLoadingAccess, respondWithAccessDenied } from './static'
4749

4850
interface AssetNode {
4951
start: number
@@ -431,7 +433,26 @@ export function indexHtmlMiddleware(
431433
if (isDev && url.startsWith(FS_PREFIX)) {
432434
filePath = decodeURIComponent(fsPathFromId(url))
433435
} else {
434-
filePath = path.join(root, decodeURIComponent(url))
436+
filePath = normalizePath(
437+
path.resolve(path.join(root, decodeURIComponent(url))),
438+
)
439+
}
440+
441+
if (isDev) {
442+
const servingAccessResult = checkLoadingAccess(server, filePath)
443+
if (servingAccessResult === 'denied') {
444+
return respondWithAccessDenied(filePath, server, res)
445+
}
446+
if (servingAccessResult === 'fallback') {
447+
return next()
448+
}
449+
servingAccessResult satisfies 'allowed'
450+
} else {
451+
// `server.fs` options does not apply to the preview server.
452+
// But we should disallow serving files outside the output directory.
453+
if (!isParentDirectory(root, filePath)) {
454+
return next()
455+
}
435456
}
436457

437458
if (fsUtils.existsSync(filePath)) {

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -247,7 +247,7 @@ export function isFileServingAllowed(
247247
return isFileLoadingAllowed(server, filePath)
248248
}
249249

250-
function isUriInFilePath(uri: string, filePath: string) {
250+
export function isUriInFilePath(uri: string, filePath: string): boolean {
251251
return isSameFileUri(uri, filePath) || isParentDirectory(uri, filePath)
252252
}
253253

playground/fs-serve/__tests__/fs-serve.spec.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,15 @@ describe.runIf(isServe)('main', () => {
6262
expect(await page.textContent('.unsafe-fetch-status')).toBe('403')
6363
})
6464

65+
test('unsafe HTML fetch', async () => {
66+
await expect
67+
.poll(() => page.textContent('.unsafe-fetch-html'))
68+
.toMatch('403 Restricted')
69+
await expect
70+
.poll(() => page.textContent('.unsafe-fetch-html-status'))
71+
.toBe('403')
72+
})
73+
6574
test('unsafe fetch with special characters (#8498)', async () => {
6675
expect(await page.textContent('.unsafe-fetch-8498')).toBe('')
6776
expect(await page.textContent('.unsafe-fetch-8498-status')).toBe('404')
@@ -478,4 +487,18 @@ describe.runIf(isServe)('invalid request', () => {
478487
)
479488
expect(response).toContain('HTTP/1.1 403 Forbidden')
480489
})
490+
491+
test('should deny request to HTML file outside root by default with relative path', async () => {
492+
const response = await sendRawRequest(viteTestUrl, '/../unsafe.html')
493+
expect(response).toContain('HTTP/1.1 403 Forbidden')
494+
})
495+
})
496+
497+
describe.runIf(!isServe)('preview HTML', () => {
498+
test('unsafe HTML fetch', async () => {
499+
await expect.poll(() => page.textContent('.unsafe-fetch-html')).toBe('')
500+
await expect
501+
.poll(() => page.textContent('.unsafe-fetch-html-status'))
502+
.toBe('404')
503+
})
481504
})

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

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ <h2>Safe Fetch Subdirectory</h2>
1919
<h2>Unsafe Fetch</h2>
2020
<pre class="unsafe-fetch-status"></pre>
2121
<pre class="unsafe-fetch"></pre>
22+
<pre class="unsafe-fetch-html-status"></pre>
23+
<pre class="unsafe-fetch-html"></pre>
2224
<pre class="unsafe-fetch-8498-status"></pre>
2325
<pre class="unsafe-fetch-8498"></pre>
2426
<pre class="unsafe-fetch-8498-2-status"></pre>
@@ -39,6 +41,8 @@ <h2>Safe /@fs/ Fetch</h2>
3941
<h2>Unsafe /@fs/ Fetch</h2>
4042
<pre class="unsafe-fs-fetch-status"></pre>
4143
<pre class="unsafe-fs-fetch"></pre>
44+
<pre class="unsafe-fs-fetch-html-status"></pre>
45+
<pre class="unsafe-fs-fetch-html"></pre>
4246
<pre class="unsafe-fs-fetch-raw-status"></pre>
4347
<pre class="unsafe-fs-fetch-raw"></pre>
4448
<pre class="unsafe-fs-fetch-raw-query1-status"></pre>
@@ -142,6 +146,19 @@ <h2>Denied</h2>
142146
console.error(e)
143147
})
144148

149+
// outside of allowed dir, treated as unsafe
150+
fetch(joinUrlSegments(base, '/unsafe.html'))
151+
.then((r) => {
152+
text('.unsafe-fetch-html-status', r.status)
153+
return r.text()
154+
})
155+
.then((data) => {
156+
text('.unsafe-fetch-html', data)
157+
})
158+
.catch((e) => {
159+
console.error(e)
160+
})
161+
145162
// outside of allowed dir with special characters #8498
146163
fetch(joinUrlSegments(base, '/src/%2e%2e%2funsafe%2etxt'))
147164
.then((r) => {
@@ -239,6 +256,19 @@ <h2>Denied</h2>
239256
console.error(e)
240257
})
241258

259+
// not imported before, outside of root, treated as unsafe
260+
fetch(joinUrlSegments(base, joinUrlSegments('/@fs/', ROOT) + '/unsafe.html'))
261+
.then((r) => {
262+
text('.unsafe-fs-fetch-html-status', r.status)
263+
return r.text()
264+
})
265+
.then((data) => {
266+
text('.unsafe-fs-fetch-html', data)
267+
})
268+
.catch((e) => {
269+
console.error(e)
270+
})
271+
242272
// not imported before, outside of root, treated as unsafe
243273
fetch(
244274
joinUrlSegments(

playground/fs-serve/root/unsafe.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>unsafe</p>

playground/fs-serve/unsafe.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
<p>unsafe outside root</p>

0 commit comments

Comments
 (0)