Software Securityhome
Fix a findingSearch
Fix card · A05:2025 Injection

Fix code injection in Python and FastAPI

The vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

In short

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.

Stack
  • Language Python
  • Runtime CPython 3.14.8
  • Framework FastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
Maps to
Verified
Tests pass · fixed, vulnerable and wrong runs as expected
Other stacks4 cards
Download the project ZIP, 17 KB
On this page

Steps

  1. 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.
  2. Replace eval with ast.parse(expression, mode="eval") and a walker that accepts only the node types you intend.
  3. 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.
  4. Reject bool and non-numeric values, catch ZeroDivisionError, and cap ** so a huge exponent cannot hang the worker.
  5. Raise HTTPException(400) for anything refused and keep the detail generic.
  6. Add regression tests with code payloads (a bare secret name, a builtin call, attribute access, object-graph traversal) and assert each is refused.

Vulnerable, fixed and wrong

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

Vulnerableimpl/vulnerable.pyPython
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

Fixedimpl/fixed.pyPython
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

Wrong fiximpl/wrong.pyPython
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 regression test

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.

Testtests/test_compute.pyPython
# 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

Pitfalls

  • 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.

Verify the fix

  1. 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).
  2. 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.
  3. 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.

Scanner rules

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.

Scanner rules for Fix code injection in Python and FastAPI
ToolRuleWhat it found here
Semgreppython.lang.security.audit.eval-detected.eval-detectedTwo 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 .
CodeQLpy/code-injectionCodeInjectionCustomizations.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
BanditB307, B105blacklist: eval; hardcoded_password_stringB307 (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

Libraries and versions

Libraries and versions
ComponentVersion
LanguagePython
RuntimeCPython 3.14.8
FrameworkFastAPI 0.142.2 (Starlette 1.7.0, Pydantic 2.13.5)
fastapi0.142.2
pydantic2.13.5
ast, operator (standard library)Python 3.14.8
httpx2 (TestClient transport, test only)2.13.1
pytest9.1.1

Run the tests

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.

Shell
python3 -m venv .venv
.venv/bin/python -m pip install -r requirements.txt
Shell
# 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 pytest

Each 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.

Download the project ZIP, 17 KB

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

  1. CWE-94: Improper Control of Generation of Code ('Code Injection')
  2. CWE-95: Eval Injection
  3. Python ast module (ast.parse, node types)
  4. FastAPI: Request body with Pydantic models
  5. OWASP Code Injection

Attack patterns:

Find your next answer

Type to search. Search runs in your browser.

Searches run in your browser, but the host can infer them from the index files they load. The address shows your search so you can bookmark or share it.