Skip to Content
Course content

286: Executing Parameterized Queries Safely

Click on the "Edit" button in the top corner of the screen to edit your slide content.

I've spent a few too many late nights auditing legacy codebases where I found developers trying to "clean" their input before sticking it into a SQL query. There is a very persistent belief that if you just strip out single quotes or use a `.replace()` method to escape problematic characters, you've effectively neutralized the threat of SQL injection. It feels intuitive—you're just removing the "bad" parts, right?

Thinking "Manual Escaping" is a Security Strategy

Let's look at a piece of code that looks "safe" to a beginner. Imagine you're building a user profile search and you decide to be proactive about security:

# The "I'll just clean the string" approach
username = "admin' --"  # Malicious input
safe_username = username.replace("'", "''") # Escaping the quote
query = f"SELECT * FROM users WHERE username = '{safe_username}'"
# Result: SELECT * FROM users WHERE username = 'admin'' --'

In this specific case, the replace method might actually stop the most basic attack. But here is the problem: you are playing a game of whack-a-mole. Between different character encodings, null bytes, and the specific quirks of different SQL dialects (PostgreSQL vs. MySQL vs. SQLite), there is almost always a way to bypass a manual filter. You're trying to predict every possible way a human can be clever with a string. I can tell you from experience: the hackers are more creative than your .replace() chain.

Letting the Database Driver Handle the Heavy Lifting

The real solution isn't to clean the data; it's to stop treating the data as part of the command. This is where parameterized queries come in. Instead of building a string and sending it to the database, you send a template and a separate list of values.

When you use parameterization, the database driver sends the SQL command and the data in two different packets, or it handles the binding at a protocol level. The database engine receives the query and knows exactly which parts are logic and which parts are just literal values. Even if the user enters ' OR 1=1 --, the database just looks for a user whose literal name is the string ' OR 1=1 --. It never executes that string as code.

Here is how you actually do this using a standard Python DB-API 2.0 compatible driver (like sqlite3 or psycopg2):

import sqlite3

connection = sqlite3.connect("app.db")
cursor = connection.cursor()

# The user input we don't trust
user_id = "123" 

# CORRECT: Use a placeholder (?) and pass values as a tuple
# Note: No f-strings, no % formatting, no .format()
cursor.execute("SELECT * FROM users WHERE id = ?", (user_id,))

result = cursor.fetchone()

A few quick tips on the syntax: notice that the second argument to .execute() is a tuple (user_id,). The trailing comma is mandatory for single-element tuples in Python. If you forget it, Python sees it as parentheses around a string, and the driver will try to iterate over every character in your string as a separate parameter, leading to a confusing "Wrong number of bindings" error.

Whether you use ? (SQLite), %s (PostgreSQL/MySQL), or :name (Oracle), the principle is the same: keep your logic and your data in separate lanes. Never, ever let them merge before they hit the database.




📋 Practical Task

Securing the Product Inventory Lookup

You've been handed a piece of a warehouse management system that is dangerously vulnerable to SQL injection. The current code uses an f-string to build a query for looking up products by their SKU (Stock Keeping Unit).

Your Task: Rewrite the get_product_details function. You must remove the f-string and implement a safe, parameterized query using the provided cursor.execute() method. Ensure that the input sku is passed as a parameter to the driver rather than being interpolated into the string.

import sqlite3

def get_product_details(cursor, sku):
    # VULNERABLE CODE:
    # query = f"SELECT name, price, stock FROM products WHERE sku = '{sku}'"
    # cursor.execute(query)
    
    # TODO: Implement the safe version here
    pass

# Test Setup
conn = sqlite3.connect(":memory:")
cur = conn.cursor()
cur.execute("CREATE TABLE products (sku TEXT, name TEXT, price REAL, stock INTEGER)")
cur.execute("INSERT INTO products VALUES ('ABC-123', 'Mechanical Keyboard', 120.00, 15)")
conn.commit()

# This should work normally
print(get_product_details(cur, 'ABC-123'))

# This malicious input should now return None instead of crashing or leaking data
print(get_product_details(cur, " ' OR '1'='1 "))
Rating
0 0

There are no comments for now.

to be the first to leave a comment.