Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
72 changes: 72 additions & 0 deletions src/__tests__/deploy-integration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
)
})
})
20 changes: 19 additions & 1 deletion src/cli/commands/deploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/)
Expand Down Expand Up @@ -219,7 +228,16 @@ async function deployToVercel(options: DeployOptions): Promise<void> {
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 {
Expand Down