Skip to content

Commit c065a77

Browse files
committed
fix: verify token for HMR WebSocket connection
1 parent 07b36d5 commit c065a77

File tree

7 files changed

+223
-12
lines changed

7 files changed

+223
-12
lines changed

packages/vite/src/client/client.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ declare const __HMR_DIRECT_TARGET__: string
1414
declare const __HMR_BASE__: string
1515
declare const __HMR_TIMEOUT__: number
1616
declare const __HMR_ENABLE_OVERLAY__: boolean
17+
declare const __WS_TOKEN__: string
1718

1819
console.debug('[vite] connecting...')
1920

@@ -29,6 +30,7 @@ const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
2930
}${__HMR_BASE__}`
3031
const directSocketHost = __HMR_DIRECT_TARGET__
3132
const base = __BASE__ || '/'
33+
const wsToken = __WS_TOKEN__
3234
const messageBuffer: string[] = []
3335

3436
let socket: WebSocket
@@ -74,7 +76,10 @@ function setupWebSocket(
7476
hostAndPath: string,
7577
onCloseWithoutOpen?: () => void,
7678
) {
77-
const socket = new WebSocket(`${protocol}://${hostAndPath}`, 'vite-hmr')
79+
const socket = new WebSocket(
80+
`${protocol}://${hostAndPath}?token=${wsToken}`,
81+
'vite-hmr',
82+
)
7883
let isOpened = false
7984

8085
socket.addEventListener(

packages/vite/src/node/config.ts

+30
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 aliasPlugin from '@rollup/plugin-alias'
@@ -330,6 +331,18 @@ export interface LegacyOptions {
330331
* @default false
331332
*/
332333
buildSsrCjsExternalHeuristics?: boolean
334+
/**
335+
* In Vite 6.0.8 / 5.4.11 / 4.5.5 and below, WebSocket server was able to connect from any web pages. However,
336+
* that could be exploited by a malicious web page.
337+
*
338+
* In Vite 6.0.9+ / 5.4.12+ / 4.5.6+ the WebSocket server now requires a token to connect from a web page.
339+
* But this may break some plugins and frameworks that connects to the WebSocket server
340+
* on their own. Enabling this option will make Vite skip the token check.
341+
*
342+
* **We do not recommend enabling this option unless you are sure that you are fine with
343+
* that security weakness.**
344+
*/
345+
skipWebSocketTokenCheck?: boolean
333346
}
334347

335348
export interface ResolveWorkerOptions extends PluginHookUtils {
@@ -385,6 +398,17 @@ export type ResolvedConfig = Readonly<
385398
worker: ResolveWorkerOptions
386399
appType: AppType
387400
experimental: ExperimentalOptions
401+
/**
402+
* The token to connect to the WebSocket server from browsers.
403+
*
404+
* We recommend using `import.meta.hot` rather than connecting
405+
* to the WebSocket server directly.
406+
* If you have a usecase that requires connecting to the WebSocket
407+
* server, please create an issue so that we can discuss.
408+
*
409+
* @deprecated use `import.meta.hot`
410+
*/
411+
webSocketToken: string
388412
} & PluginHookUtils
389413
>
390414

@@ -734,6 +758,12 @@ export async function resolveConfig(
734758
hmrPartialAccept: false,
735759
...config.experimental,
736760
},
761+
// random 72 bits (12 base64 chars)
762+
// at least 64bits is recommended
763+
// https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length
764+
webSocketToken: Buffer.from(
765+
crypto.getRandomValues(new Uint8Array(9)),
766+
).toString('base64url'),
737767
getSortedPlugins: undefined!,
738768
getSortedPluginHooks: undefined!,
739769
}

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

+2
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
6565
const hmrBaseReplacement = escapeReplacement(hmrBase)
6666
const hmrTimeoutReplacement = escapeReplacement(timeout)
6767
const hmrEnableOverlayReplacement = escapeReplacement(overlay)
68+
const wsTokenReplacement = escapeReplacement(config.webSocketToken)
6869

6970
injectConfigValues = (code: string) => {
7071
return code
@@ -79,6 +80,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
7980
.replace(`__HMR_BASE__`, hmrBaseReplacement)
8081
.replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement)
8182
.replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement)
83+
.replace(`__WS_TOKEN__`, wsTokenReplacement)
8284
}
8385
},
8486
transform(code, id, options) {

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

+74-9
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'
@@ -91,12 +92,34 @@ const wsServerEvents = [
9192
'message',
9293
]
9394

95+
// we only allow websockets to be connected if it has a valid token
96+
// this is to prevent untrusted origins to connect to the server
97+
// for example, Cross-site WebSocket hijacking
98+
//
99+
// we should check the token before calling wss.handleUpgrade
100+
// otherwise untrusted ws clients will be included in wss.clients
101+
//
102+
// using the query params means the token might be logged out in server or middleware logs
103+
// but we assume that is not an issue since the token is regenerated for each process
104+
function hasValidToken(config: ResolvedConfig, url: URL) {
105+
const token = url.searchParams.get('token')
106+
if (!token) return false
107+
108+
try {
109+
const isValidToken = crypto.timingSafeEqual(
110+
Buffer.from(token),
111+
Buffer.from(config.webSocketToken),
112+
)
113+
return isValidToken
114+
} catch {} // an error is thrown when the length is incorrect
115+
return false
116+
}
117+
94118
export function createWebSocketServer(
95119
server: Server | null,
96120
config: ResolvedConfig,
97121
httpsOptions?: HttpsServerOptions,
98122
): WebSocketServer {
99-
let wss: WebSocketServerRaw_
100123
let wsHttpServer: Server | undefined = undefined
101124

102125
const hmr = isObject(config.server.hmr) && config.server.hmr
@@ -115,21 +138,50 @@ export function createWebSocketServer(
115138
const port = hmrPort || 24678
116139
const host = (hmr && hmr.host) || undefined
117140

141+
const shouldHandle = (req: IncomingMessage) => {
142+
if (config.legacy?.skipWebSocketTokenCheck) {
143+
return true
144+
}
145+
146+
// If the Origin header is set, this request might be coming from a browser.
147+
// Browsers always sets the Origin header for WebSocket connections.
148+
if (req.headers.origin) {
149+
const parsedUrl = new URL(`http://example.com${req.url!}`)
150+
return hasValidToken(config, parsedUrl)
151+
}
152+
153+
// We allow non-browser requests to connect without a token
154+
// for backward compat and convenience
155+
// This is fine because if you can sent a request without the SOP limitation,
156+
// you can also send a normal HTTP request to the server.
157+
return true
158+
}
159+
const handleUpgrade = (
160+
req: IncomingMessage,
161+
socket: Duplex,
162+
head: Buffer,
163+
_isPing: boolean,
164+
) => {
165+
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
166+
wss.emit('connection', ws, req)
167+
})
168+
}
169+
const wss: WebSocketServerRaw_ = new WebSocketServerRaw({ noServer: true })
170+
wss.shouldHandle = shouldHandle
171+
118172
if (wsServer) {
119173
let hmrBase = config.base
120174
const hmrPath = hmr ? hmr.path : undefined
121175
if (hmrPath) {
122176
hmrBase = path.posix.join(hmrBase, hmrPath)
123177
}
124-
wss = new WebSocketServerRaw({ noServer: true })
125178
hmrServerWsListener = (req, socket, head) => {
179+
const parsedUrl = new URL(`http://example.com${req.url!}`)
126180
if (
127181
req.headers['sec-websocket-protocol'] === HMR_HEADER &&
128-
req.url === hmrBase
182+
parsedUrl.pathname === hmrBase
129183
) {
130-
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
131-
wss.emit('connection', ws, req)
132-
})
184+
handleUpgrade(req, socket as Socket, head, false)
133185
}
134186
}
135187
wsServer.on('upgrade', hmrServerWsListener)
@@ -153,9 +205,22 @@ export function createWebSocketServer(
153205
} else {
154206
wsHttpServer = createHttpServer(route)
155207
}
156-
// vite dev server in middleware mode
157-
// need to call ws listen manually
158-
wss = new WebSocketServerRaw({ server: wsHttpServer })
208+
wsHttpServer.on('upgrade', (req, socket, head) => {
209+
handleUpgrade(req, socket as Socket, head, false)
210+
})
211+
wsHttpServer.on('error', (e: Error & { code: string }) => {
212+
if (e.code === 'EADDRINUSE') {
213+
config.logger.error(
214+
colors.red(`WebSocket server error: Port is already in use`),
215+
{ error: e },
216+
)
217+
} else {
218+
config.logger.error(
219+
colors.red(`WebSocket server error:\n${e.stack || e.message}`),
220+
{ error: e },
221+
)
222+
}
223+
})
159224
}
160225

161226
wss.on('connection', (socket) => {

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
})

playground/fs-serve/package.json

+3
Original file line numberDiff line numberDiff line change
@@ -14,5 +14,8 @@
1414
"dev:deny": "vite root --config ./root/vite.config-deny.js",
1515
"build:deny": "vite build root --config ./root/vite.config-deny.js",
1616
"preview:deny": "vite preview root --config ./root/vite.config-deny.js"
17+
},
18+
"devDependencies": {
19+
"ws": "^8.18.0"
1720
}
1821
}

pnpm-lock.yaml

+18-1
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)