Skip to content

Commit e9970c4

Browse files
committed
wip: reject no-cors requests
1 parent b45be43 commit e9970c4

File tree

4 files changed

+81
-1
lines changed

4 files changed

+81
-1
lines changed

packages/vite/src/node/server/index.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,7 @@ import type { DevEnvironment } from './environment'
102102
import { hostValidationMiddleware } from './middlewares/hostCheck'
103103
import { rejectInvalidRequestMiddleware } from './middlewares/rejectInvalidRequest'
104104
import { memoryFilesMiddleware } from './middlewares/memoryFiles'
105+
import { rejectNoCorsRequestMiddleware } from './middlewares/rejectNoCorsRequest'
105106

106107
const usedConfigs = new WeakSet<ResolvedConfig>()
107108

@@ -864,8 +865,8 @@ export async function _createServer(
864865
middlewares.use(timeMiddleware(root))
865866
}
866867

867-
// disallows request that contains `#` in the URL
868868
middlewares.use(rejectInvalidRequestMiddleware())
869+
middlewares.use(rejectNoCorsRequestMiddleware())
869870

870871
// cors
871872
const { cors } = serverConfig

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import type { Connect } from 'dep-types/connect'
22

3+
// disallows request that contains `#` in the URL
34
export function rejectInvalidRequestMiddleware(): Connect.NextHandleFunction {
45
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
56
return function viteRejectInvalidRequestMiddleware(req, res, next) {
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
import type { Connect } from 'dep-types/connect'
2+
3+
/**
4+
* A middleware that rejects no-cors mode requests that are not same-origin.
5+
*
6+
* We should avoid untrusted sites to load the script to avoid attacks like GHSA-4v9v-hfq4-rm2v.
7+
* This is because:
8+
* - the path of HMR patch files / entry point files can be predictable
9+
* - the HMR patch files may not include ESM syntax
10+
* (if they include ESM syntax, loading as a classic script would fail)
11+
* - the HMR runtime in the browser has the list of all loaded modules
12+
*
13+
* https://github.com/webpack/webpack-dev-server/security/advisories/GHSA-4v9v-hfq4-rm2v
14+
* https://green.sapphi.red/blog/local-server-security-best-practices#_2-using-xssi-and-modifying-the-prototype
15+
* https://green.sapphi.red/blog/local-server-security-best-practices#properly-check-the-request-origin
16+
*/
17+
export function rejectNoCorsRequestMiddleware(): Connect.NextHandleFunction {
18+
// Keep the named function. The name is visible in debug logs via `DEBUG=connect:dispatcher ...`
19+
return function viteRejectNoCorsRequestMiddleware(req, res, next) {
20+
// While we can set Cross-Origin-Resource-Policy header instead of rejecting requests,
21+
// we choose to reject the request to be safer in case the request handler has any side-effects.
22+
if (
23+
req.headers['sec-fetch-mode'] === 'no-cors' &&
24+
req.headers['sec-fetch-site'] !== 'same-origin'
25+
) {
26+
res.statusCode = 403
27+
res.end('Cross-origin requests must be made with CORS mode enabled.')
28+
return
29+
}
30+
return next()
31+
}
32+
}

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

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -560,3 +560,49 @@ describe.runIf(!isServe)('preview HTML', () => {
560560
.toBe('404')
561561
})
562562
})
563+
564+
test.runIf(isServe)(
565+
'load script with no-cors mode from a different origin',
566+
async () => {
567+
const viteTestUrlUrl = new URL(viteTestUrl)
568+
569+
// NOTE: fetch cannot be used here as `fetch` sets some headers automatically
570+
const res = await new Promise<http.IncomingMessage>((resolve, reject) => {
571+
http
572+
.get(
573+
viteTestUrl + '/src/code.js',
574+
{
575+
headers: {
576+
'Sec-Fetch-Dest': 'script',
577+
'Sec-Fetch-Mode': 'no-cors',
578+
'Sec-Fetch-Site': 'same-site',
579+
Origin: 'http://vite.dev',
580+
Host: viteTestUrlUrl.host,
581+
},
582+
},
583+
(res) => {
584+
resolve(res)
585+
},
586+
)
587+
.on('error', (e) => {
588+
reject(e)
589+
})
590+
})
591+
expect(res.statusCode).toBe(403)
592+
const body = Buffer.concat(await ArrayFromAsync(res)).toString()
593+
expect(body).toBe(
594+
'Cross-origin requests must be made with CORS mode enabled.',
595+
)
596+
},
597+
)
598+
599+
// Note: Array.fromAsync is only supported in Node.js 22+
600+
async function ArrayFromAsync<T>(
601+
asyncIterable: AsyncIterable<T>,
602+
): Promise<T[]> {
603+
const chunks = []
604+
for await (const chunk of asyncIterable) {
605+
chunks.push(chunk)
606+
}
607+
return chunks
608+
}

0 commit comments

Comments
 (0)