From 59ff8fbd47e2fa19bab02d5a52d0cc0d9c01e042 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Thu, 14 May 2026 17:53:43 +0000 Subject: [PATCH] =?UTF-8?q?=F0=9F=9B=A1=EF=B8=8F=20Sentinel:=20[CRITICAL]?= =?UTF-8?q?=20Fix=20command=20injection=20and=20secret=20leak=20in=20githu?= =?UTF-8?q?b-clone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: bobdivx <6737167+bobdivx@users.noreply.github.com> --- .jules/sentinel.md | 4 ++++ src/pages/api/github-clone.ts | 19 +++++++++++++++---- 2 files changed, 19 insertions(+), 4 deletions(-) create mode 100644 .jules/sentinel.md diff --git a/.jules/sentinel.md b/.jules/sentinel.md new file mode 100644 index 00000000..aa7a416d --- /dev/null +++ b/.jules/sentinel.md @@ -0,0 +1,4 @@ +## 2024-10-24 - Secret Leakage in Node.js child_process errors +**Vulnerability:** When executing shell commands (via `exec` or `execFile`) that contain secrets (like an embedded `oauth2:@github.com` in a git clone URL), if the command fails, the `error.message` thrown by Node.js includes the full command string, exposing the secret. If this error is passed back in an API response (e.g. `return { error: error.message }`), the secret is leaked to the client. +**Learning:** Node.js `child_process` utilities include the executed command (and its arguments) in the error object when a process exits with a non-zero code. This means we must always sanitize the `error.message` before returning it in any external response. +**Prevention:** Always use regex or string replacement to redact secrets (e.g. `.replace(/oauth2:[^@]+@/g, 'oauth2:***@')`) from `error.message` when a shell command fails, or better yet, return a generic error message and log the real error server-side. diff --git a/src/pages/api/github-clone.ts b/src/pages/api/github-clone.ts index 5ff437b8..52e67994 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 }); } + // 🛡️ Security: Prevent flag injection by ensuring inputs don't start with a hyphen + if (repoUrl.startsWith('-') || repoName.startsWith('-')) { + return new Response(JSON.stringify({ error: 'Les paramètres ne peuvent pas commencer par un tiret' }), { 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 +38,9 @@ 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 with array instead of exec with string concatenation + // to prevent command injection + const { stdout, stderr } = await execFileAsync('git', ['clone', authUrl, repoName], { cwd: reposRoot }); // Try to auto-sync it into the database try { @@ -53,7 +60,11 @@ export const POST: APIRoute = async ({ request }) => { }); } catch (error: any) { - return new Response(JSON.stringify({ error: error.message || 'Erreur lors du clonage' }), { + // 🛡️ Security: Sanitize error message to prevent leaking GitHub token to the client + // since Node.js exec/execFile errors include the command executed + const safeErrorMsg = (error.message || 'Erreur lors du clonage').replace(/oauth2:[^@]+@/g, 'oauth2:***@'); + + return new Response(JSON.stringify({ error: safeErrorMsg }), { status: 500, headers: { 'Content-Type': 'application/json' }, });