Never evaluate user input as code. Parse the expression with ast.parse and walk the tree, allowing only numeric literals, the supplied variables and arithmetic operators; refuse everything else. Typing the Pydantic field does not help, because the attack is in the string. eval(expr, {"__builtins__": {}}, vars) is not a sandbox; attribute access still reaches any object.
- Find every eval, exec or compile reached from a path operation's request body (a formula, a filter, a rules expression). A typed Pydantic field does not make the string safe.
- Replace eval with ast.parse(expression, mode="eval") and a walker that accepts only the node types you intend.
- Allow numeric literals, the names in your variables dict, the arithmetic operators you need and parentheses; raise for attribute access, calls, subscripts, strings and every other node.
- Reject bool and non-numeric values, catch ZeroDivisionError, and cap ** so a huge exponent cannot hang the worker.
- Raise HTTPException(400) for anything refused and keep the detail generic.
- Add regression tests with code payloads (a bare secret name, a builtin call, attribute access, object-graph traversal) and assert each is refused.
The three implementations share one API and one test suite; only the code below changes. The tests load the one that IMPL names.
The vulnerable code
def compute(expression: str, variables: dict):
# eval() compiles and runs the string. Passing globals() exposes BUILD_SECRET and the
# builtins; the variables are the locals. This is remote code execution.
return eval(expression, globals(), dict(variables))With IMPL=vulnerable, 5 of 10 tests fail (4 exploit tests).
The fix: parse and walk, never eval
def compute(expression: str, variables: dict):
try:
tree = ast.parse(expression, mode="eval")
except SyntaxError as exc:
raise CalcError("could not parse the expression") from exc
return _eval(tree.body, variables)
def _number(value):
# bool is a subclass of int; reject it so "True" is not treated as 1.
if isinstance(value, bool) or not isinstance(value, (int, float)):
raise CalcError("values must be numbers")
return value
def _eval(node, variables):
if isinstance(node, ast.Constant):
return _number(node.value)
if isinstance(node, ast.Name):
if node.id not in variables:
raise CalcError(f"unknown variable {node.id!r}")
return _number(variables[node.id])
if isinstance(node, ast.UnaryOp) and type(node.op) in _UNARYOPS:
return _UNARYOPS[type(node.op)](_eval(node.operand, variables))
if isinstance(node, ast.BinOp) and type(node.op) in _BINOPS:
left = _eval(node.left, variables)
right = _eval(node.right, variables)
if isinstance(node.op, ast.Pow) and abs(right) > 100:
raise CalcError("exponent too large")
try:
return _BINOPS[type(node.op)](left, right)
except ZeroDivisionError as exc:
raise CalcError("division by zero") from exc
raise CalcError("only arithmetic over the supplied variables is allowed")With IMPL=fixed, 10 of 10 tests pass.
A fix that looks safe but is not
def compute(expression: str, variables: dict):
# Looks locked down: no builtins, only the caller's variables. But eval still runs the
# code, and (42).__class__ ... traverses the object graph from a bare literal.
return eval(expression, {"__builtins__": {}}, dict(variables))With IMPL=wrong, 3 of 10 tests fail (2 exploit tests).
The exploit tests send the attack and check that it fails; functional tests next to them keep ordinary requests working, so a fix that rejects everything fails too.
# Exploit: an "expression" that is really code.
def test_exploit_module_secret_is_not_leaked(client):
status, _, text = compute(client, "BUILD_SECRET")
assert BUILD_SECRET not in text, "the process secret leaked through the expression"
assert status == 400
def test_exploit_builtin_call_is_refused(client):
status, _, _ = compute(client, "abs(-1)")
assert status == 400
def test_exploit_attribute_access_is_refused(client):
status, _, _ = compute(client, "(42).__class__.__name__")
assert status == 400
def test_exploit_object_graph_traversal_is_refused(client):
status, _, _ = compute(client, '"".__class__.__base__.__name__')
assert status == 400- The Python documentation warns that eval() "executes arbitrary code" and that overriding __builtins__ "is not a security mechanism". eval(expr, {"__builtins__": {}}, vars) is the classic non-sandbox: attribute access still walks from (42).__class__ to the object graph and on to os and subprocess.
- Passing globals() (or omitting globals) exposes the module's own names (here BUILD_SECRET) and the builtins to the expression.
- Declaring the field as str or a strict Pydantic type validates the JSON, not the contents; the injection is in the characters of the string.
- Blocklisting __ , import or os is bypassable and breaks legitimate input; allowlist AST nodes instead.
- ast.literal_eval handles literals but not operators, so it cannot compute revenue - cost; use a small walker.
- bool is a subclass of int, so True passes isinstance(x, int); reject bool if you mean numbers.
- A blocking or CPU-heavy evaluation in a plain def runs in FastAPI's thread pool, but ** with a large exponent still burns a worker; cap it. If users must supply code, run it in a separate process or container with limits.
- Run the tests in all three modes: IMPL=fixed must pass; IMPL=vulnerable and IMPL=wrong must fail (node ../../run-all.mjs code-injection/python-fastapi does this for you).
- Against your own endpoint, post expressions that are code -- a bare secret name, abs(-1), (42).__class__.__name__ -- and confirm each is refused with 400 and nothing is leaked; confirm legitimate arithmetic still computes.
- Semgrep eval-detected and Bandit B307 flag the eval() in the vulnerable and wrong code but cannot tell them apart; the regression tests are the control.
What each rule reported when it was run on this card's code, or what its source shows it covers where it was not run. A rule that exists for the weakness does not always catch it.
| Tool | Rule | What it found here |
|---|---|---|
| Semgrep | python.lang.security.audit.eval-detected.eval-detected | Two findings: the eval() in impl/vulnerable.py and in impl/wrong.py; none in impl/fixed.py. The rule matches any eval() call, so it cannot tell the cleared-builtins 'sandbox' from the open one -- both are flagged, which is right here. exec-detected and the packs found nothing else.Checked: Semgrep 1.180.0 (CE) in semgrep/semgrep:1.180.0@sha256:529ee8a277ec8adc5b534d7c74eea0a47e9de21d62852b6ba7ac6ba9566845c3; semgrep scan --metrics=off --config p/python --config p/owasp-top-ten --config p/security-audit --config p/fastapi --config r/python.lang.security.audit.eval-detected.eval-detected --config r/python.lang.security.audit.exec-detected.exec-detected --exclude tests . |
| CodeQL | py/code-injection | CodeInjectionCustomizations.qll treats the code argument of any modelled code execution (eval) as a sink, and FastApi.qll makes the request body a remote source, so the vulnerable and wrong eval(expression, ...) should be reported if CodeQL follows the importlib IMPL loader; the fixed ast walker has no sink.Checked: github/codeql main at b5b8165; not executed |
| Bandit | B307, B105blacklist: eval; hardcoded_password_string | B307 (MEDIUM severity, HIGH confidence) on the eval() in impl/vulnerable.py and impl/wrong.py; nothing in impl/fixed.py. B105 (LOW) on BUILD_SECRET in secret.py, the stand-in secret the exploit tries to read.Checked: Bandit 1.9.4 (pip install bandit==1.9.4 in a scratch venv, Python 3.14.8); bandit -r . -x ./.venv,./tests |
| Component | Version |
|---|---|
| Language | Python |
| Runtime | CPython 3.14.8 |
| Framework | FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5) |
| fastapi | 0.142.2 |
| pydantic | 2.13.5 |
| ast, operator (standard library) | Python 3.14.8 |
| httpx2 (TestClient transport, test only) | 2.13.1 |
| pytest | 9.1.1 |
Download the project, install its dependencies once (this step needs the network), then run the tests against each implementation from the code-injection/python-fastapifolder.
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt# Expected: every test passes
IMPL=fixed .venv/bin/python -m pytest
# Expected: an exploit test fails
IMPL=vulnerable .venv/bin/python -m pytest
# Expected: a test fails
IMPL=wrong .venv/bin/python -m pytestEach run writes a JUnit report to reports/junit.xml. From the top folder of the download, node run-all.mjs code-injection/python-fastapi runs all three modes and checks the expected results.
Verified
- Machine
- macOS 27.0 (Darwin 27.0.0, arm64)
- Toolchain
- python 3.14.8
- pip 26.2.1
- fastapi 0.142.2
- starlette 1.7.0
- pydantic 2.13.5
- httpx2 2.13.1
- pytest 9.1.1
- Matrix
- fixed: 10 of 10 tests pass; vulnerable: 5 of 10 tests fail (4 exploit tests); wrong: 3 of 10 tests fail (2 exploit tests)
- Test results
- IMPL=fixed 10/10 pass; IMPL=vulnerable 5/10 fail (4 exploit: secret leak, builtin call, attribute access, object-graph traversal; plus the non-numeric-variable functional check); IMPL=wrong 3/10 fail (2 exploit: attribute access and object-graph traversal; plus the non-numeric-variable check).
- Dependency freshness
- fastapi 0.142.2 (2026-09-30), starlette 1.7.0 (2026-09-23), pydantic 2.13.5 (2026-08-28) are pinned to match the other FastAPI cards (0.143.0 changed OpenTelemetry defaults; pydantic 2.14.x were under a week old). Python 3.14.8 is the 30 September 2026 security release.
- Scanners
- Semgrep 1.180.0 in the pinned semgrep/semgrep image and Bandit 1.9.4 from a scratch venv, run (see scannerRules); CodeQL read from github/codeql main at b5b8165, not executed.
Versions and results hold for the toolchain above. The card's tests are the check: run them with your own versions before relying on the fix.
References
- CWE-94: Improper Control of Generation of Code ('Code Injection')
- CWE-95: Eval Injection
- Python ast module (ast.parse, node types)
- FastAPI: Request body with Pydantic models
- OWASP Code Injection
Attack patterns: