Skip to content

Commit b71a5c8

Browse files
committed
fix: verify token for HMR WebSocket connection
1 parent dfea38f commit b71a5c8

File tree

7 files changed

+210
-12
lines changed

7 files changed

+210
-12
lines changed

Diff for: packages/vite/src/client/client.ts

+6-1
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ declare const __HMR_DIRECT_TARGET__: string
1515
declare const __HMR_BASE__: string
1616
declare const __HMR_TIMEOUT__: number
1717
declare const __HMR_ENABLE_OVERLAY__: boolean
18+
declare const __WS_TOKEN__: string
1819

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

@@ -30,6 +31,7 @@ const socketHost = `${__HMR_HOSTNAME__ || importMetaUrl.hostname}:${
3031
}${__HMR_BASE__}`
3132
const directSocketHost = __HMR_DIRECT_TARGET__
3233
const base = __BASE__ || '/'
34+
const wsToken = __WS_TOKEN__
3335

3436
let socket: WebSocket
3537
try {
@@ -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(

Diff for: 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'
@@ -341,6 +342,18 @@ export interface LegacyOptions {
341342
* https://github.com/vitejs/vite/discussions/14697.
342343
*/
343344
proxySsrExternalModules?: boolean
345+
/**
346+
* In Vite 6.0.8 / 5.4.11 and below, WebSocket server was able to connect from any web pages. However,
347+
* that could be exploited by a malicious web page.
348+
*
349+
* In Vite 6.0.9+ / 5.4.12+, the WebSocket server now requires a token to connect from a web page.
350+
* But this may break some plugins and frameworks that connects to the WebSocket server
351+
* on their own. Enabling this option will make Vite skip the token check.
352+
*
353+
* **We do not recommend enabling this option unless you are sure that you are fine with
354+
* that security weakness.**
355+
*/
356+
skipWebSocketTokenCheck?: boolean
344357
}
345358

346359
export interface ResolvedWorkerOptions {
@@ -400,6 +413,17 @@ export type ResolvedConfig = Readonly<
400413
worker: ResolvedWorkerOptions
401414
appType: AppType
402415
experimental: ExperimentalOptions
416+
/**
417+
* The token to connect to the WebSocket server from browsers.
418+
*
419+
* We recommend using `import.meta.hot` rather than connecting
420+
* to the WebSocket server directly.
421+
* If you have a usecase that requires connecting to the WebSocket
422+
* server, please create an issue so that we can discuss.
423+
*
424+
* @deprecated
425+
*/
426+
webSocketToken: string
403427
} & PluginHookUtils
404428
>
405429

@@ -828,6 +852,12 @@ export async function resolveConfig(
828852
hmrPartialAccept: false,
829853
...config.experimental,
830854
},
855+
// random 72 bits (12 base64 chars)
856+
// at least 64bits is recommended
857+
// https://owasp.org/www-community/vulnerabilities/Insufficient_Session-ID_Length
858+
webSocketToken: Buffer.from(
859+
crypto.getRandomValues(new Uint8Array(9)),
860+
).toString('base64url'),
831861
getSortedPlugins: undefined!,
832862
getSortedPluginHooks: undefined!,
833863
}

Diff for: packages/vite/src/node/plugins/clientInjections.ts

+2
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
7272
const hmrTimeoutReplacement = escapeReplacement(timeout)
7373
const hmrEnableOverlayReplacement = escapeReplacement(overlay)
7474
const hmrConfigNameReplacement = escapeReplacement(hmrConfigName)
75+
const wsTokenReplacement = escapeReplacement(config.webSocketToken)
7576

7677
injectConfigValues = (code: string) => {
7778
return code
@@ -87,6 +88,7 @@ export function clientInjectionsPlugin(config: ResolvedConfig): Plugin {
8788
.replace(`__HMR_TIMEOUT__`, hmrTimeoutReplacement)
8889
.replace(`__HMR_ENABLE_OVERLAY__`, hmrEnableOverlayReplacement)
8990
.replace(`__HMR_CONFIG_NAME__`, hmrConfigNameReplacement)
91+
.replace(`__WS_TOKEN__`, wsTokenReplacement)
9092
}
9193
},
9294
async transform(code, id, options) {

Diff for: 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'
@@ -89,6 +90,29 @@ function noop() {
8990
// noop
9091
}
9192

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

113-
let wss: WebSocketServerRaw_
114137
let wsHttpServer: Server | undefined = undefined
115138

116139
const hmr = isObject(config.server.hmr) && config.server.hmr
@@ -129,21 +152,50 @@ export function createWebSocketServer(
129152
const port = hmrPort || 24678
130153
const host = (hmr && hmr.host) || undefined
131154

155+
const shouldHandle = (req: IncomingMessage) => {
156+
if (config.legacy?.skipWebSocketTokenCheck) {
157+
return true
158+
}
159+
160+
// If the Origin header is set, this request might be coming from a browser.
161+
// Browsers always sets the Origin header for WebSocket connections.
162+
if (req.headers.origin) {
163+
const parsedUrl = new URL(`http://example.com${req.url!}`)
164+
return hasValidToken(config, parsedUrl)
165+
}
166+
167+
// We allow non-browser requests to connect without a token
168+
// for backward compat and convenience
169+
// This is fine because if you can sent a request without the SOP limitation,
170+
// you can also send a normal HTTP request to the server.
171+
return true
172+
}
173+
const handleUpgrade = (
174+
req: IncomingMessage,
175+
socket: Duplex,
176+
head: Buffer,
177+
_isPing: boolean,
178+
) => {
179+
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
180+
wss.emit('connection', ws, req)
181+
})
182+
}
183+
const wss: WebSocketServerRaw_ = new WebSocketServerRaw({ noServer: true })
184+
wss.shouldHandle = shouldHandle
185+
132186
if (wsServer) {
133187
let hmrBase = config.base
134188
const hmrPath = hmr ? hmr.path : undefined
135189
if (hmrPath) {
136190
hmrBase = path.posix.join(hmrBase, hmrPath)
137191
}
138-
wss = new WebSocketServerRaw({ noServer: true })
139192
hmrServerWsListener = (req, socket, head) => {
193+
const parsedUrl = new URL(`http://example.com${req.url!}`)
140194
if (
141195
req.headers['sec-websocket-protocol'] === HMR_HEADER &&
142-
req.url === hmrBase
196+
parsedUrl.pathname === hmrBase
143197
) {
144-
wss.handleUpgrade(req, socket as Socket, head, (ws) => {
145-
wss.emit('connection', ws, req)
146-
})
198+
handleUpgrade(req, socket as Socket, head, false)
147199
}
148200
}
149201
wsServer.on('upgrade', hmrServerWsListener)
@@ -167,9 +219,22 @@ export function createWebSocketServer(
167219
} else {
168220
wsHttpServer = createHttpServer(route)
169221
}
170-
// vite dev server in middleware mode
171-
// need to call ws listen manually
172-
wss = new WebSocketServerRaw({ server: wsHttpServer })
222+
wsHttpServer.on('upgrade', (req, socket, head) => {
223+
handleUpgrade(req, socket as Socket, head, false)
224+
})
225+
wsHttpServer.on('error', (e: Error & { code: string }) => {
226+
if (e.code === 'EADDRINUSE') {
227+
config.logger.error(
228+
colors.red(`WebSocket server error: Port is already in use`),
229+
{ error: e },
230+
)
231+
} else {
232+
config.logger.error(
233+
colors.red(`WebSocket server error:\n${e.stack || e.message}`),
234+
{ error: e },
235+
)
236+
}
237+
})
173238
}
174239

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

Diff for: 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
})

Diff for: 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
}

Diff for: pnpm-lock.yaml

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

0 commit comments

Comments
 (0)