Skip to content

Commit 029dcd6

Browse files
committed
fix: verify token for HMR WebSocket connection
1 parent b09572a commit 029dcd6

File tree

7 files changed

+216
-21
lines changed

7 files changed

+216
-21
lines changed

packages/vite/src/client/client.ts

+10-3
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ declare const __HMR_DIRECT_TARGET__: string
1919
declare const __HMR_BASE__: string
2020
declare const __HMR_TIMEOUT__: number
2121
declare const __HMR_ENABLE_OVERLAY__: boolean
22+
declare const __WS_TOKEN__: string
2223

2324
console.debug('[vite] connecting...')
2425

@@ -35,12 +36,16 @@ const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
3536
const directSocketHost = __HMR_DIRECT_TARGET__
3637
const base = __BASE__ || '/'
3738
const hmrTimeout = __HMR_TIMEOUT__
39+
const wsToken = __WS_TOKEN__
3840

3941
const transport = normalizeModuleRunnerTransport(
4042
(() => {
4143
let wsTransport = createWebSocketModuleRunnerTransport({
4244
createConnection: () =>
43-
new WebSocket(`${socketProtocol}://${socketHost}`, 'vite-hmr'),
45+
new WebSocket(
46+
`${socketProtocol}://${socketHost}?token=${wsToken}`,
47+
'vite-hmr',
48+
),
4449
pingInterval: hmrTimeout,
4550
})
4651

@@ -54,7 +59,7 @@ const transport = normalizeModuleRunnerTransport(
5459
wsTransport = createWebSocketModuleRunnerTransport({
5560
createConnection: () =>
5661
new WebSocket(
57-
`${socketProtocol}://${directSocketHost}`,
62+
`${socketProtocol}://${directSocketHost}?token=${wsToken}`,
5863
'vite-hmr',
5964
),
6065
pingInterval: hmrTimeout,
@@ -241,7 +246,9 @@ async function handleMessage(payload: HotPayload) {
241246
if (hasDocument && !willUnload) {
242247
console.log(`[vite] server connection lost. Polling for restart...`)
243248
const socket = payload.data.webSocket as WebSocket
244-
await waitForSuccessfulPing(socket.url)
249+
const url = new URL(socket.url)
250+
url.search = '' // remove query string including `token`
251+
await waitForSuccessfulPing(url.href)
245252
location.reload()
246253
}
247254
}

packages/vite/src/node/config.ts

+32
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { pathToFileURL } from 'node:url'
55
import { promisify } from 'node:util'
66
import { performance } from 'node:perf_hooks'
77
import { createRequire } from 'node:module'
8+
import crypto from 'node:crypto'
89
import colors from 'picocolors'
910
import type { Alias, AliasOptions } from 'dep-types/alias'
1011
import { build } from 'esbuild'
@@ -519,6 +520,18 @@ export interface LegacyOptions {
519520
* https://github.com/vitejs/vite/discussions/14697.
520521
*/
521522
proxySsrExternalModules?: boolean
523+
/**
524+
* In Vite 6.0.8 and below, WebSocket server was able to connect from any web pages. However,
525+
* that could be exploited by a malicious web page.
526+
*
527+
* In Vite 6.0.9+, the WebSocket server now requires a token to connect from a web page.
528+
* But this may break some plugins and frameworks that connects to the WebSocket server
529+
* on their own. Enabling this option will make Vite skip the token check.
530+
*
531+
* **We do not recommend enabling this option unless you are sure that you are fine with
532+
* that security weakness.**
533+
*/
534+
skipWebSocketTokenCheck?: boolean
522535
}
523536

524537
export interface ResolvedWorkerOptions {
@@ -593,6 +606,17 @@ export type ResolvedConfig = Readonly<
593606
appType: AppType
594607
experimental: ExperimentalOptions
595608
environments: Record<string, ResolvedEnvironmentOptions>
609+
/**
610+
* The token to connect to the WebSocket server from browsers.
611+
*
612+
* We recommend using `import.meta.hot` rather than connecting
613+
* to the WebSocket server directly.
614+
* If you have a usecase that requires connecting to the WebSocket
615+
* server, please create an issue so that we can discuss.
616+
*
617+
* @deprecated
618+
*/
619+
webSocketToken: string
596620
/** @internal */
597621
fsDenyGlob: AnymatchFn
598622
/** @internal */
@@ -673,6 +697,7 @@ export const configDefaults = Object.freeze({
673697
},
674698
legacy: {
675699
proxySsrExternalModules: false,
700+
skipWebSocketTokenCheck: false,
676701
},
677702
logLevel: 'info',
678703
customLogger: undefined,
@@ -1420,6 +1445,13 @@ export async function resolveConfig(
14201445

14211446
environments: resolvedEnvironments,
14221447

1448+
// random 72 bits (12 base64 chars)
1449+
// at least 64bits is recommended
1450+
// https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length
1451+
webSocketToken: Buffer.from(
1452+
crypto.getRandomValues(new Uint8Array(9)),
1453+
).toString('base64url'),
1454+
14231455
getSortedPlugins: undefined!,
14241456
getSortedPluginHooks: undefined!,
14251457

packages/vite/src/node/plugins/clientInjections.ts

+2
Original file line numberDiff line numberDiff line change
@@ -76,6 +76,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
7676
const hmrTimeoutReplacement = escapeReplacement(timeout)
7777
const hmrEnableOverlayReplacement = escapeReplacement(overlay)
7878
const hmrConfigNameReplacement = escapeReplacement(hmrConfigName)
79+
const wsTokenReplacement = escapeReplacement(config.webSocketToken)
7980

8081
injectConfigValues = (code: string) => {
8182
return code
@@ -90,6 +91,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
9091
.replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement)
9192
.replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement)
9293
.replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement)
94+
.replace(`__WS_TOKEN__`, wsTokenReplacement)
9395
}
9496
},
9597
async transform(code, id, options) {

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

+74-16
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import type { ServerOptions as HttpsServerOptions } from 'node:https'
55
import { createServer as createHttpsServer } from 'node:https'
66
import type { Socket } from 'node:net'
77
import type { Duplex } from 'node:stream'
8+
import crypto from 'node:crypto'
89
import colors from 'picocolors'
910
import type { WebSocket as WebSocketRaw } from 'ws'
1011
import { WebSocketServer as WebSocketServerRaw_ } from 'ws'
@@ -87,6 +88,29 @@ function noop() {
8788
// noop
8889
}
8990

91+
// we only allow websockets to be connected if it has a valid token
92+
// this is to prevent untrusted origins to connect to the server
93+
// for example, Cross-site WebSocket hijacking
94+
//
95+
// we should check the token before calling wss.handleUpgrade
96+
// otherwise untrusted ws clients will be included in wss.clients
97+
//
98+
// using the query params means the token might be logged out in server or middleware logs
99+
// but we assume that is not an issue since the token is regenerated for each process
100+
function hasValidToken(config: ResolvedConfig, url: URL) {
101+
const token = url.searchParams.get('token')
102+
if (!token) return false
103+
104+
try {
105+
const isValidToken = crypto.timingSafeEqual(
106+
Buffer.from(token),
107+
Buffer.from(config.webSocketToken),
108+
)
109+
return isValidToken
110+
} catch {} // an error is thrown when the length is incorrect
111+
return false
112+
}
113+
90114
export function createWebSocketServer(
91115
server: HttpServer | null,
92116
config: ResolvedConfig,
@@ -116,7 +140,6 @@ export function createWebSocketServer(
116140
}
117141
}
118142

119-
let wss: WebSocketServerRaw_
120143
let wsHttpServer: Server | undefined = undefined
121144

122145
const hmr = isObject(config.server.hmr) && config.server.hmr
@@ -135,23 +158,64 @@ export function createWebSocketServer(
135158
const port = hmrPort || 24678
136159
const host = (hmr && hmr.host) || undefined
137160

161+
const shouldHandle = (req: IncomingMessage) => {
162+
const protocol = req.headers['sec-websocket-protocol']!
163+
// vite-ping is allowed to connect from anywhere
164+
// because it needs to be connected before the client fetches the new `/@vite/client`
165+
// this is fine because vite-ping does not receive / send any meaningful data
166+
if (protocol === 'vite-ping') return true
167+
168+
if (config.legacy?.skipWebSocketTokenCheck) {
169+
return true
170+
}
171+
172+
// If the Origin header is set, this request might be coming from a browser.
173+
// Browsers always sets the Origin header for WebSocket connections.
174+
if (req.headers.origin) {
175+
const parsedUrl = new URL(`http://example.com${req.url!}`)
176+
return hasValidToken(config, parsedUrl)
177+
}
178+
179+
// We allow non-browser requests to connect without a token
180+
// for backward compat and convenience
181+
// This is fine because if you can sent a request without the SOP limitation,
182+
// you can also send a normal HTTP request to the server.
183+
return true
184+
}
185+
const handleUpgrade = (
186+
req: IncomingMessage,
187+
socket: Duplex,
188+
head: Buffer,
189+
isPing: boolean,
190+
) => {
191+
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
192+
// vite-ping is allowed to connect from anywhere
193+
// we close the connection immediately without connection event
194+
// so that the client does not get included in `wss.clients`
195+
if (isPing) {
196+
ws.close(/* Normal Closure */ 1000)
197+
return
198+
}
199+
wss.emit('connection', ws, req)
200+
})
201+
}
202+
const wss: WebSocketServerRaw_ = new WebSocketServerRaw({ noServer: true })
203+
wss.shouldHandle = shouldHandle
204+
138205
if (wsServer) {
139206
let hmrBase = config.base
140207
const hmrPath = hmr ? hmr.path : undefined
141208
if (hmrPath) {
142209
hmrBase = path.posix.join(hmrBase, hmrPath)
143210
}
144-
wss = new WebSocketServerRaw({ noServer: true })
145211
hmrServerWsListener = (req, socket, head) => {
212+
const protocol = req.headers['sec-websocket-protocol']!
213+
const parsedUrl = new URL(`http://example.com${req.url!}`)
146214
if (
147-
[HMR_HEADER, 'vite-ping'].includes(
148-
req.headers['sec-websocket-protocol']!,
149-
) &&
150-
req.url === hmrBase
215+
[HMR_HEADER, 'vite-ping'].includes(protocol) &&
216+
parsedUrl.pathname === hmrBase
151217
) {
152-
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
153-
wss.emit('connection', ws, req)
154-
})
218+
handleUpgrade(req, socket as Socket, head, protocol === 'vite-ping')
155219
}
156220
}
157221
wsServer.on('upgrade', hmrServerWsListener)
@@ -177,7 +241,6 @@ export function createWebSocketServer(
177241
} else {
178242
wsHttpServer = createHttpServer(route)
179243
}
180-
wss = new WebSocketServerRaw({ noServer: true })
181244
wsHttpServer.on('upgrade', (req, socket, head) => {
182245
const protocol = req.headers['sec-websocket-protocol']!
183246
if (protocol === 'vite-ping' && server && !server.listening) {
@@ -187,9 +250,7 @@ export function createWebSocketServer(
187250
req.destroy()
188251
return
189252
}
190-
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
191-
wss.emit('connection', ws, req)
192-
})
253+
handleUpgrade(req, socket as Socket, head, protocol === 'vite-ping')
193254
})
194255
wsHttpServer.on('error', (e: Error & { code: string }) => {
195256
if (e.code === 'EADDRINUSE') {
@@ -207,9 +268,6 @@ export function createWebSocketServer(
207268
}
208269

209270
wss.on('connection', (socket) => {
210-
if (socket.protocol === 'vite-ping') {
211-
return
212-
}
213271
socket.on('message', (raw) => {
214272
if (!customListeners.size) return
215273
let parsed: any

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

+90-1
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,9 @@ import {
88
test,
99
} from 'vitest'
1010
import type { Page } from 'playwright-chromium'
11+
import WebSocket from 'ws'
1112
import testJSON from '../safe.json'
12-
import { browser, isServe, page, viteTestUrl } from '~utils'
13+
import { browser, isServe, page, viteServer, viteTestUrl } from '~utils'
1314

1415
const getViteTestIndexHtmlUrl = () => {
1516
const srcPrefix = viteTestUrl.endsWith('/') ? '' : '/'
@@ -139,6 +140,51 @@ describe('cross origin', () => {
139140
}, url)
140141
}
141142

143+
const connectWebSocketFromPage = async (page: Page, url: string) => {
144+
return await page.evaluate(async (url: string) => {
145+
try {
146+
const ws = new globalThis.WebSocket(url, ['vite-hmr'])
147+
await new Promise<void>((resolve, reject) => {
148+
ws.addEventListener('open', () => {
149+
resolve()
150+
ws.close()
151+
})
152+
ws.addEventListener('error', () => {
153+
reject()
154+
})
155+
})
156+
return true
157+
} catch {
158+
return false
159+
}
160+
}, url)
161+
}
162+
163+
const connectWebSocketFromServer = async (
164+
url: string,
165+
origin: string | undefined,
166+
) => {
167+
try {
168+
const ws = new WebSocket(url, ['vite-hmr'], {
169+
headers: {
170+
...(origin ? { Origin: origin } : undefined),
171+
},
172+
})
173+
await new Promise<void>((resolve, reject) => {
174+
ws.addEventListener('open', () => {
175+
resolve()
176+
ws.close()
177+
})
178+
ws.addEventListener('error', () => {
179+
reject()
180+
})
181+
})
182+
return true
183+
} catch {
184+
return false
185+
}
186+
}
187+
142188
describe('allowed for same origin', () => {
143189
beforeEach(async () => {
144190
await page.goto(getViteTestIndexHtmlUrl())
@@ -156,6 +202,23 @@ describe('cross origin', () => {
156202
)
157203
expect(status).toBe(200)
158204
})
205+
206+
test.runIf(isServe)('connect WebSocket with valid token', async () => {
207+
const token = viteServer.config.webSocketToken
208+
const result = await connectWebSocketFromPage(
209+
page,
210+
`${viteTestUrl}?token=${token}`,
211+
)
212+
expect(result).toBe(true)
213+
})
214+
215+
test.runIf(isServe)(
216+
'connect WebSocket without a token without the origin header',
217+
async () => {
218+
const result = await connectWebSocketFromServer(viteTestUrl, undefined)
219+
expect(result).toBe(true)
220+
},
221+
)
159222
})
160223

161224
describe('denied for different origin', async () => {
@@ -180,5 +243,31 @@ describe('cross origin', () => {
180243
)
181244
expect(status).not.toBe(200)
182245
})
246+
247+
test.runIf(isServe)('connect WebSocket without token', async () => {
248+
const result = await connectWebSocketFromPage(page, viteTestUrl)
249+
expect(result).toBe(false)
250+
251+
const result2 = await connectWebSocketFromPage(
252+
page,
253+
`${viteTestUrl}?token=`,
254+
)
255+
expect(result2).toBe(false)
256+
})
257+
258+
test.runIf(isServe)('connect WebSocket with invalid token', async () => {
259+
const token = viteServer.config.webSocketToken
260+
const result = await connectWebSocketFromPage(
261+
page,
262+
`${viteTestUrl}?token=${'t'.repeat(token.length)}`,
263+
)
264+
expect(result).toBe(false)
265+
266+
const result2 = await connectWebSocketFromPage(
267+
page,
268+
`${viteTestUrl}?token=${'t'.repeat(token.length)}t`, // different length
269+
)
270+
expect(result2).toBe(false)
271+
})
183272
})
184273
})

0 commit comments

Comments
 (0)