[READ-ONLY] a fast, modern browser for the npm registry
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

fix(cli): validate user input before running commands

+243 -10
+3 -1
cli/package.json
··· 29 29 "h3": "^1.15.5", 30 30 "listhen": "^1.9.0", 31 31 "ofetch": "^1.5.1", 32 - "picocolors": "^1.1.1" 32 + "picocolors": "^1.1.1", 33 + "validate-npm-package-name": "^7.0.2" 33 34 }, 34 35 "devDependencies": { 35 36 "@types/node": "^24.10.9", 37 + "@types/validate-npm-package-name": "^4.0.2", 36 38 "typescript": "^5.9.3", 37 39 "unbuild": "^3.6.1" 38 40 }
+91 -9
cli/src/npm-client.ts
··· 1 1 import process from 'node:process' 2 - import { exec } from 'node:child_process' 2 + import { execFile } from 'node:child_process' 3 3 import { promisify } from 'node:util' 4 + import validateNpmPackageName from 'validate-npm-package-name' 4 5 import { logCommand, logSuccess, logError } from './logger.ts' 5 6 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 + } 7 66 8 67 export interface NpmExecResult { 9 68 stdout: string ··· 56 115 args: string[], 57 116 options: { otp?: string; silent?: boolean } = {}, 58 117 ): 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 64 120 65 121 // Log the command being run (hide OTP value for security) 66 122 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(' ') 68 126 logCommand(displayCmd) 69 127 } 70 128 71 129 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, { 73 133 timeout: 60000, 74 134 env: { ...process.env, FORCE_COLOR: '0' }, 75 135 }) ··· 127 187 role: 'developer' | 'admin' | 'owner', 128 188 otp?: string, 129 189 ): Promise<NpmExecResult> { 190 + validateOrgName(org) 191 + validateUsername(user) 130 192 return execNpm(['org', 'set', org, user, role], { otp }) 131 193 } 132 194 ··· 135 197 user: string, 136 198 otp?: string, 137 199 ): Promise<NpmExecResult> { 200 + validateOrgName(org) 201 + validateUsername(user) 138 202 return execNpm(['org', 'rm', org, user], { otp }) 139 203 } 140 204 141 205 export async function teamCreate(scopeTeam: string, otp?: string): Promise<NpmExecResult> { 206 + validateScopeTeam(scopeTeam) 142 207 return execNpm(['team', 'create', scopeTeam], { otp }) 143 208 } 144 209 145 210 export async function teamDestroy(scopeTeam: string, otp?: string): Promise<NpmExecResult> { 211 + validateScopeTeam(scopeTeam) 146 212 return execNpm(['team', 'destroy', scopeTeam], { otp }) 147 213 } 148 214 ··· 151 217 user: string, 152 218 otp?: string, 153 219 ): Promise<NpmExecResult> { 220 + validateScopeTeam(scopeTeam) 221 + validateUsername(user) 154 222 return execNpm(['team', 'add', scopeTeam, user], { otp }) 155 223 } 156 224 ··· 159 227 user: string, 160 228 otp?: string, 161 229 ): Promise<NpmExecResult> { 230 + validateScopeTeam(scopeTeam) 231 + validateUsername(user) 162 232 return execNpm(['team', 'rm', scopeTeam, user], { otp }) 163 233 } 164 234 ··· 168 238 pkg: string, 169 239 otp?: string, 170 240 ): Promise<NpmExecResult> { 241 + validateScopeTeam(scopeTeam) 242 + validatePackageName(pkg) 171 243 return execNpm(['access', 'grant', permission, scopeTeam, pkg], { otp }) 172 244 } 173 245 ··· 176 248 pkg: string, 177 249 otp?: string, 178 250 ): Promise<NpmExecResult> { 251 + validateScopeTeam(scopeTeam) 252 + validatePackageName(pkg) 179 253 return execNpm(['access', 'revoke', scopeTeam, pkg], { otp }) 180 254 } 181 255 182 256 export async function ownerAdd(user: string, pkg: string, otp?: string): Promise<NpmExecResult> { 257 + validateUsername(user) 258 + validatePackageName(pkg) 183 259 return execNpm(['owner', 'add', user, pkg], { otp }) 184 260 } 185 261 186 262 export async function ownerRemove(user: string, pkg: string, otp?: string): Promise<NpmExecResult> { 263 + validateUsername(user) 264 + validatePackageName(pkg) 187 265 return execNpm(['owner', 'rm', user, pkg], { otp }) 188 266 } 189 267 190 268 // List functions (for reading data) - silent since they're not user-triggered operations 191 269 192 270 export async function orgListUsers(org: string): Promise<NpmExecResult> { 271 + validateOrgName(org) 193 272 return execNpm(['org', 'ls', org, '--json'], { silent: true }) 194 273 } 195 274 196 275 export async function teamListTeams(org: string): Promise<NpmExecResult> { 276 + validateOrgName(org) 197 277 return execNpm(['team', 'ls', org, '--json'], { silent: true }) 198 278 } 199 279 200 280 export async function teamListUsers(scopeTeam: string): Promise<NpmExecResult> { 281 + validateScopeTeam(scopeTeam) 201 282 return execNpm(['team', 'ls', scopeTeam, '--json'], { silent: true }) 202 283 } 203 284 204 285 export async function accessListCollaborators(pkg: string): Promise<NpmExecResult> { 286 + validatePackageName(pkg) 205 287 return execNpm(['access', 'list', 'collaborators', pkg, '--json'], { silent: true }) 206 288 }
+17
pnpm-lock.yaml
··· 173 173 picocolors: 174 174 specifier: ^1.1.1 175 175 version: 1.1.1 176 + validate-npm-package-name: 177 + specifier: ^7.0.2 178 + version: 7.0.2 176 179 devDependencies: 177 180 '@types/node': 178 181 specifier: ^24.10.9 179 182 version: 24.10.9 183 + '@types/validate-npm-package-name': 184 + specifier: ^4.0.2 185 + version: 4.0.2 180 186 typescript: 181 187 specifier: ^5.9.3 182 188 version: 5.9.3 ··· 2761 2767 2762 2768 '@types/unist@3.0.3': 2763 2769 resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} 2770 + 2771 + '@types/validate-npm-package-name@4.0.2': 2772 + resolution: {integrity: sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw==} 2764 2773 2765 2774 '@types/web-bluetooth@0.0.21': 2766 2775 resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} ··· 6545 6554 typescript: 6546 6555 optional: true 6547 6556 6557 + validate-npm-package-name@7.0.2: 6558 + resolution: {integrity: sha512-hVDIBwsRruT73PbK7uP5ebUt+ezEtCmzZz3F59BSr2F6OVFnJ/6h8liuvdLrQ88Xmnk6/+xGGuq+pG9WwTuy3A==} 6559 + engines: {node: ^20.17.0 || >=22.9.0} 6560 + 6548 6561 vfile-message@4.0.3: 6549 6562 resolution: {integrity: sha512-QTHzsGd1EhbZs4AsQ20JX1rC3cOlt/IWJruk893DfLRr57lcnOeMaWG4K0JrRta4mIJZKth2Au3mM3u03/JWKw==} 6550 6563 ··· 9545 9558 '@types/trusted-types@2.0.7': {} 9546 9559 9547 9560 '@types/unist@3.0.3': {} 9561 + 9562 + '@types/validate-npm-package-name@4.0.2': {} 9548 9563 9549 9564 '@types/web-bluetooth@0.0.21': {} 9550 9565 ··· 14277 14292 valibot@1.2.0(typescript@5.9.3): 14278 14293 optionalDependencies: 14279 14294 typescript: 5.9.3 14295 + 14296 + validate-npm-package-name@7.0.2: {} 14280 14297 14281 14298 vfile-message@4.0.3: 14282 14299 dependencies:
+132
test/unit/cli-validation.spec.ts
··· 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 + })