Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .jules/sentinel.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@

## 2024-05-18 - PM2 API Command Injection Prevention
**Vulnerability:** Found a shell injection vulnerability in `src/pages/api/pm2.ts` where user inputs (appName, scriptPath, env properties) were directly string concatenated into an `exec` command without sanitization.
**Learning:** Concatenating paths and user-provided environment values into a shell execution via `exec` allows an attacker to trivially bypass constraints by appending arbitrary shell commands. This affects local endpoints wrapping complex CLI utilities (like `pm2`).
**Prevention:** Instead of using shell interpolation via `exec`, use `execFile` or `spawn` to run the underlying executable (like `npx` or `npm`) by passing all arguments as an array (`['pm2', 'start', ...]`). Also, pass directory constraints via the `cwd` option, safely pass environment variables through the `env` option (extending `process.env`), and validate user parameters (such as `appName`) to ensure they don't start with a hyphen (`-`) to avoid flag injection.
51 changes: 26 additions & 25 deletions src/pages/api/pm2.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
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';

const execPromise = util.promisify(exec);
const execFilePromise = util.promisify(execFile);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

To ensure cross-platform compatibility (especially on Windows), it's better to define platform-specific command names for npm and npx, as execFile cannot directly execute .cmd files without them.

const execFilePromise = util.promisify(execFile);
const npmCmd = process.platform === 'win32' ? 'npm.cmd' : 'npm';
const npxCmd = process.platform === 'win32' ? 'npx.cmd' : 'npx';


export const POST: APIRoute = async ({ request }) => {
try {
Expand All @@ -13,17 +13,16 @@ export const POST: APIRoute = async ({ request }) => {
return new Response(JSON.stringify({ error: 'action and appName are required' }), { status: 400 });
}

let command = '';
// πŸ›‘οΈ Sentinel: Input validation to prevent flag injection in PM2 CLI
if (appName.startsWith('-')) {
return new Response(JSON.stringify({ error: 'appName cannot start with a hyphen' }), { status: 400 });
}

if (action === 'start' || action === 'start_prod') {
if (!scriptPath) return new Response(JSON.stringify({ error: 'scriptPath required to start' }), { status: 400 });
// Build env variables string
let envString = '';
if (env) {
for (const [key, value] of Object.entries(env)) {
envString += `${key}="${value}" `;
}
}

// Prepare secure environment dictionary instead of string concatenation
const childEnv = { ...process.env, ...(env || {}) };
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

Merging user-provided environment variables directly into the child process environment is a security risk. Attackers can use variables like NODE_OPTIONS or LD_PRELOAD to execute arbitrary code or bypass security constraints. Sanitize the env object by removing sensitive keys.


let npmScript = action === 'start_prod' ? 'start' : 'dev';

Expand Down Expand Up @@ -60,24 +59,26 @@ export const POST: APIRoute = async ({ request }) => {
} catch(e) {}

if (hasBuild) {
command = `cd ${scriptPath} && npm run build && ${envString} npx -y pm2 start npm --name "${appName}" -- run ${npmScript}`;
} else {
command = `cd ${scriptPath} && ${envString} npx -y pm2 start npm --name "${appName}" -- run ${npmScript}`;
// πŸ›‘οΈ Sentinel: Using execFile with array of args to prevent shell injection
await execFilePromise('npm', ['run', 'build'], { cwd: scriptPath, env: childEnv });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security-high high

The scriptPath is used as the cwd for command execution without validation, which could lead to Path Traversal vulnerabilities. Ensure scriptPath is restricted to allowed project directories. Additionally, use the platform-aware npmCmd for Windows compatibility.

}
} else {
command = `cd ${scriptPath} && ${envString} npx -y pm2 start npm --name "${appName}" -- run ${npmScript}`;
}
} else if (action === 'stop') {
command = `npx -y pm2 stop "${appName}"`;
} else if (action === 'delete') {
command = `npx -y pm2 delete "${appName}"`;
} else if (action === 'restart') {
command = `npx -y pm2 restart "${appName}"`;
} else {
return new Response(JSON.stringify({ error: 'Unknown action' }), { status: 400 });

const { stdout, stderr } = await execFilePromise('npx', ['-y', 'pm2', 'start', 'npm', '--name', appName, '--', 'run', npmScript], { cwd: scriptPath, env: childEnv });
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the platform-aware npxCmd defined earlier to ensure this works on Windows systems.

Suggested change
const { stdout, stderr } = await execFilePromise('npx', ['-y', 'pm2', 'start', 'npm', '--name', appName, '--', 'run', npmScript], { cwd: scriptPath, env: childEnv });
const { stdout, stderr } = await execFilePromise(npxCmd, ['-y', 'pm2', 'start', 'npm', '--name', appName, '--', 'run', npmScript], { cwd: scriptPath, env: childEnv });

return new Response(JSON.stringify({ status: 'ok', stdout, stderr }), {
status: 200,
headers: { 'Content-Type': 'application/json' },
});
}

const { stdout, stderr } = await execPromise(command);
let pm2Action = '';
if (action === 'stop') pm2Action = 'stop';
else if (action === 'delete') pm2Action = 'delete';
else if (action === 'restart') pm2Action = 'restart';
else return new Response(JSON.stringify({ error: 'Unknown action' }), { status: 400 });

// πŸ›‘οΈ Sentinel: Using execFile with array of args to prevent shell injection
const { stdout, stderr } = await execFilePromise('npx', ['-y', 'pm2', pm2Action, appName]);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the platform-aware npxCmd defined earlier to ensure this works on Windows systems.

Suggested change
const { stdout, stderr } = await execFilePromise('npx', ['-y', 'pm2', pm2Action, appName]);
const { stdout, stderr } = await execFilePromise(npxCmd, ['-y', 'pm2', pm2Action, appName]);


return new Response(JSON.stringify({ status: 'ok', stdout, stderr }), {
status: 200,
Expand All @@ -93,7 +94,7 @@ export const POST: APIRoute = async ({ request }) => {

export const GET: APIRoute = async () => {
try {
const { stdout } = await execPromise('npx -y pm2 jlist');
const { stdout } = await execFilePromise('npx', ['-y', 'pm2', 'jlist']);
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Use the platform-aware npxCmd defined earlier to ensure this works on Windows systems.

Suggested change
const { stdout } = await execFilePromise('npx', ['-y', 'pm2', 'jlist']);
const { stdout } = await execFilePromise(npxCmd, ['-y', 'pm2', 'jlist']);

const list = JSON.parse(stdout);
return new Response(JSON.stringify(list), {
status: 200,
Expand Down