Skip to content

Commit c22c43d

Browse files
authored
fix: check static serve file inside sirv (#19965)
1 parent 171e856 commit c22c43d

File tree

5 files changed

+124
-50
lines changed

5 files changed

+124
-50
lines changed

docs/config/server-options.md

+6
Original file line numberDiff line numberDiff line change
@@ -377,6 +377,12 @@ export default defineConfig({
377377

378378
Blocklist for sensitive files being restricted to be served by Vite dev server. This will have higher priority than [`server.fs.allow`](#server-fs-allow). [picomatch patterns](https://github.com/micromatch/picomatch#globbing-features) are supported.
379379

380+
::: tip NOTE
381+
382+
This blocklist does not apply to [the public directory](/guide/assets.md#the-public-directory). All files in the public directory are served without any filtering, since they are copied directly to the output directory during build.
383+
384+
:::
385+
380386
## server.origin
381387

382388
- **Type:** `string`

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

+85-42
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,6 @@ import type { ViteDevServer } from '../../server'
88
import type { ResolvedConfig } from '../../config'
99
import { FS_PREFIX } from '../../constants'
1010
import {
11-
fsPathFromId,
1211
fsPathFromUrl,
1312
isFileReadable,
1413
isImportRequest,
@@ -27,11 +26,16 @@ import {
2726
} from '../../../shared/utils'
2827

2928
const knownJavascriptExtensionRE = /\.(?:[tj]sx?|[cm][tj]s)$/
29+
const ERR_DENIED_FILE = 'ERR_DENIED_FILE'
3030

3131
const sirvOptions = ({
32+
config,
3233
getHeaders,
34+
disableFsServeCheck,
3335
}: {
36+
config: ResolvedConfig
3437
getHeaders: () => OutgoingHttpHeaders | undefined
38+
disableFsServeCheck?: boolean
3539
}): Options => {
3640
return {
3741
dev: true,
@@ -53,6 +57,22 @@ const sirvOptions = ({
5357
}
5458
}
5559
},
60+
shouldServe: disableFsServeCheck
61+
? undefined
62+
: (filePath) => {
63+
const servingAccessResult = checkLoadingAccess(config, filePath)
64+
if (servingAccessResult === 'denied') {
65+
const error: any = new Error('denied access')
66+
error.code = ERR_DENIED_FILE
67+
error.path = filePath
68+
throw error
69+
}
70+
if (servingAccessResult === 'fallback') {
71+
return false
72+
}
73+
servingAccessResult satisfies 'allowed'
74+
return true
75+
},
5676
}
5777
}
5878

@@ -64,7 +84,9 @@ export function servePublicMiddleware(
6484
const serve = sirv(
6585
dir,
6686
sirvOptions({
87+
config: server.config,
6788
getHeaders: () => server.config.server.headers,
89+
disableFsServeCheck: true,
6890
}),
6991
)
7092

@@ -105,6 +127,7 @@ export function serveStaticMiddleware(
105127
const serve = sirv(
106128
dir,
107129
sirvOptions({
130+
config: server.config,
108131
getHeaders: () => server.config.server.headers,
109132
}),
110133
)
@@ -154,16 +177,20 @@ export function serveStaticMiddleware(
154177
if (resolvedPathname.endsWith('/') && fileUrl[fileUrl.length - 1] !== '/') {
155178
fileUrl = withTrailingSlash(fileUrl)
156179
}
157-
if (!ensureServingAccess(fileUrl, server, res, next)) {
158-
return
159-
}
160-
161180
if (redirectedPathname) {
162181
url.pathname = encodeURI(redirectedPathname)
163182
req.url = url.href.slice(url.origin.length)
164183
}
165184

166-
serve(req, res, next)
185+
try {
186+
serve(req, res, next)
187+
} catch (e) {
188+
if (e && 'code' in e && e.code === ERR_DENIED_FILE) {
189+
respondWithAccessDenied(e.path, server, res)
190+
return
191+
}
192+
throw e
193+
}
167194
}
168195
}
169196

@@ -172,7 +199,10 @@ export function serveRawFsMiddleware(
172199
): Connect.NextHandleFunction {
173200
const serveFromRoot = sirv(
174201
'/',
175-
sirvOptions({ getHeaders: () => server.config.server.headers }),
202+
sirvOptions({
203+
config: server.config,
204+
getHeaders: () => server.config.server.headers,
205+
}),
176206
)
177207

178208
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
@@ -184,24 +214,20 @@ export function serveRawFsMiddleware(
184214
if (req.url!.startsWith(FS_PREFIX)) {
185215
const url = new URL(req.url!, 'http://example.com')
186216
const pathname = decodeURI(url.pathname)
187-
// restrict files outside of `fs.allow`
188-
if (
189-
!ensureServingAccess(
190-
slash(path.resolve(fsPathFromId(pathname))),
191-
server,
192-
res,
193-
next,
194-
)
195-
) {
196-
return
197-
}
198-
199217
let newPathname = pathname.slice(FS_PREFIX.length)
200218
if (isWindows) newPathname = newPathname.replace(/^[A-Z]:/i, '')
201-
202219
url.pathname = encodeURI(newPathname)
203220
req.url = url.href.slice(url.origin.length)
204-
serveFromRoot(req, res, next)
221+
222+
try {
223+
serveFromRoot(req, res, next)
224+
} catch (e) {
225+
if (e && 'code' in e && e.code === ERR_DENIED_FILE) {
226+
respondWithAccessDenied(e.path, server, res)
227+
return
228+
}
229+
throw e
230+
}
205231
} else {
206232
next()
207233
}
@@ -210,14 +236,12 @@ export function serveRawFsMiddleware(
210236

211237
/**
212238
* Check if the url is allowed to be served, via the `server.fs` config.
239+
* @deprecated Use the `isFileLoadingAllowed` function instead.
213240
*/
214241
export function isFileServingAllowed(
215242
config: ResolvedConfig,
216243
url: string,
217244
): boolean
218-
/**
219-
* @deprecated Use the `isFileServingAllowed(config, url)` signature instead.
220-
*/
221245
export function isFileServingAllowed(
222246
url: string,
223247
server: ViteDevServer,
@@ -259,33 +283,52 @@ export function isFileLoadingAllowed(
259283
return false
260284
}
261285

262-
export function ensureServingAccess(
286+
export function checkLoadingAccess(
287+
config: ResolvedConfig,
288+
path: string,
289+
): 'allowed' | 'denied' | 'fallback' {
290+
if (isFileLoadingAllowed(config, slash(path))) {
291+
return 'allowed'
292+
}
293+
if (isFileReadable(path)) {
294+
return 'denied'
295+
}
296+
// if the file doesn't exist, we shouldn't restrict this path as it can
297+
// be an API call. Middlewares would issue a 404 if the file isn't handled
298+
return 'fallback'
299+
}
300+
301+
export function checkServingAccess(
263302
url: string,
264303
server: ViteDevServer,
265-
res: ServerResponse,
266-
next: Connect.NextFunction,
267-
): boolean {
304+
): 'allowed' | 'denied' | 'fallback' {
268305
if (isFileServingAllowed(url, server)) {
269-
return true
306+
return 'allowed'
270307
}
271308
if (isFileReadable(cleanUrl(url))) {
272-
const urlMessage = `The request url "${url}" is outside of Vite serving allow list.`
273-
const hintMessage = `
309+
return 'denied'
310+
}
311+
// if the file doesn't exist, we shouldn't restrict this path as it can
312+
// be an API call. Middlewares would issue a 404 if the file isn't handled
313+
return 'fallback'
314+
}
315+
316+
export function respondWithAccessDenied(
317+
url: string,
318+
server: ViteDevServer,
319+
res: ServerResponse,
320+
): void {
321+
const urlMessage = `The request url "${url}" is outside of Vite serving allow list.`
322+
const hintMessage = `
274323
${server.config.server.fs.allow.map((i) => `- ${i}`).join('\n')}
275324
276325
Refer to docs https://vite.dev/config/server-options.html#server-fs-allow for configurations and more details.`
277326

278-
server.config.logger.error(urlMessage)
279-
server.config.logger.warnOnce(hintMessage + '\n')
280-
res.statusCode = 403
281-
res.write(renderRestrictedErrorHTML(urlMessage + '\n' + hintMessage))
282-
res.end()
283-
} else {
284-
// if the file doesn't exist, we shouldn't restrict this path as it can
285-
// be an API call. Middlewares would issue a 404 if the file isn't handled
286-
next()
287-
}
288-
return false
327+
server.config.logger.error(urlMessage)
328+
server.config.logger.warnOnce(hintMessage + '\n')
329+
res.statusCode = 403
330+
res.write(renderRestrictedErrorHTML(urlMessage + '\n' + hintMessage))
331+
res.end()
289332
}
290333

291334
function renderRestrictedErrorHTML(msg: string): string {

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

+19-8
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ import {
4141
ERR_OUTDATED_OPTIMIZED_DEP,
4242
NULL_BYTE_PLACEHOLDER,
4343
} from '../../../shared/constants'
44-
import { ensureServingAccess } from './static'
44+
import { checkServingAccess, respondWithAccessDenied } from './static'
4545

4646
const debugCache = createDebugger('vite:cache')
4747

@@ -60,13 +60,24 @@ function deniedServingAccessForTransform(
6060
res: ServerResponse,
6161
next: Connect.NextFunction,
6262
) {
63-
return (
64-
(rawRE.test(url) ||
65-
urlRE.test(url) ||
66-
inlineRE.test(url) ||
67-
svgRE.test(url)) &&
68-
!ensureServingAccess(url, server, res, next)
69-
)
63+
if (
64+
rawRE.test(url) ||
65+
urlRE.test(url) ||
66+
inlineRE.test(url) ||
67+
svgRE.test(url)
68+
) {
69+
const servingAccessResult = checkServingAccess(url, server)
70+
if (servingAccessResult === 'denied') {
71+
respondWithAccessDenied(url, server, res)
72+
return true
73+
}
74+
if (servingAccessResult === 'fallback') {
75+
next()
76+
return true
77+
}
78+
servingAccessResult satisfies 'allowed'
79+
}
80+
return false
7081
}
7182

7283
/**

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

+13
Original file line numberDiff line numberDiff line change
@@ -478,4 +478,17 @@ describe.runIf(isServe)('invalid request', () => {
478478
)
479479
expect(response).toContain('HTTP/1.1 400 Bad Request')
480480
})
481+
482+
test('should deny request to denied file when a request has /.', async () => {
483+
const response = await sendRawRequest(viteTestUrl, '/src/dummy.crt/.')
484+
expect(response).toContain('HTTP/1.1 403 Forbidden')
485+
})
486+
487+
test('should deny request with /@fs/ to denied file when a request has /.', async () => {
488+
const response = await sendRawRequest(
489+
viteTestUrl,
490+
path.posix.join('/@fs/', root, 'root/src/dummy.crt/') + '.',
491+
)
492+
expect(response).toContain('HTTP/1.1 403 Forbidden')
493+
})
481494
})
+1
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
secret

0 commit comments

Comments
 (0)