diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..033f757f --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-03-01 - Command Injection in github-clone.ts +**Vulnerability:** Command injection and argument injection in `src/pages/api/github-clone.ts`. The API endpoint accepted `repoUrl` and `repoName` from JSON body and concatenated them directly into a `git clone` shell command using `exec`. A malicious user could provide a `repoName` like `; rm -rf /;` to execute arbitrary commands on the server. Furthermore, they could provide a string starting with `-` to inject flags to `git`. +**Learning:** Shell-based command execution (like `exec`) paired with user input is a critical security risk. Node's `exec` passes the entire command string to a shell, making it trivial to inject additional commands. +**Prevention:** Always use `execFile` (or `spawn`) and pass arguments as an array to ensure the system executes only the intended executable with arguments securely escaped by the OS. Additionally, explicitly validate that user-controlled input intended as arguments (like URLs or folder names) does not start with a hyphen (`-`) to prevent flag/argument injection against the target executable itself. diff --git a/src/pages/api/github-clone.ts b/src/pages/api/github-clone.ts index 5ff437b8..002e4587 100644 --- a/src/pages/api/github-clone.ts +++ b/src/pages/api/github-clone.ts @@ -1,12 +1,12 @@ import type { APIRoute } from 'astro'; -import { exec } from 'node:child_process'; +import { execFile } from 'node:child_process'; import util from 'node:util'; import fs from 'node:fs'; import path from 'node:path'; import { getReposRootResolved } from '../../lib/forge-repos'; import { getConfig } from '../../lib/config-db'; -const execPromise = util.promisify(exec); +const execFilePromise = util.promisify(execFile); export const POST: APIRoute = async ({ request }) => { try { @@ -16,6 +16,15 @@ export const POST: APIRoute = async ({ request }) => { return new Response(JSON.stringify({ error: 'repoUrl et repoName requis' }), { status: 400 }); } + if (typeof repoUrl !== 'string' || typeof repoName !== 'string') { + return new Response(JSON.stringify({ error: 'Type invalide pour repoUrl ou repoName' }), { status: 400 }); + } + + // Security: Prevent argument injection + if (repoUrl.trim().startsWith('-') || repoName.trim().startsWith('-')) { + return new Response(JSON.stringify({ error: 'Format de repoUrl ou repoName invalide' }), { status: 400 }); + } + const githubToken = await getConfig('githubToken', true); if (!githubToken || githubToken.trim() === '') { return new Response(JSON.stringify({ error: 'Jeton GitHub manquant' }), { status: 400 }); @@ -33,7 +42,8 @@ export const POST: APIRoute = async ({ request }) => { const authUrl = repoUrl.replace('https://', `https://oauth2:${githubToken}@`); // Clone the repository - const { stdout, stderr } = await execPromise(`git clone ${authUrl} ${repoName}`, { cwd: reposRoot }); + // Security: Use execFile to prevent command injection + const { stdout, stderr } = await execFilePromise('git', ['clone', authUrl, repoName], { cwd: reposRoot }); // Try to auto-sync it into the database try {