diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..706471f3 --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2025-02-27 - [Fix Command Injection in API Routes] +**Vulnerability:** Found multiple API routes (`docker-logs.ts`, `docker-health.ts`, `github-clone.ts`) using `execSync` and `exec` with string interpolation for executing shell commands involving user inputs, leading to potential command injection. +**Learning:** External processes should be spawned safely using explicit arrays of arguments, preventing argument injections. +**Prevention:** Avoid `exec` and `execSync` altogether when running commands with variable parameters. Use `execFile` or `execFileAsync` (`promisify(execFile)`) providing an explicit array for arguments. For Git clone, make sure to separate URL arguments with `--`. Check and validate user inputs with regex. diff --git a/src/pages/api/docker-health.ts b/src/pages/api/docker-health.ts index 94251167..b4e96d83 100644 --- a/src/pages/api/docker-health.ts +++ b/src/pages/api/docker-health.ts @@ -1,10 +1,15 @@ import type { APIRoute } from 'astro'; -import { execSync } from 'child_process'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); export const GET: APIRoute = async () => { try { const format = '{"ID":"{{.ID}}","Names":"{{.Names}}","Image":"{{.Image}}","Status":"{{.Status}}","State":"{{.State}}","Ports":"{{.Ports}}"}'; - const output = execSync(`docker ps -a --format '${format}'`).toString().trim(); + // 🛡️ Sentinel: Use execFileAsync with argument arrays instead of execSync to prevent command injection + const { stdout } = await execFileAsync('docker', ['ps', '-a', '--format', format]); + const output = stdout.trim(); if (!output) { return new Response(JSON.stringify({ containers: [] }), { diff --git a/src/pages/api/docker-logs.ts b/src/pages/api/docker-logs.ts index cb72e09e..9c188052 100644 --- a/src/pages/api/docker-logs.ts +++ b/src/pages/api/docker-logs.ts @@ -1,5 +1,8 @@ import type { APIRoute } from 'astro'; -import { execSync } from 'child_process'; +import { execFile } from 'child_process'; +import { promisify } from 'util'; + +const execFileAsync = promisify(execFile); export const GET: APIRoute = async ({ url }) => { try { @@ -21,14 +24,26 @@ export const GET: APIRoute = async ({ url }) => { }); } - // Commande Docker pour récupérer les logs - const command = `docker logs --tail ${tail} ${containerId}`; + // 🛡️ Sentinel: Validate containerId to prevent argument injection + if (!/^[a-zA-Z0-9_.-]+$/.test(containerId)) { + return new Response(JSON.stringify({ error: "ID de conteneur invalide" }), { + status: 400, + headers: { 'Content-Type': 'application/json' } + }); + } + + const tailNum = parseInt(tail, 10); + const validTail = isNaN(tailNum) ? '100' : String(tailNum); + let logs = []; try { - const output = execSync(command, { stdio: ['pipe', 'pipe', 'pipe'] }).toString(); + // 🛡️ Sentinel: Use execFileAsync with argument arrays instead of execSync with string concatenation + // to prevent command injection via shell operators. + const { stdout, stderr } = await execFileAsync('docker', ['logs', '--tail', validTail, containerId]); + // Docker logs may output to stdout or stderr depending on the container + const output = stdout || stderr; logs = output.trim().split('\n'); } catch (err: any) { - // Certains logs sortent sur stderr, checkons stderr si stdout est vide ou si erreur if (err.stderr) { logs = err.stderr.toString().trim().split('\n'); } else { @@ -41,7 +56,8 @@ export const GET: APIRoute = async ({ url }) => { headers: { 'Content-Type': 'application/json' } }); } catch (error: any) { - return new Response(JSON.stringify({ error: "Logs indisponibles: " + error.message }), { + // 🛡️ Sentinel: Don't leak raw error details + return new Response(JSON.stringify({ error: "Logs indisponibles" }), { status: 500, headers: { 'Content-Type': 'application/json' } }); diff --git a/src/pages/api/github-clone.ts b/src/pages/api/github-clone.ts index 5ff437b8..c41b0fe1 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 execFileAsync = util.promisify(execFile); export const POST: APIRoute = async ({ request }) => { try { @@ -16,6 +16,11 @@ export const POST: APIRoute = async ({ request }) => { return new Response(JSON.stringify({ error: 'repoUrl et repoName requis' }), { status: 400 }); } + // 🛡️ Sentinel: Validate repoName to prevent directory traversal and command injection + if (!/^[a-zA-Z0-9_.-]+$/.test(repoName) || repoName.includes('..')) { + return new Response(JSON.stringify({ error: 'Nom de dépôt invalide' }), { status: 400 }); + } + const githubToken = await getConfig('githubToken', true); if (!githubToken || githubToken.trim() === '') { return new Response(JSON.stringify({ error: 'Jeton GitHub manquant' }), { status: 400 }); @@ -30,19 +35,16 @@ export const POST: APIRoute = async ({ request }) => { // Inject token into URL for private repo cloning // Assuming format https://github.com/user/repo.git + // 🛡️ Sentinel: Make sure repoUrl is basically valid before injecting + if (!repoUrl.startsWith('https://github.com/')) { + return new Response(JSON.stringify({ error: 'URL Github invalide' }), { status: 400 }); + } const authUrl = repoUrl.replace('https://', `https://oauth2:${githubToken}@`); // Clone the repository - const { stdout, stderr } = await execPromise(`git clone ${authUrl} ${repoName}`, { cwd: reposRoot }); - - // Try to auto-sync it into the database - try { - const syncScript = path.join(process.cwd(), 'src/pages/api/sync-projects.ts'); - // Just ping the sync endpoint internally or rely on the user clicking "Sync" - // For simplicity, we just clone here. The user can click "Synchroniser" on the dashboard. - } catch(e) { - // Ignore sync error - } + // 🛡️ Sentinel: Prevent command injection using execFile instead of exec with string interpolation, + // and use `--` to indicate end of options. + const { stdout, stderr } = await execFileAsync('git', ['clone', '--', authUrl, repoName], { cwd: reposRoot }); return new Response(JSON.stringify({ status: 'ok', @@ -53,7 +55,11 @@ export const POST: APIRoute = async ({ request }) => { }); } catch (error: any) { - return new Response(JSON.stringify({ error: error.message || 'Erreur lors du clonage' }), { + // 🛡️ Sentinel: Prevent token leak in error messages + let msg = error.message || 'Erreur lors du clonage'; + msg = msg.replace(/https:\/\/[^@]+@/g, 'https://***@'); + + return new Response(JSON.stringify({ error: msg }), { status: 500, headers: { 'Content-Type': 'application/json' }, });