-
Notifications
You must be signed in to change notification settings - Fork 0
π‘οΈ Sentinel: [CRITICAL] Fix command injection in multiple API routes #81
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. Weβll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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. |
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -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 }); | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+20
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Instead of writing a custom regex to validate the repository name, you can reuse the existing
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| 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}@`); | ||||||||||||||
|
Comment on lines
+39
to
42
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Using simple string replacement ( let parsedUrl: URL;
try {
parsedUrl = new URL(repoUrl);
if (parsedUrl.protocol !== 'https:' || parsedUrl.hostname !== 'github.com') {
return new Response(JSON.stringify({ error: 'URL Github invalide' }), { status: 400 });
}
} catch {
return new Response(JSON.stringify({ error: 'URL Github invalide' }), { status: 400 });
}
parsedUrl.username = 'oauth2';
parsedUrl.password = githubToken;
const authUrl = parsedUrl.toString(); |
||||||||||||||
|
|
||||||||||||||
| // 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' }, | ||||||||||||||
| }); | ||||||||||||||
|
|
||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The current validation of
tailusingparseIntbreaks thetail=allfunctionality, which is a valid and common option in Docker logs. Additionally, it does not handle negative numbers gracefully. We can simplify and secure this by validating thattailis either exactly'all'or a non-negative integer using a regular expression.