Skip to content

Commit c293bf9

Browse files
committed
fix(vite): bind vite-node IPC to a permissioned filesystem socket
(cherry picked from commit d2ea350bd359cc8f07e1d0b968c136b9e9a5cf48) Refs: GHSA-534h-c3cw-v3h9
1 parent 6497d99 commit c293bf9

2 files changed

Lines changed: 141 additions & 45 deletions

File tree

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

Lines changed: 60 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
import process from 'node:process'
2-
import { unlink } from 'node:fs/promises'
2+
import { rm } from 'node:fs/promises'
33
import type { Socket } from 'node:net'
44
import net from 'node:net'
55
import os from 'node:os'
66
import fs from 'node:fs' // For sync operations like unlinkSync if needed during setup
77
import { pathToFileURL } from 'node:url'
88
import { Buffer } from 'node:buffer'
9-
import { isAbsolute, join, normalize } from 'pathe'
9+
import { dirname, isAbsolute, join, normalize } from 'pathe'
1010
import { directoryToURL, resolveAlias, tryUseNuxt, useNitro } from '@nuxt/kit'
1111
import type { EnvironmentModuleNode, ModuleNode, PluginContainer, ViteDevServer, Plugin as VitePlugin } from 'vite'
1212
import { getQuery } from 'ufo'
@@ -15,7 +15,6 @@ import { ViteNodeServer } from 'vite-node/server'
1515
import { normalizeViteManifest } from 'vue-bundle-renderer'
1616
import type { Manifest } from 'vue-bundle-renderer'
1717
import type { Nuxt } from '@nuxt/schema'
18-
import { provider } from 'std-env'
1918
import { resolveModulePath } from 'exsolve'
2019

2120
import { isCSS } from '../utils/index.ts'
@@ -125,34 +124,28 @@ function getManifest (nuxt: Nuxt, viteServer: ViteDevServer, clientEntry: string
125124
return manifest
126125
}
127126

128-
function generateSocketPath () {
127+
export interface SocketPathInfo {
128+
socketPath: string
129+
/** mkdtemp directory we own; cleaned up on close. Undefined for Windows pipes. */
130+
parentDir?: string
131+
}
132+
133+
// only exported for tests
134+
export function pickSocketPath (platform: NodeJS.Platform): SocketPathInfo {
129135
const uniqueSuffix = `${process.pid}-${Date.now()}`
130136
const socketName = `nuxt-vite-node-${uniqueSuffix}`
131137

132-
// Windows: pipe
133-
if (process.platform === 'win32') {
134-
return join(String.raw`\\.\pipe`, socketName)
138+
if (platform === 'win32') {
139+
return { socketPath: join(String.raw`\\.\pipe`, socketName) }
135140
}
136-
// Linux: abstract namespace
137-
if (process.platform === 'linux') {
138-
const nodeMajor = Number.parseInt(process.versions.node.split('.')[0]!, 10)
139-
if (nodeMajor >= 20 && provider !== 'stackblitz') {
140-
// We avoid abstract sockets in Docker due to performance issues
141-
let isDocker = false
142-
143-
try {
144-
isDocker = fs.existsSync('/.dockerenv') || (fs.existsSync('/proc/1/cgroup') && fs.readFileSync('/proc/1/cgroup', 'utf8').includes('docker'))
145-
} catch {
146-
// Ignore errors checking Docker status
147-
}
141+
// place the socket inside a freshly-created 0700 directory to gate access.
142+
const parentDir = fs.mkdtempSync(join(os.tmpdir(), 'nuxt-vite-node-'))
143+
fs.chmodSync(parentDir, 0o700)
144+
return { socketPath: join(parentDir, `${socketName}.sock`), parentDir }
145+
}
148146

149-
if (!isDocker) {
150-
return `\0${socketName}.sock`
151-
}
152-
}
153-
}
154-
// Unix socket
155-
return join(os.tmpdir(), `${socketName}.sock`)
147+
function generateSocketPath (): SocketPathInfo {
148+
return pickSocketPath(process.platform)
156149
}
157150

158151
function useInvalidates () {
@@ -186,18 +179,21 @@ export function ViteNodePlugin (nuxt: Nuxt): VitePlugin | undefined {
186179
}
187180

188181
let socketServer: net.Server | undefined
189-
const socketPath = generateSocketPath()
182+
const { socketPath, parentDir } = generateSocketPath()
190183
const { invalidates, markInvalidate, markInvalidates } = useInvalidates()
191184

185+
let cleanedUp = false
192186
async function cleanupSocket () {
187+
if (cleanedUp) { return }
188+
cleanedUp = true
193189
if (socketServer && socketServer.listening) {
194190
await new Promise<void>(resolveClose => socketServer!.close(() => resolveClose()))
195191
}
196-
if (socketPath && !socketPath.startsWith('\\\\.\\pipe\\')) {
192+
if (parentDir) {
197193
try {
198-
await unlink(socketPath)
194+
await rm(parentDir, { recursive: true, force: true })
199195
} catch {
200-
// Error is ignored if the file doesn't exist or cannot be unlinked
196+
// mkdtemp directory cleanup is best-effort
201197
}
202198
}
203199
}
@@ -473,26 +469,45 @@ function createViteNodeSocketServer (nuxt: Nuxt, ssrServer: ViteDevServer, clien
473469
throw new Error('Socket path not configured for ViteNodeSocketServer.')
474470
}
475471

476-
// Clean up existing socket file (Unix only)
477-
if (!currentSocketPath.startsWith('\\\\.\\pipe\\')) {
478-
try {
479-
fs.unlinkSync(currentSocketPath)
480-
} catch (unlinkError: any) {
481-
if (unlinkError.code !== 'ENOENT') {
482-
// Socket cleanup failed, but continue anyway
483-
}
484-
}
485-
}
472+
listenAndRestrict(server, currentSocketPath)
486473

487-
server.listen(currentSocketPath)
488-
489-
server.on('error', () => {
490-
// Server error - will be handled by calling code
491-
})
474+
server.on('error', () => {})
492475

493476
return server
494477
}
495478

479+
export function listenAndRestrict (server: net.Server, socketPath: string): void {
480+
const isWindowsPipe = socketPath.startsWith('\\\\.\\pipe\\')
481+
if (isWindowsPipe) {
482+
server.listen(socketPath)
483+
return
484+
}
485+
// 1. the 0700 parent directory (created by pickSocketPath) is the access-control
486+
// boundary, since AF_UNIX connect requires +x on every parent.
487+
// 2. The post-listen chmod 0600 protects the socket file itself.
488+
// 3. The umask wrap tightens the in-process window between bind(2) and the chmod,
489+
// where the file would otherwise briefly exist with the default umask
490+
// and be observable to other code.
491+
const previousUmask = process.umask(0o077)
492+
try {
493+
server.listen(socketPath, () => {
494+
try {
495+
fs.chmodSync(socketPath, 0o600)
496+
} catch (error) {
497+
console.error('[nuxt] Failed to restrict vite-node socket permissions; closing.', error)
498+
server.close()
499+
try {
500+
fs.rmSync(dirname(socketPath), { recursive: true, force: true })
501+
} catch {
502+
// mkdtemp directory cleanup is best-effort
503+
}
504+
}
505+
})
506+
} finally {
507+
process.umask(previousUmask)
508+
}
509+
}
510+
496511
function sendResponse<T extends keyof ViteNodeRequestMap> (
497512
socket: net.Socket,
498513
id: number,
Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import fs from 'node:fs'
2+
import net from 'node:net'
3+
import os from 'node:os'
4+
import process from 'node:process'
5+
import { afterEach, describe, expect, it } from 'vitest'
6+
import { listenAndRestrict, pickSocketPath } from '../src/plugins/vite-node.ts'
7+
8+
const createdDirs: string[] = []
9+
10+
function trackedPick (platform: NodeJS.Platform = process.platform) {
11+
const info = pickSocketPath(platform)
12+
if (info.parentDir) {
13+
createdDirs.push(info.parentDir)
14+
}
15+
return info
16+
}
17+
18+
afterEach(() => {
19+
while (createdDirs.length) {
20+
const dir = createdDirs.pop()!
21+
fs.rmSync(dir, { recursive: true, force: true })
22+
}
23+
})
24+
25+
const realTmpdir = fs.realpathSync(os.tmpdir())
26+
27+
describe('pickSocketPath', () => {
28+
it('uses a filesystem socket on Linux, not an abstract namespace socket', () => {
29+
const { socketPath, parentDir } = trackedPick('linux')
30+
expect(socketPath.startsWith('\0')).toBe(false)
31+
expect(parentDir).toBeDefined()
32+
expect(fs.realpathSync(parentDir!).startsWith(realTmpdir)).toBe(true)
33+
})
34+
35+
it('uses a filesystem socket on macOS', () => {
36+
const { socketPath, parentDir } = trackedPick('darwin')
37+
expect(socketPath.startsWith('\0')).toBe(false)
38+
expect(parentDir).toBeDefined()
39+
expect(fs.realpathSync(parentDir!).startsWith(realTmpdir)).toBe(true)
40+
})
41+
42+
it('uses a named pipe on Windows', () => {
43+
const { socketPath, parentDir } = pickSocketPath('win32')
44+
expect(socketPath.startsWith('\0')).toBe(false)
45+
expect(socketPath).toContain('/pipe/')
46+
expect(parentDir).toBeUndefined()
47+
})
48+
49+
it('places the socket in a 0700 parent directory on Unix', { skip: process.platform === 'win32' }, () => {
50+
const { parentDir } = trackedPick()
51+
const stat = fs.statSync(parentDir!)
52+
expect(stat.mode & 0o777).toBe(0o700)
53+
})
54+
})
55+
56+
describe('listenAndRestrict', () => {
57+
it.runIf(process.platform !== 'win32')('binds a socket with mode 0600', async () => {
58+
const { socketPath } = trackedPick()
59+
const server = net.createServer()
60+
await new Promise<void>((resolve, reject) => {
61+
server.once('listening', () => resolve())
62+
server.once('error', reject)
63+
listenAndRestrict(server, socketPath)
64+
})
65+
try {
66+
const stat = fs.statSync(socketPath)
67+
expect(stat.mode & 0o777).toBe(0o600)
68+
} finally {
69+
await new Promise<void>(resolve => server.close(() => resolve()))
70+
}
71+
})
72+
73+
it.runIf(process.platform !== 'win32')('binds with no group/other permission bits before the listening callback fires', () => {
74+
const { socketPath } = trackedPick()
75+
const server = net.createServer()
76+
listenAndRestrict(server, socketPath)
77+
const stat = fs.statSync(socketPath)
78+
expect(stat.mode & 0o077).toBe(0)
79+
server.close()
80+
})
81+
})

0 commit comments

Comments
 (0)