Shell Command Injection in Git Routes [CloudCLI UI]
Description
Cloud CLI (aka Claude Code UI) is a desktop and mobile UI for Claude Code, Cursor CLI, Codex, and Gemini-CLI. Prior to 1.24.0, The /api/user/git-config endpoint constructs shell commands by interpolating user-supplied gitName and gitEmail values into command strings passed to child_process.exec(). The input is placed within double quotes and only " is escaped, but backticks (`), $() command substitution, and \ sequences are all interpreted within double-quoted strings in bash. This allows authenticated attackers to execute arbitrary OS commands via the git configuration endpoint. This vulnerability is fixed in 1.24.0.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Authenticated attackers can execute arbitrary OS commands via shell injection in the git configuration endpoint of Cloud CLI prior to version 1.24.0.
Vulnerability
Overview
CVE-2026-31861 describes a critical command injection vulnerability in Cloud CLI (also known as Claude Code UI), a desktop and mobile interface for managing Claude Code, Cursor CLI, Codex, and Gemini-CLI sessions [1]. In versions prior to 1.24.0, the /api/user/git-config endpoint constructs shell commands by directly interpolating user-supplied gitName and gitEmail values into strings passed to child_process.exec(). The input is placed within double quotes, and only the double-quote character (") is escaped. However, within double-quoted strings in bash, backticks (` `), $()` command substitution, and backslash sequences are still interpreted, creating a direct injection vector [3][4].
Attack
Vector and Exploitation
An authenticated attacker can send a crafted POST request to the /api/user/git-config endpoint with malicious payloads in the gitName or gitEmail fields. For example, injecting $(malicious_command) or ` malicious_command ` results in arbitrary command execution on the server. The vulnerability is identified as CWE-78 (OS Command Injection) and requires network access and authentication via a JWT token. Notably, when chained with another vulnerability involving a hardcoded JWT secret (VULN-01), authentication can be bypassed, making exploitation possible without valid credentials [4].
Impact
Successful exploitation allows an attacker to execute arbitrary operating system commands as the Node.js process user. This enables reading or writing any file, installing backdoors, pivoting to internal systems, exfiltrating sensitive data, and modifying the server-wide git configuration through the --global flag, which affects all subsequent git operations on that host [4]. The CVSSv3.1 score for this vulnerability when chained is 8.8 (High) [4].
Mitigation
The vulnerability is patched in Cloud CLI version 1.24.0 [1][3]. Users are strongly advised to upgrade to the latest version immediately. There are no known workarounds for unpatched instances. The fix, implemented in commit 86c33c1c0cb34176725a38f46960213714fc3e04, properly sanitizes input before constructing shell commands [3].
AI Insight generated on May 18, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@siteboon/claude-code-uinpm | < 1.24.0 | 1.24.0 |
Affected products
2- siteboon/claudecodeuiv5Range: < 1.24.0
Patches
186c33c1c0cb3fix(git): prevent shell injection in git routes
4 files changed · +165 −81
plugins/starter+1 −0 added@@ -0,0 +1 @@ +Subproject commit bfa63328103ca330a012bc083e4f934adbc2086e
server/routes/git.js+127 −71 modified@@ -1,14 +1,12 @@ import express from 'express'; -import { exec, spawn } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; import path from 'path'; import { promises as fs } from 'fs'; import { extractProjectDirectory } from '../projects.js'; import { queryClaudeSDK } from '../claude-sdk.js'; import { spawnCursor } from '../cursor-cli.js'; const router = express.Router(); -const execAsync = promisify(exec); function spawnAsync(command, args, options = {}) { return new Promise((resolve, reject) => { @@ -47,6 +45,36 @@ function spawnAsync(command, args, options = {}) { }); } +// Input validation helpers (defense-in-depth) +function validateCommitRef(commit) { + // Allow hex hashes, HEAD, HEAD~N, HEAD^N, tag names, branch names + if (!/^[a-zA-Z0-9._~^{}@\/-]+$/.test(commit)) { + throw new Error('Invalid commit reference'); + } + return commit; +} + +function validateBranchName(branch) { + if (!/^[a-zA-Z0-9._\/-]+$/.test(branch)) { + throw new Error('Invalid branch name'); + } + return branch; +} + +function validateFilePath(file) { + if (!file || file.includes('\0')) { + throw new Error('Invalid file path'); + } + return file; +} + +function validateRemoteName(remote) { + if (!/^[a-zA-Z0-9._-]+$/.test(remote)) { + throw new Error('Invalid remote name'); + } + return remote; +} + // Helper function to get the actual project path from the encoded project name async function getActualProjectPath(projectName) { try { @@ -98,14 +126,14 @@ async function validateGitRepository(projectPath) { try { // Allow any directory that is inside a work tree (repo root or nested folder). - const { stdout: insideWorkTreeOutput } = await execAsync('git rev-parse --is-inside-work-tree', { cwd: projectPath }); + const { stdout: insideWorkTreeOutput } = await spawnAsync('git', ['rev-parse', '--is-inside-work-tree'], { cwd: projectPath }); const isInsideWorkTree = insideWorkTreeOutput.trim() === 'true'; if (!isInsideWorkTree) { throw new Error('Not inside a git work tree'); } // Ensure git can resolve the repository root for this directory. - await execAsync('git rev-parse --show-toplevel', { cwd: projectPath }); + await spawnAsync('git', ['rev-parse', '--show-toplevel'], { cwd: projectPath }); } catch { throw new Error('Not a git repository. This directory does not contain a .git folder. Initialize a git repository with "git init" to use source control features.'); } @@ -129,7 +157,7 @@ router.get('/status', async (req, res) => { let branch = 'main'; let hasCommits = true; try { - const { stdout: branchOutput } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: branchOutput } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); branch = branchOutput.trim(); } catch (error) { // No HEAD exists - repository has no commits yet @@ -142,7 +170,7 @@ router.get('/status', async (req, res) => { } // Get git status - const { stdout: statusOutput } = await execAsync('git status --porcelain', { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain'], { cwd: projectPath }); const modified = []; const added = []; @@ -201,8 +229,11 @@ router.get('/diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check if file is untracked or deleted - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -223,21 +254,21 @@ router.get('/diff', async (req, res) => { } } else if (isDeleted) { // For deleted files, show the entire file content from HEAD as deletions - const { stdout: fileContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: fileContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); const lines = fileContent.split('\n'); diff = `--- a/${file}\n+++ /dev/null\n@@ -1,${lines.length} +0,0 @@\n` + lines.map(line => `-${line}`).join('\n'); } else { // Get diff for tracked files // First check for unstaged changes (working tree vs index) - const { stdout: unstagedDiff } = await execAsync(`git diff -- "${file}"`, { cwd: projectPath }); + const { stdout: unstagedDiff } = await spawnAsync('git', ['diff', '--', file], { cwd: projectPath }); if (unstagedDiff) { // Show unstaged changes if they exist diff = stripDiffHeaders(unstagedDiff); } else { // If no unstaged changes, check for staged changes (index vs HEAD) - const { stdout: stagedDiff } = await execAsync(`git diff --cached -- "${file}"`, { cwd: projectPath }); + const { stdout: stagedDiff } = await spawnAsync('git', ['diff', '--cached', '--', file], { cwd: projectPath }); diff = stripDiffHeaders(stagedDiff) || ''; } } @@ -263,8 +294,11 @@ router.get('/file-with-diff', async (req, res) => { // Validate git repository await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check file status - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); const isUntracked = statusOutput.startsWith('??'); const isDeleted = statusOutput.trim().startsWith('D ') || statusOutput.trim().startsWith(' D'); @@ -273,7 +307,7 @@ router.get('/file-with-diff', async (req, res) => { if (isDeleted) { // For deleted files, get content from HEAD - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); oldContent = headContent; currentContent = headContent; // Show the deleted content in editor } else { @@ -291,7 +325,7 @@ router.get('/file-with-diff', async (req, res) => { if (!isUntracked) { // Get the old content from HEAD for tracked files try { - const { stdout: headContent } = await execAsync(`git show HEAD:"${file}"`, { cwd: projectPath }); + const { stdout: headContent } = await spawnAsync('git', ['show', `HEAD:${file}`], { cwd: projectPath }); oldContent = headContent; } catch (error) { // File might be newly added to git (staged but not committed) @@ -328,17 +362,17 @@ router.post('/initial-commit', async (req, res) => { // Check if there are already commits try { - await execAsync('git rev-parse HEAD', { cwd: projectPath }); + await spawnAsync('git', ['rev-parse', 'HEAD'], { cwd: projectPath }); return res.status(400).json({ error: 'Repository already has commits. Use regular commit instead.' }); } catch (error) { // No HEAD - this is good, we can create initial commit } // Add all files - await execAsync('git add .', { cwd: projectPath }); + await spawnAsync('git', ['add', '.'], { cwd: projectPath }); // Create initial commit - const { stdout } = await execAsync('git commit -m "Initial commit"', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', 'Initial commit'], { cwd: projectPath }); res.json({ success: true, output: stdout, message: 'Initial commit created successfully' }); } catch (error) { @@ -372,11 +406,12 @@ router.post('/commit', async (req, res) => { // Stage selected files for (const file of files) { - await execAsync(`git add "${file}"`, { cwd: projectPath }); + validateFilePath(file); + await spawnAsync('git', ['add', file], { cwd: projectPath }); } - + // Commit with message - const { stdout } = await execAsync(`git commit -m "${message.replace(/"/g, '\\"')}"`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['commit', '-m', message], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -400,7 +435,7 @@ router.get('/branches', async (req, res) => { await validateGitRepository(projectPath); // Get all branches - const { stdout } = await execAsync('git branch -a', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['branch', '-a'], { cwd: projectPath }); // Parse branches const branches = stdout @@ -439,7 +474,8 @@ router.post('/checkout', async (req, res) => { const projectPath = await getActualProjectPath(project); // Checkout the branch - const { stdout } = await execAsync(`git checkout "${branch}"`, { cwd: projectPath }); + validateBranchName(branch); + const { stdout } = await spawnAsync('git', ['checkout', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -460,7 +496,8 @@ router.post('/create-branch', async (req, res) => { const projectPath = await getActualProjectPath(project); // Create and checkout new branch - const { stdout } = await execAsync(`git checkout -b "${branch}"`, { cwd: projectPath }); + validateBranchName(branch); + const { stdout } = await spawnAsync('git', ['checkout', '-b', branch], { cwd: projectPath }); res.json({ success: true, output: stdout }); } catch (error) { @@ -509,8 +546,8 @@ router.get('/commits', async (req, res) => { // Get stats for each commit for (const commit of commits) { try { - const { stdout: stats } = await execAsync( - `git show --stat --format='' ${commit.hash}`, + const { stdout: stats } = await spawnAsync( + 'git', ['show', '--stat', '--format=', commit.hash], { cwd: projectPath } ); commit.stats = stats.trim().split('\n').pop(); // Get the summary line @@ -536,10 +573,13 @@ router.get('/commit-diff', async (req, res) => { try { const projectPath = await getActualProjectPath(project); - + + // Validate commit reference (defense-in-depth) + validateCommitRef(commit); + // Get diff for the commit - const { stdout } = await execAsync( - `git show ${commit}`, + const { stdout } = await spawnAsync( + 'git', ['show', commit], { cwd: projectPath } ); @@ -570,8 +610,9 @@ router.post('/generate-commit-message', async (req, res) => { let diffContext = ''; for (const file of files) { try { - const { stdout } = await execAsync( - `git diff HEAD -- "${file}"`, + validateFilePath(file); + const { stdout } = await spawnAsync( + 'git', ['diff', 'HEAD', '--', file], { cwd: projectPath } ); if (stdout) { @@ -764,22 +805,22 @@ router.get('/remote-status', async (req, res) => { await validateGitRepository(projectPath); // Get current branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); // Check if there's a remote tracking branch (smart detection) let trackingBranch; let remoteName; try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); trackingBranch = stdout.trim(); remoteName = trackingBranch.split('/')[0]; // Extract remote name (e.g., "origin/main" -> "origin") } catch (error) { // No upstream branch configured - but check if we have remotes let hasRemote = false; let remoteName = null; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length > 0) { hasRemote = true; @@ -788,8 +829,8 @@ router.get('/remote-status', async (req, res) => { } catch (remoteError) { // No remotes configured } - - return res.json({ + + return res.json({ hasRemote, hasUpstream: false, branch, @@ -799,8 +840,8 @@ router.get('/remote-status', async (req, res) => { } // Get ahead/behind counts - const { stdout: countOutput } = await execAsync( - `git rev-list --count --left-right ${trackingBranch}...HEAD`, + const { stdout: countOutput } = await spawnAsync( + 'git', ['rev-list', '--count', '--left-right', `${trackingBranch}...HEAD`], { cwd: projectPath } ); @@ -835,20 +876,21 @@ router.post('/fetch', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); remoteName = stdout.trim().split('/')[0]; // Extract remote name } catch (error) { // No upstream, try to fetch from origin anyway console.log('No upstream configured, using origin as fallback'); } - const { stdout } = await execAsync(`git fetch ${remoteName}`, { cwd: projectPath }); - + validateRemoteName(remoteName); + const { stdout } = await spawnAsync('git', ['fetch', remoteName], { cwd: projectPath }); + res.json({ success: true, output: stdout || 'Fetch completed successfully', remoteName }); } catch (error) { console.error('Git fetch error:', error); @@ -876,13 +918,13 @@ router.post('/pull', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -891,17 +933,19 @@ router.post('/pull', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git pull ${remoteName} ${remoteBranch}`, { cwd: projectPath }); - - res.json({ - success: true, - output: stdout || 'Pull completed successfully', + validateRemoteName(remoteName); + validateBranchName(remoteBranch); + const { stdout } = await spawnAsync('git', ['pull', remoteName, remoteBranch], { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Pull completed successfully', remoteName, remoteBranch }); } catch (error) { console.error('Git pull error:', error); - + // Enhanced error handling for common pull scenarios let errorMessage = 'Pull failed'; let details = error.message; @@ -943,13 +987,13 @@ router.post('/push', async (req, res) => { await validateGitRepository(projectPath); // Get current branch and its upstream remote - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const branch = currentBranch.trim(); let remoteName = 'origin'; // fallback let remoteBranch = branch; // fallback try { - const { stdout } = await execAsync(`git rev-parse --abbrev-ref ${branch}@{upstream}`, { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', `${branch}@{upstream}`], { cwd: projectPath }); const tracking = stdout.trim(); remoteName = tracking.split('/')[0]; // Extract remote name remoteBranch = tracking.split('/').slice(1).join('/'); // Extract branch name @@ -958,11 +1002,13 @@ router.post('/push', async (req, res) => { console.log('No upstream configured, using origin/branch as fallback'); } - const { stdout } = await execAsync(`git push ${remoteName} ${remoteBranch}`, { cwd: projectPath }); - - res.json({ - success: true, - output: stdout || 'Push completed successfully', + validateRemoteName(remoteName); + validateBranchName(remoteBranch); + const { stdout } = await spawnAsync('git', ['push', remoteName, remoteBranch], { cwd: projectPath }); + + res.json({ + success: true, + output: stdout || 'Push completed successfully', remoteName, remoteBranch }); @@ -1012,35 +1058,39 @@ router.post('/publish', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate branch name + validateBranchName(branch); + // Get current branch to verify it matches the requested branch - const { stdout: currentBranch } = await execAsync('git rev-parse --abbrev-ref HEAD', { cwd: projectPath }); + const { stdout: currentBranch } = await spawnAsync('git', ['rev-parse', '--abbrev-ref', 'HEAD'], { cwd: projectPath }); const currentBranchName = currentBranch.trim(); - + if (currentBranchName !== branch) { - return res.status(400).json({ - error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` + return res.status(400).json({ + error: `Branch mismatch. Current branch is ${currentBranchName}, but trying to publish ${branch}` }); } // Check if remote exists let remoteName = 'origin'; try { - const { stdout } = await execAsync('git remote', { cwd: projectPath }); + const { stdout } = await spawnAsync('git', ['remote'], { cwd: projectPath }); const remotes = stdout.trim().split('\n').filter(r => r.trim()); if (remotes.length === 0) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin <url>' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin <url>' }); } remoteName = remotes.includes('origin') ? 'origin' : remotes[0]; } catch (error) { - return res.status(400).json({ - error: 'No remote repository configured. Add a remote with: git remote add origin <url>' + return res.status(400).json({ + error: 'No remote repository configured. Add a remote with: git remote add origin <url>' }); } // Publish the branch (set upstream and push) - const { stdout } = await execAsync(`git push --set-upstream ${remoteName} ${branch}`, { cwd: projectPath }); + validateRemoteName(remoteName); + const { stdout } = await spawnAsync('git', ['push', '--set-upstream', remoteName, branch], { cwd: projectPath }); res.json({ success: true, @@ -1088,9 +1138,12 @@ router.post('/discard', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check file status to determine correct discard command - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); - + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); + if (!statusOutput.trim()) { return res.status(400).json({ error: 'No changes to discard for this file' }); } @@ -1109,10 +1162,10 @@ router.post('/discard', async (req, res) => { } } else if (status.includes('M') || status.includes('D')) { // Modified or deleted file - restore from HEAD - await execAsync(`git restore "${file}"`, { cwd: projectPath }); + await spawnAsync('git', ['restore', file], { cwd: projectPath }); } else if (status.includes('A')) { // Added file - unstage it - await execAsync(`git reset HEAD "${file}"`, { cwd: projectPath }); + await spawnAsync('git', ['reset', 'HEAD', file], { cwd: projectPath }); } res.json({ success: true, message: `Changes discarded for ${file}` }); @@ -1134,8 +1187,11 @@ router.post('/delete-untracked', async (req, res) => { const projectPath = await getActualProjectPath(project); await validateGitRepository(projectPath); + // Validate file path + validateFilePath(file); + // Check if file is actually untracked - const { stdout: statusOutput } = await execAsync(`git status --porcelain "${file}"`, { cwd: projectPath }); + const { stdout: statusOutput } = await spawnAsync('git', ['status', '--porcelain', file], { cwd: projectPath }); if (!statusOutput.trim()) { return res.status(400).json({ error: 'File is not untracked or does not exist' });
server/routes/user.js+22 −5 modified@@ -2,12 +2,29 @@ import express from 'express'; import { userDb } from '../database/db.js'; import { authenticateToken } from '../middleware/auth.js'; import { getSystemGitConfig } from '../utils/gitConfig.js'; -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; -const execAsync = promisify(exec); const router = express.Router(); +function spawnAsync(command, args, options = {}) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { ...options, shell: false }); + let stdout = ''; + let stderr = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.stderr.on('data', (data) => { stderr += data.toString(); }); + child.on('error', (error) => { reject(error); }); + child.on('close', (code) => { + if (code === 0) { resolve({ stdout, stderr }); return; } + const error = new Error(`Command failed: ${command} ${args.join(' ')}`); + error.code = code; + error.stdout = stdout; + error.stderr = stderr; + reject(error); + }); + }); +} + router.get('/git-config', authenticateToken, async (req, res) => { try { const userId = req.user.id; @@ -55,8 +72,8 @@ router.post('/git-config', authenticateToken, async (req, res) => { userDb.updateGitConfig(userId, gitName, gitEmail); try { - await execAsync(`git config --global user.name "${gitName.replace(/"/g, '\\"')}"`); - await execAsync(`git config --global user.email "${gitEmail.replace(/"/g, '\\"')}"`); + await spawnAsync('git', ['config', '--global', 'user.name', gitName]); + await spawnAsync('git', ['config', '--global', 'user.email', gitEmail]); console.log(`Applied git config globally: ${gitName} <${gitEmail}>`); } catch (gitError) { console.error('Error applying git config:', gitError);
server/utils/gitConfig.js+15 −5 modified@@ -1,7 +1,17 @@ -import { exec } from 'child_process'; -import { promisify } from 'util'; +import { spawn } from 'child_process'; -const execAsync = promisify(exec); +function spawnAsync(command, args) { + return new Promise((resolve, reject) => { + const child = spawn(command, args, { shell: false }); + let stdout = ''; + child.stdout.on('data', (data) => { stdout += data.toString(); }); + child.on('error', (error) => { reject(error); }); + child.on('close', (code) => { + if (code === 0) { resolve({ stdout }); return; } + reject(new Error(`Command failed with code ${code}`)); + }); + }); +} /** * Read git configuration from system's global git config @@ -10,8 +20,8 @@ const execAsync = promisify(exec); export async function getSystemGitConfig() { try { const [nameResult, emailResult] = await Promise.all([ - execAsync('git config --global user.name').catch(() => ({ stdout: '' })), - execAsync('git config --global user.email').catch(() => ({ stdout: '' })) + spawnAsync('git', ['config', '--global', 'user.name']).catch(() => ({ stdout: '' })), + spawnAsync('git', ['config', '--global', 'user.email']).catch(() => ({ stdout: '' })) ]); return {
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-7fv4-fmmc-86g2ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2026-31861ghsaADVISORY
- github.com/siteboon/claudecodeui/commit/86c33c1c0cb34176725a38f46960213714fc3e04ghsax_refsource_MISCWEB
- github.com/siteboon/claudecodeui/releases/tag/v1.24.0ghsax_refsource_MISCWEB
- github.com/siteboon/claudecodeui/security/advisories/GHSA-7fv4-fmmc-86g2ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.