How to Patch a SQL Injection Query in a Flask Search Endpoint
A /search?q= endpoint that builds its SQL injection query by f-string concatenation is trivially exploitable: sending q=' OR '1'='1 returns every one of the 3 products instead of a filtered match. The patch is one line - swap the f-string for a parameterized psycopg2 call, cur.execute(sql, (param,)), so the input is bound as a value and can never break out of the query string.
How the injection query works
The vulnerable endpoint builds its SQL by dropping the raw query string straight into the statement with an f-string:
@app.route("/search")
def search():
q = request.args.get("q", "")
cur = _conn.cursor()
# BUG: SQL injection via string concatenation.
cur.execute(f"SELECT id, name FROM products WHERE name LIKE '%{q}%'")
rows = cur.fetchall()
return jsonify([{"id": r[0], "name": r[1]} for r in rows])
A normal request, q=apple, produces the query you expect and matches one row. But the
attacker controls q, so they send q=' OR '1'='1 and the SQL the database actually
parses becomes:
SELECT id, name FROM products WHERE name LIKE '%' OR '1'='1%'
'1'='1' is always true, so the WHERE filter is defeated and every row in products
comes back. On a real users or orders table that is a full data dump. More advanced
payloads chain UNION SELECT to read other tables or call functions that exfiltrate data
out of band. SQL injection has sat at or near the top of the OWASP Top Ten since 2003
precisely because this pattern is so easy to write by accident.
Patch: bind the value with a parameterized query
Never interpolate user input into the SQL text. Pass it as a separate argument so the psycopg2 driver binds it as a value:
@app.route("/search")
def search():
q = request.args.get("q", "")
cur = _conn.cursor()
cur.execute(
"SELECT id, name FROM products WHERE name LIKE %s",
(f"%{q}%",), # note the trailing comma - this is a tuple
)
rows = cur.fetchall()
return jsonify([{"id": r[0], "name": r[1]} for r in rows])
Two things matter here. First, %s is psycopg2's placeholder, not Python's % string
operator - the driver sends the SQL template and the value to PostgreSQL as separate
messages, and the engine never parses the value as SQL. Second, the LIKE wildcards now
wrap the value (f"%{q}%"), not the query text, so partial-match search still works.
The trailing comma is easy to miss: (x) is just x, but (x,) is a one-element tuple,
which is what execute() expects for its parameters.
With the patch in place, ' OR '1'='1 becomes the literal search string
%' OR '1'='1%, which matches zero products.
Verify the patch
Restart the app and hit it with both a real query and the injection payload:
bash /workspace/run.sh
# Real search still works - returns 1 row
curl 'http://localhost:5001/search?q=apple'
# Injection payload - returns [] now, not all 3 rows
curl --get --data-urlencode "q=' OR '1'='1" http://localhost:5001/search
A clean patch returns exactly one row for apple and an empty list for the payload. In
production you would also lock this in with a linter rule (Bandit or Semgrep flags
f-string SQL), a least-privilege database role with no DROP grant, and an ORM that makes
the parameterized path the default.
Want to try it hands-on? HeyDevJob gives you this exact setup in a live cloud workspace in your browser - edit it, run it, and see it work. Free, nothing to install.
Try it in a workspace →What you'll practice
- Reproducing a SQL injection query with the q=' OR '1'='1 payload against a live endpoint
- Replacing f-string SQL with a parameterized psycopg2 call using %s placeholders
- Confirming the patch returns zero rows for the payload while normal search still works
FAQ
What is a SQL injection query?
A SQL injection query is one where attacker-controlled input is concatenated into the SQL text and reinterpreted as code rather than data. A payload like ' OR '1'='1 turns a filtered lookup into a query that matches every row, letting an attacker read data they should never see.
How do I patch a SQL injection in a Flask endpoint?
Replace the f-string query with a parameterized call - cur.execute("... LIKE %s", (f"%{q}%",)) - so psycopg2 binds the input as a value. The database engine never parses that value as SQL, so injection operators become a harmless literal string.
Why does q=' OR '1'='1 return every row?
The single quote closes the string literal early and the OR '1'='1' clause is always true, so the WHERE filter no longer restricts results. Every row in the table matches and is returned, which is a full table dump.
Do I need the trailing comma in the psycopg2 parameters?
Yes. psycopg2 expects a sequence of parameters, so (f"%{q}%",) must be a tuple. Without the comma, (f"%{q}%") is just a plain string and psycopg2 will raise an error or bind it incorrectly.
Keep learning
Learn it by doing. Open this in a live cloud workspace, make the change yourself, and keep a record of the work you can share.
Open the workspace →