Skip to content

Commit 53c42f1

Browse files
committed
fix(dev): validate lock and worker metadata before trusting it
1 parent f1a664b commit 53c42f1

3 files changed

Lines changed: 237 additions & 3 deletions

File tree

packages/nuxt-cli/src/utils/dev-server.ts

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,15 +106,26 @@ export async function findNitroDevWorker(cwd: string, buildDir?: string): Promis
106106
}
107107

108108
const { socketPath, host, port } = dev.workerAddress
109-
if (socketPath) {
109+
if (typeof socketPath === 'string' && socketPath) {
110110
return { pid: dev.pid, socketPath }
111111
}
112-
if (port) {
112+
if (typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65_535 && isLocalHost(host)) {
113113
return { pid: dev.pid, url: `http://${host || 'localhost'}:${port}` }
114114
}
115115
}
116116
}
117117

118+
const LOCAL_HOSTS = new Set(['', 'localhost', '127.0.0.1', '::1', '[::1]', '0.0.0.0', '::', '[::]'])
119+
120+
/**
121+
* Whether an address recorded by Nitro belongs to this machine. The worker is
122+
* always local, and the file naming it lives in the project, so an address
123+
* pointing anywhere else is not one we should send task payloads to.
124+
*/
125+
function isLocalHost(host: string | undefined): boolean {
126+
return LOCAL_HOSTS.has(host ?? '')
127+
}
128+
118129
export function noDevServerMessage(what: string): string {
119130
return `No running Nuxt dev server found. Start one with ${styleText('cyan', 'nuxt dev')}, or pass an absolute URL to ${styleText('cyan', what)}.`
120131
}

packages/nuxt-cli/src/utils/lockfile.ts

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,10 @@ const OUTPUT_LOCK_DIRNAME = 'node_modules/.cache/nuxt'
3636
// PID recycling safety net. Locks older than this cannot be trusted because a
3737
// recycled PID could match a dead build's record.
3838
const MAX_LOCK_AGE_MS = 24 * 60 * 60 * 1000
39+
// A lock written by a machine whose clock runs slightly ahead of ours is still
40+
// plausible; anything further into the future is not, and would otherwise keep
41+
// `isLockActive` true indefinitely.
42+
const MAX_LOCK_CLOCK_SKEW_MS = 5 * 60 * 1000
3943

4044
export function isProcessAlive(pid: number): boolean {
4145
try {
@@ -119,13 +123,91 @@ export function getTakeoverPid(buildDir: string): number | undefined {
119123

120124
function readLockFile(lockPath: string): LockInfo | undefined {
121125
try {
122-
return JSON.parse(readFileSync(lockPath, 'utf-8')) as LockInfo
126+
return parseLockInfo(JSON.parse(readFileSync(lockPath, 'utf-8')))
123127
}
124128
catch {
125129
return undefined
126130
}
127131
}
128132

133+
const LOCK_COMMANDS = new Set<LockInfo['command']>(['dev', 'build', 'analyze'])
134+
const MAX_LOCK_STRING_LENGTH = 1024
135+
// C0 and C1 control characters, which would otherwise reach the terminal when a
136+
// lock is described to the user.
137+
// eslint-disable-next-line no-control-regex
138+
const CONTROL_CHARS_RE = /[\u0000-\u001F\u007F-\u009F]/g
139+
const LOCK_HOSTNAME_RE = /^[\w.:[\]-]{1,253}$/
140+
141+
function lockPid(value: unknown): number | undefined {
142+
return typeof value === 'number' && Number.isInteger(value) && value > 0 && value <= 2 ** 31 - 1
143+
? value
144+
: undefined
145+
}
146+
147+
function lockText(value: unknown): string | undefined {
148+
return typeof value === 'string'
149+
? value.slice(0, MAX_LOCK_STRING_LENGTH).replace(CONTROL_CHARS_RE, '')
150+
: undefined
151+
}
152+
153+
function lockURL(value: unknown): string | undefined {
154+
const text = lockText(value)
155+
if (!text) {
156+
return undefined
157+
}
158+
try {
159+
const url = new URL(text)
160+
return url.protocol === 'http:' || url.protocol === 'https:' ? text : undefined
161+
}
162+
catch {
163+
return undefined
164+
}
165+
}
166+
167+
/**
168+
* Validate a parsed `nuxt.lock` document.
169+
*
170+
* A lock lives in the build directory, so it can arrive with a cloned project
171+
* and is read before any of that project's code runs. Everything taken from it
172+
* is either signalled, connected to, or printed, so a record that does not have
173+
* the exact shape written by {@link acquireLock} is discarded rather than
174+
* repaired.
175+
*/
176+
export function parseLockInfo(raw: unknown): LockInfo | undefined {
177+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
178+
return undefined
179+
}
180+
const input = raw as Record<string, unknown>
181+
182+
const pid = lockPid(input.pid)
183+
const command = input.command as LockInfo['command']
184+
const startedAt = input.startedAt
185+
if (!pid || !LOCK_COMMANDS.has(command) || typeof startedAt !== 'number' || !Number.isFinite(startedAt)) {
186+
return undefined
187+
}
188+
if (startedAt > Date.now() + MAX_LOCK_CLOCK_SKEW_MS) {
189+
return undefined
190+
}
191+
192+
const port = input.port
193+
const hostname = lockText(input.hostname)
194+
const parentPid = lockPid(input.parentPid)
195+
const takenOverBy = lockPid(input.takenOverBy)
196+
197+
return {
198+
pid,
199+
startedAt,
200+
command,
201+
cwd: lockText(input.cwd) ?? '',
202+
interactive: input.interactive === true,
203+
...typeof port === 'number' && Number.isInteger(port) && port > 0 && port <= 65_535 ? { port } : {},
204+
...hostname && LOCK_HOSTNAME_RE.test(hostname) ? { hostname } : {},
205+
...lockURL(input.url) ? { url: lockURL(input.url) } : {},
206+
...parentPid ? { parentPid } : {},
207+
...takenOverBy ? { takenOverBy } : {},
208+
}
209+
}
210+
129211
/**
130212
* Replace a lock we own. Writing a sibling temp file and renaming it into place
131213
* keeps the window where a reader could see a truncated file from existing: a
Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
2+
import { tmpdir } from 'node:os'
3+
import { join } from 'node:path'
4+
import process from 'node:process'
5+
6+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
7+
8+
import { takeOverDevServer } from '../../../src/dev/takeover'
9+
import { findDevServer, findNitroDevWorker } from '../../../src/utils/dev-server'
10+
import { parseLockInfo, readLock } from '../../../src/utils/lockfile'
11+
12+
let tempDir: string
13+
14+
beforeEach(async () => {
15+
tempDir = await mkdtemp(join(tmpdir(), 'nuxt-untrusted-lock-'))
16+
delete process.env.NUXT_IGNORE_LOCK
17+
delete process.env.NUXT_LOCK
18+
})
19+
20+
afterEach(async () => {
21+
vi.restoreAllMocks()
22+
await rm(tempDir, { recursive: true, force: true })
23+
})
24+
25+
function baseLock(overrides: Record<string, unknown> = {}) {
26+
return {
27+
pid: process.pid,
28+
startedAt: Date.now(),
29+
command: 'dev',
30+
cwd: '/tmp/project',
31+
interactive: false,
32+
...overrides,
33+
}
34+
}
35+
36+
async function writeLock(contents: unknown): Promise<string> {
37+
await writeFile(join(tempDir, 'nuxt.lock'), typeof contents === 'string' ? contents : JSON.stringify(contents))
38+
return tempDir
39+
}
40+
41+
describe('parseLockInfo', () => {
42+
it('should reject a lock with a non-positive pid', () => {
43+
expect(parseLockInfo(baseLock({ pid: -1 }))).toBeUndefined()
44+
expect(parseLockInfo(baseLock({ pid: 0 }))).toBeUndefined()
45+
expect(parseLockInfo(baseLock({ pid: 1.5 }))).toBeUndefined()
46+
expect(parseLockInfo(baseLock({ pid: '123' }))).toBeUndefined()
47+
})
48+
49+
it('should reject a lock with an unknown command', () => {
50+
expect(parseLockInfo(baseLock({ command: 'rm -rf /' }))).toBeUndefined()
51+
})
52+
53+
it('should reject anything that is not an object', () => {
54+
expect(parseLockInfo(null)).toBeUndefined()
55+
expect(parseLockInfo([baseLock()])).toBeUndefined()
56+
expect(parseLockInfo('dev')).toBeUndefined()
57+
})
58+
59+
it('should drop a negative parent pid rather than the whole lock', () => {
60+
expect(parseLockInfo(baseLock({ parentPid: -1 }))?.parentPid).toBeUndefined()
61+
expect(parseLockInfo(baseLock({ takenOverBy: -1 }))?.takenOverBy).toBeUndefined()
62+
})
63+
64+
it('should drop an out-of-range port', () => {
65+
expect(parseLockInfo(baseLock({ port: 0 }))?.port).toBeUndefined()
66+
expect(parseLockInfo(baseLock({ port: 70_000 }))?.port).toBeUndefined()
67+
expect(parseLockInfo(baseLock({ port: 3000 }))?.port).toBe(3000)
68+
})
69+
70+
it('should drop a url that is not http or https', () => {
71+
expect(parseLockInfo(baseLock({ url: 'file:///etc/passwd' }))?.url).toBeUndefined()
72+
expect(parseLockInfo(baseLock({ url: 'not a url' }))?.url).toBeUndefined()
73+
expect(parseLockInfo(baseLock({ url: 'http://localhost:3000' }))?.url).toBe('http://localhost:3000')
74+
})
75+
76+
it('should strip control characters from displayed strings', () => {
77+
const info = parseLockInfo(baseLock({ cwd: '/tmp/\u001B[2Jproject\u0007' }))
78+
expect(info?.cwd).toBe('/tmp/[2Jproject')
79+
})
80+
81+
it('should reject a lock timestamped far into the future', () => {
82+
expect(parseLockInfo(baseLock({ startedAt: Number.MAX_VALUE }))).toBeUndefined()
83+
expect(parseLockInfo(baseLock({ startedAt: Date.now() + 60 * 60 * 1000 }))).toBeUndefined()
84+
expect(parseLockInfo(baseLock({ startedAt: Date.now() + 1000 }))).toBeDefined()
85+
})
86+
87+
it('should cap the length of strings it keeps', () => {
88+
expect(parseLockInfo(baseLock({ cwd: 'a'.repeat(5000) }))?.cwd).toHaveLength(1024)
89+
})
90+
})
91+
92+
describe('reading an untrusted lock', () => {
93+
it('should ignore a lock file that is not valid json', async () => {
94+
expect(readLock(await writeLock('}{'))).toBeUndefined()
95+
})
96+
97+
it('should ignore a lock claiming a negative pid', async () => {
98+
expect(readLock(await writeLock(baseLock({ pid: -1, port: 3000 })))).toBeUndefined()
99+
})
100+
101+
it('should never signal a process group during takeover', async () => {
102+
const kill = vi.spyOn(process, 'kill')
103+
const result = await takeOverDevServer(
104+
await writeLock(baseLock({ pid: -1, port: 3000, url: 'http://localhost:3000' })),
105+
{ takeover: true },
106+
)
107+
108+
expect(result.action).toBe('none')
109+
for (const call of kill.mock.calls) {
110+
expect(call[0]).toBeGreaterThan(0)
111+
}
112+
})
113+
114+
it('should not resolve a dev server url pointing at another scheme', async () => {
115+
const dir = await writeLock(baseLock({ pid: process.ppid, url: 'file:///etc/passwd' }))
116+
await expect(findDevServer(dir, dir)).resolves.toBeUndefined()
117+
})
118+
119+
it('should resolve a dev server recorded with an http url', async () => {
120+
const dir = await writeLock(baseLock({ pid: process.ppid, url: 'http://localhost:3000' }))
121+
await expect(findDevServer(dir, dir)).resolves.toMatchObject({ url: 'http://localhost:3000' })
122+
})
123+
})
124+
125+
describe('findNitroDevWorker', () => {
126+
it('should ignore a worker address on a remote host', async () => {
127+
await writeFile(join(tempDir, 'nitro.json'), JSON.stringify({
128+
dev: { pid: process.pid, workerAddress: { host: 'evil.example.com', port: 80 } },
129+
}))
130+
131+
await expect(findNitroDevWorker(tempDir, tempDir)).resolves.toBeUndefined()
132+
})
133+
134+
it('should accept a loopback worker address', async () => {
135+
await writeFile(join(tempDir, 'nitro.json'), JSON.stringify({
136+
dev: { pid: process.pid, workerAddress: { host: '127.0.0.1', port: 3000 } },
137+
}))
138+
139+
await expect(findNitroDevWorker(tempDir, tempDir)).resolves.toMatchObject({ url: 'http://127.0.0.1:3000' })
140+
})
141+
})

0 commit comments

Comments
 (0)