diff --git a/src/__tests__/deploy-integration.test.ts b/src/__tests__/deploy-integration.test.ts index e825dfb..ab13209 100644 --- a/src/__tests__/deploy-integration.test.ts +++ b/src/__tests__/deploy-integration.test.ts @@ -547,4 +547,76 @@ describe("deploy command (mocked shell)", () => { } } }) + + function setupUnlinkedTestDir(name: string, packageName: string) { + const testDir = join(fixturesDir, name) + mkdirSync(testDir, { recursive: true }) + writeFileSync( + join(testDir, "package.json"), + JSON.stringify({ name: packageName }), + ) + return testDir + } + + it("should not pass a package.json name with shell metacharacters into the link command (CWE-78)", async () => { + vi.spyOn(process, "exit").mockImplementation(code => { + throw new ExitError(code as number) + }) + const logSpy = vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + + const maliciousName = '"; touch /tmp/pwned; echo "' + const testDir = setupUnlinkedTestDir("malicious-name", maliciousName) + + setupExecMock({ + "vercel --version": "Vercel CLI 33.0.0", + "vercel whoami": "test-user", + "vercel deploy --prod": new Error("stop after link"), + }) + + process.chdir(testDir) + + const { deployCommand } = await import("../cli/commands/deploy.js") + await expect( + deployCommand.parseAsync(["--host", "vercel"], { from: "user" }), + ).rejects.toThrow(ExitError) + + for (const call of execSyncMock.mock.calls) { + expect(String(call[0])).not.toContain(maliciousName) + expect(String(call[0])).not.toContain("touch /tmp/pwned") + } + + expect(logSpy).toHaveBeenCalledWith( + expect.stringContaining("not a valid Vercel project name"), + ) + }) + + it("should pass a valid package.json name through to the link command", async () => { + vi.spyOn(process, "exit").mockImplementation(code => { + throw new ExitError(code as number) + }) + vi.spyOn(console, "log").mockImplementation(() => {}) + vi.spyOn(console, "error").mockImplementation(() => {}) + + const testDir = setupUnlinkedTestDir("valid-name", "@scope/my-tool") + + setupExecMock({ + "vercel --version": "Vercel CLI 33.0.0", + "vercel whoami": "test-user", + "vercel link": "", + "vercel deploy --prod": new Error("stop after link"), + }) + + process.chdir(testDir) + + const { deployCommand } = await import("../cli/commands/deploy.js") + await expect( + deployCommand.parseAsync(["--host", "vercel"], { from: "user" }), + ).rejects.toThrow(ExitError) + + expect(execSyncMock).toHaveBeenCalledWith( + expect.stringContaining("--project=my-tool"), + expect.anything(), + ) + }) }) diff --git a/src/cli/commands/deploy.ts b/src/cli/commands/deploy.ts index fa3dd95..c3ef982 100644 --- a/src/cli/commands/deploy.ts +++ b/src/cli/commands/deploy.ts @@ -78,6 +78,15 @@ export function isSensitiveEnvVar(name: string): boolean { return SENSITIVE_PATTERN.test(name) } +// Vercel project names may only contain lowercase letters, numbers, ".", "_" +// and "-" (max 100 chars). Anything outside this set must never reach a +// shell-interpolated command string (execCmd runs via execSync/"sh -c"). +const VERCEL_PROJECT_NAME_PATTERN = /^[a-z0-9][a-z0-9._-]{0,99}$/ + +export function isValidVercelProjectName(name: string): boolean { + return VERCEL_PROJECT_NAME_PATTERN.test(name) +} + export function extractDeploymentUrl(output: string): string | undefined { for (const line of output.split("\n")) { const match = line.match(/https:\/\/[\w-]+\.vercel\.app/) @@ -219,7 +228,16 @@ async function deployToVercel(options: DeployOptions): Promise { name?: string } if (pkg.name) { - projectName = pkg.name.replace(/^@[^/]+\//, "") + const candidate = pkg.name.replace(/^@[^/]+\//, "") + if (isValidVercelProjectName(candidate)) { + projectName = candidate + } else { + console.log( + pc.yellow( + ` Warning: package.json name "${pkg.name}" is not a valid Vercel project name — omitting --project flag`, + ), + ) + } } } } catch {