How it arises in Flask
A Flask view receives strings. request.args.get("q") returns whatever the client sent, and nothing between the route decorator and your query looks at it. Three shapes account for most findings.
A driver used directly. Small services open sqlite3 or a PostgreSQL driver and call execute() with an f-string. The driver would have bound the value if it had been passed as the second argument.
Flask-SQLAlchemy with a textual query. The extension's documented path is db.session.execute(db.select(User).filter_by(username=username)), which binds values. The same db.session.execute() also accepts db.text(...), and a text() built with an f-string is plain SQL concatenation with an ORM import above it.
Typed route converters that are not used. @app.route("/orders/<int:order_id>") only matches digits, so order_id cannot carry SQL. @app.route("/orders/<order_id>") matches any string. Views that started with the typed form and later moved the value to the query string lose that check without anyone noticing.
Search boxes and sort links are where it concentrates: a LIKE pattern, an ORDER BY column and a page size, all from request.args, all tempting to format into one string.
Vulnerable and fixed
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///shop.db"
db = SQLAlchemy(app)
@app.get("/orders")
def orders():
customer = request.args.get("customer", "")
sort = request.args.get("sort", "created_at")
sql = db.text(
f"SELECT id, total FROM orders WHERE customer = '{customer}' ORDER BY {sort}"
)
rows = db.session.execute(sql).all()
return jsonify([tuple(r) for r in rows])
The value becomes a named bind parameter. The sort key is looked up in a dictionary, so the only strings that can reach the statement are ones written in the source:
from flask import Flask, request, jsonify
from flask_sqlalchemy import SQLAlchemy
app = Flask(__name__)
app.config["SQLALCHEMY_DATABASE_URI"] = "sqlite:///shop.db"
db = SQLAlchemy(app)
SORTABLE = {"created": "created_at", "total": "total"}
@app.get("/orders")
def orders():
customer = request.args.get("customer", "")
column = SORTABLE.get(request.args.get("sort"), "created_at")
sql = db.text(
"SELECT id, total FROM orders WHERE customer = :customer ORDER BY " + column
)
rows = db.session.execute(sql, {"customer": customer}).all()
return jsonify([tuple(r) for r in rows])
What Flask and its extensions already do
Flask's converters (int, float, uuid) reject a request before the view runs when the path segment has the wrong shape. Flask-SQLAlchemy's db.select() constructs bind every value. SQLAlchemy's text() supports :name parameters. Used as documented, each of them keeps data out of the statement. None of them can help with a string that was already formatted before it reached them.
What es-python adds
Run the same Flask app with es-python and the socket source on. When characters from the request are present in the statement text at the moment it is executed, an event is recorded: sql injection for SQLAlchemy's text(), and sqlite execute when the code calls the standard sqlite3 module directly. Each carries the sqli impact tag, the value, and the stack trace down to your view function.
Two things make this useful next to code review. It does not depend on the shape of the call, so a query assembled in a helper module, or in a Flask extension you did not write, is reported the same way. And a bound parameter never appears in the statement text, so the fixed view above reports nothing for customer. The sort column is still a request-chosen string inside SQL; when EyalSec cannot prove it harmless the event stays, and a rule records that you checked the allowlist.
To stop the statement instead of logging it, the ready-made Block backend network attacks template raises on sql injection and sqlite execute; under it the view gets a RuntimeError before the database sees the query.
Review checklist
- Search for
text(f",text("...".format, andexecute(f". - Prefer
db.select()andfilter_by()over textual SQL. - Use typed route converters for identifiers in the path.
- Send
LIKEpatterns as parameters; add the%in Python, not in the SQL string. - Allowlist sort columns and directions.
References
- Reference: Flask-SQLAlchemy documentation: Modifying and Querying Data (checked 2026-10-12)
- Reference: SQLAlchemy documentation: text() (checked 2026-10-12)
- Reference: Flask documentation: API, URL route registrations (checked 2026-10-12)