Skip to content

Commit 0c9cfa3

Browse files
fix(cli): avoid shell execution in module installs (#34)
Co-authored-by: rissrice2105-agent <rissrice2105-agent@users.noreply.github.com>
1 parent d5384d9 commit 0c9cfa3

1 file changed

Lines changed: 58 additions & 10 deletions

File tree

apps/cli/src/commands/modules.ts

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { execSync } from 'node:child_process';
1+
import { execFileSync } from 'node:child_process';
22
import { existsSync, mkdirSync, writeFileSync, cpSync, rmSync, readFileSync } from 'node:fs';
33
import { basename, join, resolve } from 'node:path';
44
import chalk from 'chalk';
@@ -16,6 +16,28 @@ function modulesDir(): string {
1616
return PATHS.moduleDir;
1717
}
1818

19+
function safeModuleDirName(name: string, label = 'module name'): string {
20+
if (!/^[A-Za-z0-9._@-]+$/.test(name) || name === '.' || name === '..') {
21+
throw new Error(`Unsafe ${label}: ${name}`);
22+
}
23+
return name;
24+
}
25+
26+
function moduleDestination(dir: string, name: string, label?: string): string {
27+
return join(dir, safeModuleDirName(name, label));
28+
}
29+
30+
function assertSafeTarballEntries(tarPath: string): void {
31+
const listing = execFileSync('tar', ['-tzf', tarPath], { encoding: 'utf-8' });
32+
for (const entry of listing.split('\n').map((line) => line.trim()).filter(Boolean)) {
33+
const normalized = entry.replace(/\\/g, '/');
34+
const segments = normalized.split('/');
35+
if (normalized.startsWith('/') || segments.includes('..')) {
36+
throw new Error(`Unsafe tarball entry: ${entry}`);
37+
}
38+
}
39+
}
40+
1941
function validateManifest(modPath: string): { ok: boolean; error?: string; name?: string; version?: string } {
2042
const manifestPath = join(modPath, 'mod.toml');
2143
if (!existsSync(manifestPath)) {
@@ -105,7 +127,13 @@ export async function modulesInstallCommand(source: string): Promise<void> {
105127
console.log(chalk.red(` ✗ ${check.error}\n`));
106128
return;
107129
}
108-
const dest = join(dir, check.name!);
130+
let dest: string;
131+
try {
132+
dest = moduleDestination(dir, check.name!);
133+
} catch (err) {
134+
console.log(chalk.red(` x ${(err as Error).message}\n`));
135+
return;
136+
}
109137
if (existsSync(dest)) {
110138
console.log(chalk.yellow(` ! ${check.name} is already installed at ${dest}`));
111139
console.log(chalk.dim(` Run ${chalk.white(`threatcrush modules remove ${check.name}`)} first.\n`));
@@ -130,16 +158,23 @@ export async function modulesInstallCommand(source: string): Promise<void> {
130158
? `https://github.com/${source.slice('github:'.length)}.git`
131159
: source;
132160

133-
const name = basename(gitUrl.replace(/\.git$/, ''));
134-
const dest = join(dir, name);
161+
let name: string;
162+
let dest: string;
163+
try {
164+
name = safeModuleDirName(basename(gitUrl.replace(/\.git$/, '')), 'repository name');
165+
dest = moduleDestination(dir, name);
166+
} catch (err) {
167+
console.log(chalk.red(` x ${(err as Error).message}\n`));
168+
return;
169+
}
135170
if (existsSync(dest)) {
136171
console.log(chalk.yellow(` ! ${name} is already installed at ${dest}`));
137172
console.log(chalk.dim(` Run ${chalk.white(`threatcrush modules remove ${name}`)} first.\n`));
138173
return;
139174
}
140175
const spinner = ora({ text: `Cloning ${gitUrl}...`, color: 'green' }).start();
141176
try {
142-
execSync(`git clone --depth 1 ${gitUrl} ${dest}`, { stdio: 'pipe' });
177+
execFileSync('git', ['clone', '--depth', '1', '--', gitUrl, dest], { stdio: 'pipe' });
143178
} catch (err) {
144179
spinner.fail(`Clone failed: ${(err as Error).message}`);
145180
return;
@@ -201,7 +236,13 @@ export async function modulesInstallCommand(source: string): Promise<void> {
201236
spinner.succeed(`Found ${mod.name} v${mod.version}`);
202237

203238
const install = mod.install;
204-
const dest = join(dir, mod.slug);
239+
let dest: string;
240+
try {
241+
dest = moduleDestination(dir, mod.slug, 'module slug');
242+
} catch (err) {
243+
console.log(chalk.red(` x ${(err as Error).message}\n`));
244+
return;
245+
}
205246
if (existsSync(dest)) {
206247
console.log(chalk.yellow(` ! ${mod.slug} is already installed at ${dest}\n`));
207248
return;
@@ -210,7 +251,7 @@ export async function modulesInstallCommand(source: string): Promise<void> {
210251
if (install.npm_package) {
211252
const npmSpinner = ora({ text: `Installing ${install.npm_package}...`, color: 'green' }).start();
212253
try {
213-
execSync(`npm install -g ${install.npm_package}`, { stdio: 'pipe' });
254+
execFileSync('npm', ['install', '-g', '--', install.npm_package], { stdio: 'pipe' });
214255
npmSpinner.succeed(`Package ${install.npm_package} installed globally`);
215256
} catch (err) {
216257
npmSpinner.fail(`npm install failed: ${(err as Error).message}`);
@@ -219,7 +260,7 @@ export async function modulesInstallCommand(source: string): Promise<void> {
219260
} else if (install.git_url) {
220261
const cloneSpinner = ora({ text: 'Cloning module repository...', color: 'green' }).start();
221262
try {
222-
execSync(`git clone --depth 1 ${install.git_url} ${dest}`, { stdio: 'pipe' });
263+
execFileSync('git', ['clone', '--depth', '1', '--', install.git_url, dest], { stdio: 'pipe' });
223264
} catch (err) {
224265
cloneSpinner.fail(`Clone failed: ${(err as Error).message}`);
225266
return;
@@ -238,7 +279,8 @@ export async function modulesInstallCommand(source: string): Promise<void> {
238279
if (!res.ok) { dlSpinner.fail(`HTTP ${res.status}`); return; }
239280
const tar = join(dir, `${mod.slug}.tar.gz`);
240281
writeFileSync(tar, Buffer.from(await res.arrayBuffer()));
241-
execSync(`tar -xzf ${tar} -C ${dir}`, { stdio: 'pipe' });
282+
assertSafeTarballEntries(tar);
283+
execFileSync('tar', ['-xzf', tar, '-C', dir], { stdio: 'pipe' });
242284
rmSync(tar, { force: true });
243285
const check = validateManifest(dest);
244286
if (!check.ok) {
@@ -266,7 +308,13 @@ export async function modulesRemoveCommand(name: string): Promise<void> {
266308
console.log(chalk.gray(' ' + '─'.repeat(50)));
267309

268310
const dir = modulesDir();
269-
const target = join(dir, name);
311+
let target: string;
312+
try {
313+
target = moduleDestination(dir, name);
314+
} catch (err) {
315+
console.log(chalk.red(` x ${(err as Error).message}\n`));
316+
return;
317+
}
270318
if (!existsSync(target)) {
271319
console.log(chalk.yellow(` ! No module named "${name}" in ${dir}\n`));
272320
return;

0 commit comments

Comments
 (0)