Skip to content

Commit a1ab73d

Browse files
committed
fix(cli): validate user input before running commands
1 parent 446d169 commit a1ab73d

4 files changed

Lines changed: 243 additions & 10 deletions

File tree

cli/package.json

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,10 +29,12 @@
2929
"h3": "^1.15.5",
3030
"listhen": "^1.9.0",
3131
"ofetch": "^1.5.1",
32-
"picocolors": "^1.1.1"
32+
"picocolors": "^1.1.1",
33+
"validate-npm-package-name": "^7.0.2"
3334
},
3435
"devDependencies": {
3536
"@types/node": "^24.10.9",
37+
"@types/validate-npm-package-name": "^4.0.2",
3638
"typescript": "^5.9.3",
3739
"unbuild": "^3.6.1"
3840
}

cli/src/npm-client.ts

Lines changed: 91 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,68 @@
11
import process from 'node:process'
2-
import { exec } from 'node:child_process'
2+
import { execFile } from 'node:child_process'
33
import { promisify } from 'node:util'
4+
import validateNpmPackageName from 'validate-npm-package-name'
45
import { logCommand, logSuccess, logError } from './logger.ts'
56

6-
const execAsync = promisify(exec)
7+
const execFileAsync = promisify(execFile)
8+
9+
// Validation pattern for npm usernames/org names
10+
// These follow similar rules: lowercase alphanumeric with hyphens, can't start/end with hyphen
11+
const NPM_USERNAME_RE = /^[a-z0-9]([a-z0-9-]*[a-z0-9])?$/i
12+
13+
/**
14+
* Validates an npm package name using the official npm validation package
15+
* @throws Error if the name is invalid
16+
*/
17+
export function validatePackageName(name: string): void {
18+
const result = validateNpmPackageName(name)
19+
if (!result.validForNewPackages && !result.validForOldPackages) {
20+
const errors = result.errors || result.warnings || ['Invalid package name']
21+
throw new Error(`Invalid package name "${name}": ${errors.join(', ')}`)
22+
}
23+
}
24+
25+
/**
26+
* Validates an npm username
27+
* @throws Error if the username is invalid
28+
*/
29+
export function validateUsername(name: string): void {
30+
if (!name || name.length > 50 || !NPM_USERNAME_RE.test(name)) {
31+
throw new Error(`Invalid username: ${name}`)
32+
}
33+
}
34+
35+
/**
36+
* Validates an npm org name (without the @ prefix)
37+
* @throws Error if the org name is invalid
38+
*/
39+
export function validateOrgName(name: string): void {
40+
if (!name || name.length > 50 || !NPM_USERNAME_RE.test(name)) {
41+
throw new Error(`Invalid org name: ${name}`)
42+
}
43+
}
44+
45+
/**
46+
* Validates a scope:team format (e.g., @myorg:developers)
47+
* @throws Error if the scope:team is invalid
48+
*/
49+
export function validateScopeTeam(scopeTeam: string): void {
50+
if (!scopeTeam || scopeTeam.length > 100) {
51+
throw new Error(`Invalid scope:team: ${scopeTeam}`)
52+
}
53+
// Format: @scope:team
54+
const match = scopeTeam.match(/^@([^:]+):(.+)$/)
55+
if (!match) {
56+
throw new Error(`Invalid scope:team format: ${scopeTeam}`)
57+
}
58+
const [, scope, team] = match
59+
if (!scope || !NPM_USERNAME_RE.test(scope)) {
60+
throw new Error(`Invalid scope in scope:team: ${scopeTeam}`)
61+
}
62+
if (!team || !NPM_USERNAME_RE.test(team)) {
63+
throw new Error(`Invalid team name in scope:team: ${scopeTeam}`)
64+
}
65+
}
766

867
export interface NpmExecResult {
968
stdout: string
@@ -56,20 +115,21 @@ export async function execNpm(
56115
args: string[],
57116
options: { otp?: string; silent?: boolean } = {},
58117
): Promise<NpmExecResult> {
59-
const cmd = ['npm', ...args]
60-
61-
if (options.otp) {
62-
cmd.push('--otp', options.otp)
63-
}
118+
// Build the full args array including OTP if provided
119+
const npmArgs = options.otp ? [...args, '--otp', options.otp] : args
64120

65121
// Log the command being run (hide OTP value for security)
66122
if (!options.silent) {
67-
const displayCmd = options.otp ? ['npm', ...args, '--otp', '******'].join(' ') : cmd.join(' ')
123+
const displayCmd = options.otp
124+
? ['npm', ...args, '--otp', '******'].join(' ')
125+
: ['npm', ...args].join(' ')
68126
logCommand(displayCmd)
69127
}
70128

71129
try {
72-
const { stdout, stderr } = await execAsync(cmd.join(' '), {
130+
// Use execFile instead of exec to avoid shell injection vulnerabilities
131+
// execFile does not spawn a shell, so metacharacters are passed literally
132+
const { stdout, stderr } = await execFileAsync('npm', npmArgs, {
73133
timeout: 60000,
74134
env: { ...process.env, FORCE_COLOR: '0' },
75135
})
@@ -127,6 +187,8 @@ export async function orgAddUser(
127187
role: 'developer' | 'admin' | 'owner',
128188
otp?: string,
129189
): Promise<NpmExecResult> {
190+
validateOrgName(org)
191+
validateUsername(user)
130192
return execNpm(['org', 'set', org, user, role], { otp })
131193
}
132194

@@ -135,14 +197,18 @@ export async function orgRemoveUser(
135197
user: string,
136198
otp?: string,
137199
): Promise<NpmExecResult> {
200+
validateOrgName(org)
201+
validateUsername(user)
138202
return execNpm(['org', 'rm', org, user], { otp })
139203
}
140204

141205
export async function teamCreate(scopeTeam: string, otp?: string): Promise<NpmExecResult> {
206+
validateScopeTeam(scopeTeam)
142207
return execNpm(['team', 'create', scopeTeam], { otp })
143208
}
144209

145210
export async function teamDestroy(scopeTeam: string, otp?: string): Promise<NpmExecResult> {
211+
validateScopeTeam(scopeTeam)
146212
return execNpm(['team', 'destroy', scopeTeam], { otp })
147213
}
148214

@@ -151,6 +217,8 @@ export async function teamAddUser(
151217
user: string,
152218
otp?: string,
153219
): Promise<NpmExecResult> {
220+
validateScopeTeam(scopeTeam)
221+
validateUsername(user)
154222
return execNpm(['team', 'add', scopeTeam, user], { otp })
155223
}
156224

@@ -159,6 +227,8 @@ export async function teamRemoveUser(
159227
user: string,
160228
otp?: string,
161229
): Promise<NpmExecResult> {
230+
validateScopeTeam(scopeTeam)
231+
validateUsername(user)
162232
return execNpm(['team', 'rm', scopeTeam, user], { otp })
163233
}
164234

@@ -168,6 +238,8 @@ export async function accessGrant(
168238
pkg: string,
169239
otp?: string,
170240
): Promise<NpmExecResult> {
241+
validateScopeTeam(scopeTeam)
242+
validatePackageName(pkg)
171243
return execNpm(['access', 'grant', permission, scopeTeam, pkg], { otp })
172244
}
173245

@@ -176,31 +248,41 @@ export async function accessRevoke(
176248
pkg: string,
177249
otp?: string,
178250
): Promise<NpmExecResult> {
251+
validateScopeTeam(scopeTeam)
252+
validatePackageName(pkg)
179253
return execNpm(['access', 'revoke', scopeTeam, pkg], { otp })
180254
}
181255

182256
export async function ownerAdd(user: string, pkg: string, otp?: string): Promise<NpmExecResult> {
257+
validateUsername(user)
258+
validatePackageName(pkg)
183259
return execNpm(['owner', 'add', user, pkg], { otp })
184260
}
185261

186262
export async function ownerRemove(user: string, pkg: string, otp?: string): Promise<NpmExecResult> {
263+
validateUsername(user)
264+
validatePackageName(pkg)
187265
return execNpm(['owner', 'rm', user, pkg], { otp })
188266
}
189267

190268
// List functions (for reading data) - silent since they're not user-triggered operations
191269

192270
export async function orgListUsers(org: string): Promise<NpmExecResult> {
271+
validateOrgName(org)
193272
return execNpm(['org', 'ls', org, '--json'], { silent: true })
194273
}
195274

196275
export async function teamListTeams(org: string): Promise<NpmExecResult> {
276+
validateOrgName(org)
197277
return execNpm(['team', 'ls', org, '--json'], { silent: true })
198278
}
199279

200280
export async function teamListUsers(scopeTeam: string): Promise<NpmExecResult> {
281+
validateScopeTeam(scopeTeam)
201282
return execNpm(['team', 'ls', scopeTeam, '--json'], { silent: true })
202283
}
203284

204285
export async function accessListCollaborators(pkg: string): Promise<NpmExecResult> {
286+
validatePackageName(pkg)
205287
return execNpm(['access', 'list', 'collaborators', pkg, '--json'], { silent: true })
206288
}

pnpm-lock.yaml

Lines changed: 17 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

test/unit/cli-validation.spec.ts

Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import { describe, expect, it } from 'vitest'
2+
import {
3+
validateUsername,
4+
validateOrgName,
5+
validateScopeTeam,
6+
validatePackageName,
7+
} from '../../cli/src/npm-client.ts'
8+
9+
describe('validateUsername', () => {
10+
it('accepts valid usernames', () => {
11+
expect(() => validateUsername('alice')).not.toThrow()
12+
expect(() => validateUsername('bob123')).not.toThrow()
13+
expect(() => validateUsername('my-user')).not.toThrow()
14+
expect(() => validateUsername('user-name-123')).not.toThrow()
15+
expect(() => validateUsername('a')).not.toThrow()
16+
expect(() => validateUsername('A1')).not.toThrow()
17+
})
18+
19+
it('rejects empty or missing usernames', () => {
20+
expect(() => validateUsername('')).toThrow('Invalid username')
21+
expect(() => validateUsername(null as unknown as string)).toThrow('Invalid username')
22+
expect(() => validateUsername(undefined as unknown as string)).toThrow('Invalid username')
23+
})
24+
25+
it('rejects usernames that are too long', () => {
26+
const longName = 'a'.repeat(51)
27+
expect(() => validateUsername(longName)).toThrow('Invalid username')
28+
})
29+
30+
it('rejects usernames with invalid characters', () => {
31+
expect(() => validateUsername('user;rm -rf')).toThrow('Invalid username')
32+
expect(() => validateUsername('user && evil')).toThrow('Invalid username')
33+
expect(() => validateUsername('$(whoami)')).toThrow('Invalid username')
34+
expect(() => validateUsername('user`id`')).toThrow('Invalid username')
35+
expect(() => validateUsername('user|cat')).toThrow('Invalid username')
36+
expect(() => validateUsername('user name')).toThrow('Invalid username')
37+
expect(() => validateUsername('user.name')).toThrow('Invalid username')
38+
expect(() => validateUsername('user_name')).toThrow('Invalid username')
39+
expect(() => validateUsername('user@name')).toThrow('Invalid username')
40+
})
41+
42+
it('rejects usernames starting or ending with hyphen', () => {
43+
expect(() => validateUsername('-username')).toThrow('Invalid username')
44+
expect(() => validateUsername('username-')).toThrow('Invalid username')
45+
expect(() => validateUsername('-')).toThrow('Invalid username')
46+
})
47+
})
48+
49+
describe('validateOrgName', () => {
50+
it('accepts valid org names', () => {
51+
expect(() => validateOrgName('nuxt')).not.toThrow()
52+
expect(() => validateOrgName('my-org')).not.toThrow()
53+
expect(() => validateOrgName('org123')).not.toThrow()
54+
})
55+
56+
it('rejects empty or missing org names', () => {
57+
expect(() => validateOrgName('')).toThrow('Invalid org name')
58+
})
59+
60+
it('rejects org names that are too long', () => {
61+
const longName = 'a'.repeat(51)
62+
expect(() => validateOrgName(longName)).toThrow('Invalid org name')
63+
})
64+
65+
it('rejects org names with shell injection characters', () => {
66+
expect(() => validateOrgName('org;rm -rf /')).toThrow('Invalid org name')
67+
expect(() => validateOrgName('org && evil')).toThrow('Invalid org name')
68+
expect(() => validateOrgName('$(whoami)')).toThrow('Invalid org name')
69+
})
70+
})
71+
72+
describe('validateScopeTeam', () => {
73+
it('accepts valid scope:team format', () => {
74+
expect(() => validateScopeTeam('@nuxt:developers')).not.toThrow()
75+
expect(() => validateScopeTeam('@my-org:my-team')).not.toThrow()
76+
expect(() => validateScopeTeam('@org123:team456')).not.toThrow()
77+
expect(() => validateScopeTeam('@a:b')).not.toThrow()
78+
})
79+
80+
it('rejects empty or missing scope:team', () => {
81+
expect(() => validateScopeTeam('')).toThrow('Invalid scope:team')
82+
expect(() => validateScopeTeam(null as unknown as string)).toThrow('Invalid scope:team')
83+
})
84+
85+
it('rejects scope:team that is too long', () => {
86+
const longScopeTeam = '@' + 'a'.repeat(50) + ':' + 'b'.repeat(50)
87+
expect(() => validateScopeTeam(longScopeTeam)).toThrow('Invalid scope:team')
88+
})
89+
90+
it('rejects invalid scope:team format', () => {
91+
expect(() => validateScopeTeam('nuxt:developers')).toThrow('Invalid scope:team format')
92+
expect(() => validateScopeTeam('@nuxt')).toThrow('Invalid scope:team format')
93+
expect(() => validateScopeTeam('developers')).toThrow('Invalid scope:team format')
94+
expect(() => validateScopeTeam('@:team')).toThrow('Invalid scope:team format')
95+
expect(() => validateScopeTeam('@org:')).toThrow('Invalid scope:team format')
96+
})
97+
98+
it('rejects scope:team with shell injection in scope', () => {
99+
expect(() => validateScopeTeam('@org;rm:team')).toThrow('Invalid scope in scope:team')
100+
expect(() => validateScopeTeam('@$(whoami):team')).toThrow('Invalid scope in scope:team')
101+
})
102+
103+
it('rejects scope:team with shell injection in team', () => {
104+
expect(() => validateScopeTeam('@org:team;rm')).toThrow('Invalid team name in scope:team')
105+
expect(() => validateScopeTeam('@org:$(whoami)')).toThrow('Invalid team name in scope:team')
106+
})
107+
108+
it('rejects scope or team starting/ending with hyphen', () => {
109+
expect(() => validateScopeTeam('@-org:team')).toThrow('Invalid scope in scope:team')
110+
expect(() => validateScopeTeam('@org-:team')).toThrow('Invalid scope in scope:team')
111+
expect(() => validateScopeTeam('@org:-team')).toThrow('Invalid team name in scope:team')
112+
expect(() => validateScopeTeam('@org:team-')).toThrow('Invalid team name in scope:team')
113+
})
114+
})
115+
116+
describe('validatePackageName', () => {
117+
it('accepts valid package names', () => {
118+
expect(() => validatePackageName('my-package')).not.toThrow()
119+
expect(() => validatePackageName('@scope/package')).not.toThrow()
120+
expect(() => validatePackageName('package123')).not.toThrow()
121+
})
122+
123+
it('rejects package names with shell injection', () => {
124+
expect(() => validatePackageName('pkg;rm -rf /')).toThrow('Invalid package name')
125+
expect(() => validatePackageName('pkg && evil')).toThrow('Invalid package name')
126+
expect(() => validatePackageName('$(whoami)')).toThrow('Invalid package name')
127+
})
128+
129+
it('rejects empty package names', () => {
130+
expect(() => validatePackageName('')).toThrow('Invalid package name')
131+
})
132+
})

0 commit comments

Comments
 (0)