How it arises in Flask
The debugger. Running with --debug enables Werkzeug's interactive debugger. Flask's documentation is plain about it: the debugger allows executing arbitrary Python code from the browser, it is protected by a PIN that should not be relied on for security, and it must not be enabled in a production environment. It still reaches production through a copied app.run(debug=True) or an environment variable left in a container image.
eval() behind small features. A unit converter, a spreadsheet-style formula field, a "filter" parameter such as ?where=price<10. Python's own evaluator is the shortest implementation. request.args["expr"] goes straight in.
Import by name. werkzeug.utils.import_string(import_name) imports an object from a dotted path or a module:object string; Werkzeug describes it as useful for import paths used as endpoints. Flask's app.config.from_object() accepts the same kind of string. Both are meant for names the developer writes. A plugin system that loads import_string(request.json["handler"]) imports whatever module the caller names, and importing runs that module's top level.
Template context helpers. A helper registered with app.jinja_env.globals that evaluates a string turns any template variable into code.
Vulnerable and fixed
from flask import Flask, jsonify, request
from werkzeug.utils import import_string
app = Flask(__name__)
@app.get("/calc")
def calc():
return jsonify(result=eval(request.args["expr"]))
@app.post("/export")
def export():
exporter = import_string("exporters." + request.json["format"])
return exporter(request.json["rows"])
Arithmetic does not need Python's evaluator. A walk over the parsed syntax tree accepts numbers and four operators and rejects everything else. The exporter is chosen from a dictionary:
import ast
import operator
from flask import Flask, abort, jsonify, request
import exporters
app = Flask(__name__)
OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul, ast.Div: operator.truediv}
EXPORTERS = {"csv": exporters.to_csv, "json": exporters.to_json}
def arithmetic(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.BinOp) and type(node.op) in OPS:
return OPS[type(node.op)](arithmetic(node.left), arithmetic(node.right))
raise ValueError("unsupported expression")
@app.get("/calc")
def calc():
try:
tree = ast.parse(request.args["expr"][:200], mode="eval")
return jsonify(result=arithmetic(tree.body))
except (ValueError, SyntaxError, ZeroDivisionError):
abort(400)
@app.post("/export")
def export():
exporter = EXPORTERS.get(request.json.get("format"))
if exporter is None:
abort(400)
return exporter(request.json["rows"])
What Flask already does for you
Debug mode is off unless you ask for it, and the documentation warns against it in production in two separate places. The debugger is PIN-protected as a second line. Jinja templates rendered by render_template() cannot run arbitrary Python statements. Route converters keep path segments typed. Flask does not offer an "evaluate this" helper; every eval() in a Flask app was added by hand, so a search finds them.
What es-python adds
The Flask-specific event here is interactive console. es-python reports it when a console runs input that came from the network, which is what a reachable debugger looks like at runtime. It carries the rce impact tag, and the Block network code execution template blocks it. If that event ever appears on a production machine, the debugger is on and someone used it.
For the view code, eval, exec and compile are reported when their argument contains request data, graded Critical for data from the network. An import by computed name is reported as __import__.
One thing to expect from the fixed version: ast.parse() cannot run code, but it does compile its input to a syntax tree, so es-python reports it as compile when the input is untrusted. That is accurate and, here, intended. Record the decision with a rule for that call site so it stays out of your default view. This is also why the Block network code execution template raises on eval and exec but leaves compile alone: under it, the vulnerable /calc gets a RuntimeError before the expression runs, and the fixed one keeps working.
Review checklist
- Confirm debug mode is off in every deployed configuration and image.
- Search for
eval(,exec(,import_string(and__import__(. - Replace expression evaluation with a syntax-tree walk or a small parser.
- Load plugins from a dictionary or from entry points you control.
- Keep evaluators out of Jinja globals and filters.
References
- Reference: Flask documentation: Debugging Application Errors (checked 2026-10-12)
- Reference: Werkzeug documentation: Utilities, import_string() (checked 2026-10-12)
- Reference: Flask documentation: Configuration Handling (checked 2026-10-12)