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(typeofsocketPath==='string'&&socketPath){
110110
return{pid: dev.pid, socketPath }
111111
}
112-
if(port){
112+
if(typeofport==='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+
constLOCAL_HOSTS=newSet(['','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+
functionisLocalHost(host: string|undefined): boolean{
126+
returnLOCAL_HOSTS.has(host??'')
127+
}
128+
118129
exportfunctionnoDevServerMessage(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
constMAX_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+
constMAX_LOCK_CLOCK_SKEW_MS=5*60*1000
3943

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

120124
functionreadLockFile(lockPath: string): LockInfo|undefined{
121125
try{
122-
returnJSON.parse(readFileSync(lockPath,'utf-8'))asLockInfo
126+
returnparseLockInfo(JSON.parse(readFileSync(lockPath,'utf-8')))
123127
}
124128
catch{
125129
returnundefined
126130
}
127131
}
128132

133+
constLOCK_COMMANDS=newSet<LockInfo['command']>(['dev','build','analyze'])
134+
constMAX_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+
constCONTROL_CHARS_RE=/[\u0000-\u001F\u007F-\u009F]/g
139+
constLOCK_HOSTNAME_RE=/^[\w.:[\]-]{1,253}$/
140+
141+
functionlockPid(value: unknown): number|undefined{
142+
returntypeofvalue==='number'&&Number.isInteger(value)&&value>0&&value<=2**31-1
143+
? value
144+
: undefined
145+
}
146+
147+
functionlockText(value: unknown): string|undefined{
148+
returntypeofvalue==='string'
149+
? value.slice(0,MAX_LOCK_STRING_LENGTH).replace(CONTROL_CHARS_RE,'')
150+
: undefined
151+
}
152+
153+
functionlockURL(value: unknown): string|undefined{
154+
consttext=lockText(value)
155+
if(!text){
156+
returnundefined
157+
}
158+
try{
159+
consturl=newURL(text)
160+
returnurl.protocol==='http:'||url.protocol==='https:' ? text : undefined
161+
}
162+
catch{
163+
returnundefined
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+
exportfunctionparseLockInfo(raw: unknown): LockInfo|undefined{
177+
if(!raw||typeofraw!=='object'||Array.isArray(raw)){
178+
returnundefined
179+
}
180+
constinput=rawasRecord<string,unknown>
181+
182+
constpid=lockPid(input.pid)
183+
constcommand=input.commandasLockInfo['command']
184+
conststartedAt=input.startedAt
185+
if(!pid||!LOCK_COMMANDS.has(command)||typeofstartedAt!=='number'||!Number.isFinite(startedAt)){
186+
returnundefined
187+
}
188+
if(startedAt>Date.now()+MAX_LOCK_CLOCK_SKEW_MS){
189+
returnundefined
190+
}
191+
192+
constport=input.port
193+
consthostname=lockText(input.hostname)
194+
constparentPid=lockPid(input.parentPid)
195+
consttakenOverBy=lockPid(input.takenOverBy)
196+
197+
return{
198+
pid,
199+
startedAt,
200+
command,
201+
cwd: lockText(input.cwd)??'',
202+
interactive: input.interactive===true,
203+
...typeofport==='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+
importprocessfrom'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+
lettempDir: string
13+
14+
beforeEach(async()=>{
15+
tempDir=awaitmkdtemp(join(tmpdir(),'nuxt-untrusted-lock-'))
16+
deleteprocess.env.NUXT_IGNORE_LOCK
17+
deleteprocess.env.NUXT_LOCK
18+
})
19+
20+
afterEach(async()=>{
21+
vi.restoreAllMocks()
22+
awaitrm(tempDir,{recursive: true,force: true})
23+
})
24+
25+
functionbaseLock(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+
asyncfunctionwriteLock(contents: unknown): Promise<string>{
37+
awaitwriteFile(join(tempDir,'nuxt.lock'),typeofcontents==='string' ? contents : JSON.stringify(contents))
38+
returntempDir
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+
constinfo=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(awaitwriteLock('}{'))).toBeUndefined()
95+
})
96+
97+
it('should ignore a lock claiming a negative pid',async()=>{
98+
expect(readLock(awaitwriteLock(baseLock({pid: -1,port: 3000})))).toBeUndefined()
99+
})
100+
101+
it('should never signal a process group during takeover',async()=>{
102+
constkill=vi.spyOn(process,'kill')
103+
constresult=awaittakeOverDevServer(
104+
awaitwriteLock(baseLock({pid: -1,port: 3000,url: 'http://localhost:3000'})),
105+
{takeover: true},
106+
)
107+
108+
expect(result.action).toBe('none')
109+
for(constcallofkill.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+
constdir=awaitwriteLock(baseLock({pid: process.ppid,url: 'file:///etc/passwd'}))
116+
awaitexpect(findDevServer(dir,dir)).resolves.toBeUndefined()
117+
})
118+
119+
it('should resolve a dev server recorded with an http url',async()=>{
120+
constdir=awaitwriteLock(baseLock({pid: process.ppid,url: 'http://localhost:3000'}))
121+
awaitexpect(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+
awaitwriteFile(join(tempDir,'nitro.json'),JSON.stringify({
128+
dev: {pid: process.pid,workerAddress: {host: 'evil.example.com',port: 80}},
129+
}))
130+
131+
awaitexpect(findNitroDevWorker(tempDir,tempDir)).resolves.toBeUndefined()
132+
})
133+
134+
it('should accept a loopback worker address',async()=>{
135+
awaitwriteFile(join(tempDir,'nitro.json'),JSON.stringify({
136+
dev: {pid: process.pid,workerAddress: {host: '127.0.0.1',port: 3000}},
137+
}))
138+
139+
awaitexpect(findNitroDevWorker(tempDir,tempDir)).resolves.toMatchObject({url: 'http://127.0.0.1:3000'})
140+
})
141+
})

0 commit comments

Comments
 (0)