How Shell Metacharacter Injection Happens in Node.js SSH Provisioning and How to Fix It
Summary
A critical command injection vulnerability was discovered in services/onuProvisionService.js, where the sanitizeCliInput function only stripped newlines and carriage returns from user input before passing it to SSH shell streams targeting ONU/ONT network devices. Attackers could inject shell metacharacters like semicolons, pipes, and backticks to execute arbitrary commands on network infrastructure. The fix expands the sanitization regex to remove all dangerous shell metacharacters, closing the injection path entirely.
Direct Answer: This is a shell command injection vulnerability (CWE-78) in Node.js. The
sanitizeCliInput()function inservices/onuProvisionService.jsonly removed newlines from user input before writing to an SSH shell stream. The fix replaces the incomplete regex/[\r\n]+/gwith/[\r\n;|&\$()<>!{}\]+/g` to strip all dangerous shell metacharacters.
Introduction
The services/onuProvisionService.js file handles provisioning of ONU (Optical Network Unit) devices — the hardware endpoints in fiber-optic networks made by vendors like ZTE and Huawei. Functions like zteProvisionONU and huaweiProvisionONU accept parameters such as name, pon, and onuId from HTTP request handlers and write them directly to SSH shell streams to configure the devices.
The problem? The function responsible for making those parameters safe — sanitizeCliInput — only removed newline characters. That's a bit like locking your front door but leaving the window wide open.
Here's the vulnerable code that started it all:
function sanitizeCliInput(val) {
if (val === undefined || val === null) return '';
return String(val).replace(/[\r\n]+/g, ' ').trim();
}
At a glance, this looks like it's doing something. It converts the value to a string, strips carriage returns and newlines, and trims whitespace. But it completely ignores the rest of the shell metacharacter alphabet — and that's where the attack lives.
The Vulnerability Explained
What the Code Was Supposed to Do
sanitizeCliInput was clearly written with the intent of preventing command injection by cleaning up user-supplied values before they were sent to SSH shell streams. The sanitizeParams function calls it across all provisioning parameters:
function sanitizeParams(params) {
const clean = {};
if (!params || typeof params !== 'object') return clean;
for (const k of Object.keys(params)) {
clean[k] = typeof params[k] === 'string'
? params[k].replace(/[\r\n]+/g, ' ').trim()
: params[k];
}
return clean;
}
Every string parameter gets the same treatment: strip \r\n, trim. Done. Except it's not done.
Why Newline Stripping Alone Is Not Enough
In a Unix shell, there are many ways to chain or inject commands without ever using a newline character. Here are the metacharacters that the original regex completely ignored:
| Character | Shell Meaning |
|---|---|
; |
Command separator — run next command regardless of exit code |
\| |
Pipe — send output of one command to another |
& |
Background execution / AND operator |
` |
Backtick command substitution |
$() |
Modern command substitution |
<, > |
Input/output redirection |
! |
History expansion / negation |
{, } |
Brace expansion |
\\ |
Escape character |
An attacker doesn't need a newline to inject a command. They just need a semicolon.
A Concrete Attack Scenario
Imagine an HTTP request to the ZTE ONU provisioning endpoint with this payload in the name parameter:
FIBER_USER_001; reboot
After passing through the original sanitizeCliInput:
String("FIBER_USER_001; reboot").replace(/[\r\n]+/g, ' ').trim()
// Result: "FIBER_USER_001; reboot" ← semicolon survives untouched
The semicolon passes through completely intact. When this string is written to the SSH shell stream connected to a ZTE OLT (Optical Line Terminal), the device's CLI interprets it as two separate commands:
1. Whatever the provisioning command was supposed to do with FIBER_USER_001
2. reboot — which reboots the device, causing a service outage
More sophisticated payloads could:
- Execute delete or no commands to remove existing configurations
- Exfiltrate device configuration via display current-configuration | ...
- Create backdoor accounts on the network device
- Disable entire PON ports, taking down hundreds of subscribers
Because this is a web service, these endpoints are directly reachable by remote attackers — no local access required.
The pon and onuId Parameters Are Also Affected
The vulnerability isn't limited to the name parameter. The sanitizeParams function applies the same incomplete sanitization to all string parameters, meaning pon (e.g., 0/1/1; enable) and onuId (e.g., 5; show running-config) are equally exploitable attack surfaces.
The Fix
What Changed
The fix is a targeted regex expansion in two places — the sanitizeCliInput function and the inline sanitization inside sanitizeParams:
Before:
function sanitizeCliInput(val) {
if (val === undefined || val === null) return '';
return String(val).replace(/[\r\n]+/g, ' ').trim();
}
After:
function sanitizeCliInput(val) {
if (val === undefined || val === null) return '';
return String(val).replace(/[\r\n;|&`$()<>!{}\\]+/g, '').trim();
}
And in sanitizeParams:
Before:
clean[k] = typeof params[k] === 'string'
? params[k].replace(/[\r\n]+/g, ' ').trim()
: params[k];
After:
clean[k] = typeof params[k] === 'string'
? params[k].replace(/[\r\n;|&`$()<>!{}\\]+/g, '').trim()
: params[k];
Why This Fix Works
The new regex /[\r\n;|&\$()<>!{}\]+/g` covers the full set of shell metacharacters that could be used to inject commands:
;— command chaining|— piping&— backgrounding/AND`— backtick substitution$— variable expansion and$()substitution(and)— subshell execution<and>— I/O redirection!— history expansion{and}— brace expansion\\— escape sequences
Critically, the replacement value also changed: the old code replaced matched characters with a space (' '), which could still cause issues in some edge cases. The new code replaces with an empty string (''), completely removing the dangerous characters.
Two Locations, One Consistent Fix
It's worth noting that the fix was applied in both sanitizeCliInput and the inline logic in sanitizeParams. This is important — having inconsistent sanitization between the two functions would have left a gap where parameters sanitized via sanitizeParams directly (rather than through sanitizeCliInput) could still be exploited. The consistent application of the same regex in both locations closes that gap.
Prevention & Best Practices
1. Prefer Structured APIs Over Shell Strings
The most robust defense against command injection is to avoid constructing shell commands from strings entirely. Where possible, use structured APIs (like parameterized SSH command execution libraries) that separate commands from data at the protocol level. Libraries like ssh2 in Node.js support executing specific commands with argument arrays rather than raw shell strings.
2. Use an Allowlist, Not Just a Blocklist
The current fix uses a blocklist approach (strip known bad characters). While this is a significant improvement, the gold standard is an allowlist: only permit characters that are explicitly known to be safe for your use case.
For ONU provisioning parameters like name, pon, and onuId, the valid character set is likely very narrow — alphanumeric characters, hyphens, forward slashes, and dots. Consider adding an allowlist validation layer:
function sanitizeCliInput(val) {
if (val === undefined || val === null) return '';
// Allowlist: only permit alphanumeric, hyphen, slash, dot, underscore, space
return String(val).replace(/[^a-zA-Z0-9\-\/\._\s]/g, '').trim();
}
This approach is more restrictive but also more future-proof — it won't miss newly discovered metacharacters.
3. Apply Defense in Depth
Sanitization at the application layer is one layer of defense. Consider also:
- Least-privilege SSH accounts: The SSH user used for provisioning should only have permission to run provisioning commands, not arbitrary shell commands.
- Command allowlisting on the device: Many enterprise OLTs support restricting which CLI commands can be executed by specific users.
- Input validation at the API layer: Validate parameter formats (e.g., onuId should be a small integer) before they ever reach sanitizeCliInput.
4. Test Your Sanitization Functions
Sanitization functions like sanitizeCliInput are security-critical code and deserve dedicated unit tests with adversarial inputs:
describe('sanitizeCliInput', () => {
it('strips semicolons', () => {
expect(sanitizeCliInput('foo; rm -rf /')).toBe('foo rm -rf /');
});
it('strips backticks', () => {
expect(sanitizeCliInput('foo`whoami`')).toBe('foowhoami');
});
it('strips pipe characters', () => {
expect(sanitizeCliInput('foo | cat /etc/passwd')).toBe('foo cat /etcpasswd');
});
it('strips dollar signs', () => {
expect(sanitizeCliInput('foo$(id)')).toBe('fooid');
});
});
5. Relevant Security Standards
- OWASP Command Injection: https://owasp.org/www-community/attacks/Command_Injection
- OWASP Input Validation Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Input_Validation_Cheat_Sheet.html
- CWE-78: Improper Neutralization of Special Elements used in an OS Command
Key Takeaways
- Stripping only
\r\nfrom SSH input is not sufficient sanitization — the originalsanitizeCliInputregex gave a false sense of security while leaving the most dangerous metacharacters (;,|,`,&,$()) completely intact. - Both
sanitizeCliInputandsanitizeParamsneeded the same fix — inconsistent sanitization between two functions that serve the same purpose creates gaps that attackers can exploit. - Network device provisioning endpoints are high-value targets — a successful injection into
zteProvisionONUorhuaweiProvisionONUdoesn't just compromise a server; it can take down physical network infrastructure serving many subscribers. - The
name,pon, andonuIdparameters were all affected — any string parameter flowing throughsanitizeParamswas an injection vector, not just the most obvious ones. - Replacing dangerous characters with empty string is safer than replacing with space — the original code substituted a space, which could still create unexpected command structures in edge cases.
How Orbis AppSec Detected This
- Source: User-controlled HTTP request parameters (
name,pon,onuId) passed to ONU provisioning endpoints (zteProvisionONU,huaweiProvisionONU) - Sink: SSH shell stream write operations in
services/onuProvisionService.js, where sanitized parameter values are written directly to network device CLI sessions - Missing control: The
sanitizeCliInputfunction at line 5 ofservices/onuProvisionService.jsonly filtered\r\ncharacters via/[\r\n]+/g, leaving all shell metacharacters (;,|,&,`,$,(),<>,!,{},\) intact and injectable - CWE: CWE-78 — Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- Fix: The sanitization regex in both
sanitizeCliInputandsanitizeParamswas expanded to/[\r\n;|&\$()<>!{}\]+/g`, removing all shell metacharacters before values reach the SSH stream
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 is a textbook example of how incomplete sanitization can be more dangerous than no sanitization at all — it creates a false sense of security while leaving the door open for attackers. The sanitizeCliInput function in services/onuProvisionService.js was clearly written with security intent, but by only filtering newlines, it missed the entire class of shell metacharacters that make command injection possible.
The fix is elegant in its simplicity: a single regex change in two locations closes the injection path for semicolons, pipes, backticks, subshells, redirections, and escape sequences. But the broader lesson is worth internalizing — whenever you're writing sanitization code for inputs that will touch a shell, CLI, or command interpreter, test it against the full set of shell metacharacters, not just the obvious ones.
For teams building network automation tools, provisioning services, or any system that bridges HTTP APIs to SSH/CLI sessions, this class of vulnerability deserves special attention. The blast radius of a successful injection isn't just a compromised server — it's potentially hundreds of network devices and the subscribers depending on them.