VYPR
High severityNVD Advisory· Published Mar 11, 2026· Updated Mar 12, 2026

Shell Command Injection in Git Routes [CloudCLI UI]

CVE-2026-31861

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.

PackageAffected versionsPatched versions
@siteboon/claude-code-uinpm
< 1.24.01.24.0

Affected products

2

Patches

1
86c33c1c0cb3

fix(git): prevent shell injection in git routes

https://github.com/siteboon/claudecodeuisimosmikMar 9, 2026via ghsa
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

News mentions

0

No linked articles in our index yet.