Injection attacks: SQL, command and XSS
See how SQL injection, command injection and XSS work on tiny practice code you run yourself, then fix each with parameterised queries, argument lists, output encoding and a CSP header.
Injection happens when an app builds a command or a page by gluing in untrusted text, and that text changes what the command or page *means*. It is one root cause with three common faces: SQL injection (the database command changes), command injection (the operating-system command changes) and cross-site scripting, XSS (the web page changes and runs script in someone else's browser).
In this tutorial you will reproduce each problem on a tiny practice app on your own machine, with the fix next to it. You will need Python 3.10 or newer; the SQL and command labs use only the standard library. Do this only on your own code or on labs built for it. Do not test these ideas on websites you do not own.
How to work through this tutorial
- 1Create a folder
injection-laband open a terminal in it. - 2Check Python:
python --version(3.10 or newer). - 3Work through the three parts below, saving each file and running it with
python <file>.py. - 4For the CSP part, also install FastAPI:
pip install fastapi uvicorn. - 5Finish with the exercise at the end.
The root cause: data mixed with commands
Think of a restaurant order slip. You write "1 masala chai". If a customer could write "1 masala chai AND give me the cash box" and the kitchen obeyed it as an instruction, the slip would be a command channel. Injection bugs are exactly this: the app lets data (what the user typed) leak into the command (what the interpreter executes).
The general cure has two parts. First, keep data and commands separate using the safe API each interpreter offers (parameterised queries, argument lists, auto-escaping templates). Second, add defence in depth: validate input, run with least privilege and set browser protections.
Part 1: SQL injection
Brew & Bean has a menu search box. The vulnerable version builds the SQL by joining strings. The safe version uses a parameterised query: the SQL text has a ? placeholder, and the value is sent separately, so the database always treats it as data, never as SQL.
Save this as sqli_lab.py. It uses an in-memory SQLite database with a menu table and a staff table, so nothing real is involved.
import sqlite3
conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE menu (id TEXT, name TEXT, category TEXT, price INTEGER)")
conn.executemany(
"INSERT INTO menu VALUES (?, ?, ?, ?)",
[
("espresso", "Espresso", "coffee", 120),
("masala-chai", "Masala Chai", "tea", 90),
("croissant", "Butter Croissant", "bakery", 150),
],
)
conn.execute("CREATE TABLE staff (email TEXT, password_hash TEXT)")
conn.execute("INSERT INTO staff VALUES ('asha@brewandbean.example', 'not-a-real-hash')")
def search_unsafe(term):
# VULNERABLE: user text is glued into the SQL string
sql = "SELECT name, price FROM menu WHERE name LIKE '%" + term + "%'"
return conn.execute(sql).fetchall()
def search_safe(term):
# FIXED: the ? placeholder keeps data separate from the SQL command
sql = "SELECT name, price FROM menu WHERE name LIKE ?"
return conn.execute(sql, (f"%{term}%",)).fetchall()
normal = "chai"
evil = "x' UNION SELECT email, 0 FROM staff --"
print("unsafe, normal:", search_unsafe(normal))
print("unsafe, evil :", search_unsafe(evil))
print("safe, normal:", search_safe(normal))
print("safe, evil :", search_safe(evil))
Run python sqli_lab.py. The evil text contains an apostrophe, which closes the string in the unsafe query, then UNION SELECT asks for a different table.
unsafe, normal: [('Masala Chai', 90)]
unsafe, evil : [('asha@brewandbean.example', 0)]
safe, normal: [('Masala Chai', 90)]
safe, evil : []Read the output. With the unsafe function, the search box returned a staff email from a table the page was never meant to show. With the safe function, the same text is treated as an oddly named drink to search for, finds nothing and does no harm.
Why this works: with ?, the database parses the SQL first and fills in the value afterwards. The value can never become part of the command.
Parameterised queries in common tools
| Library | Safe pattern |
|---|---|
| Python sqlite3 | conn.execute("SELECT ... WHERE name = ?", (value,)) |
| Python psycopg | cur.execute("SELECT ... WHERE name = %s", (value,)) (value is a separate argument) |
| SQLAlchemy | select(Menu).where(Menu.name == value) or text("... :name") with {"name": value} |
| Node mysql2 / pg | db.query("... WHERE name = $1", [value]) (pg) or ? placeholders (mysql2) |
| Any ORM | Use its query builder; avoid raw SQL strings with user values |
Placeholders cannot do everything
Parameters protect values, not SQL pieces like table names, column names or ORDER BY direction. If the user may choose a sort column, map their choice through an allow-list ({"price": "price", "name": "name"}) and never paste their text into the SQL. Also, f-strings and .format() in SQL are the same bug as +.
Extra layers: connect with a database account that has only the permissions the app needs (no DROP, no access to unrelated tables); do not show raw database errors to users; validate types (a quantity must be an integer). These do not replace parameterised queries but they limit the damage.
Part 2: Command injection
If your app runs operating-system commands (converting a file, pinging a host, calling git), never build the command as one string and hand it to a shell. Shell characters like ;, &&, | and backticks let extra commands ride along. For example os.system("nslookup " + host) becomes a way to run any command if host is example.com; something-else.
The fix: prefer a library function over a shell command, and if you must run a program, pass a list of arguments with no shell. The user's text then stays one single argument. Save this as cmd_lab.py.
import subprocess
import sys
user_value = "report.txt; echo INJECTED"
# SAFE: a list of arguments, no shell. The value is passed as plain data.
result = subprocess.run(
[sys.executable, "-c", "import sys; print('argument received:', sys.argv[1])", user_value],
capture_output=True,
text=True,
check=True,
)
print(result.stdout.strip())
The program being run here is Python itself, printing the argument it received. The dangerous-looking ; echo INJECTED does not run, because there is no shell to interpret it.
argument received: report.txt; echo INJECTEDThe whole string arrived as one harmless argument. The same idea in other languages: Node execFile or spawn with an arguments array (not exec with a string), Java ProcessBuilder with separate arguments. Never use shell=True in Python with user text. Also validate: if the input should be a hostname or a file name, check it against a strict pattern or allow-list before use, and keep file paths inside an allowed folder (watch for ../ path traversal).
Part 3: Cross-site scripting (XSS)
XSS is injection into a web page. If your site shows user-supplied text (a customer's name on an order screen, a review, a search term) without making it safe, a malicious value such as a <script> tag can run in another user's browser, for example the staff dashboard. Scripts running on your page can read what the user can see, call your API as them and change the page.
There are three flavours. Stored XSS: the bad text is saved in your database and shown to others. Reflected XSS: the text comes from the URL or form and is echoed back immediately. DOM-based XSS: client-side JavaScript puts untrusted text into the page unsafely.
The cure is output encoding: turn characters that have special meaning in HTML into harmless text at the moment you output them. Save this as xss_lab.py.
from html import escape
comment = '<script>alert("hi")</script>'
print("raw :", f"<p>{comment}</p>")
print("escaped:", f"<p>{escape(comment)}</p>")
html.escape converts <, >, & and quotes into HTML entities. The browser displays them as text instead of treating them as tags.
raw : <p><script>alert("hi")</script></p>
escaped: <p><script>alert("hi")</script></p>You rarely escape by hand. Use tools that do it for you:
React escapes everything you put in { } in JSX. The danger is switching that off: dangerouslySetInnerHTML (the name is a warning), assigning to element.innerHTML, or building HTML strings. Jinja2 in Flask escapes by default for .html templates; avoid the |safe filter on user text. Server-side string building like f"<p>{name}</p>" has no protection at all.
// Safe in React: the text is escaped automatically
function OrderRow({ order }) {
return <p>Pickup for {order.customer_name}</p>;
}
// Risky: only do this with HTML you trust, or after sanitising
function BadRow({ order }) {
return <p dangerouslySetInnerHTML={{ __html: order.customer_name }} />;
}
If customer_name were <img src=x onerror=...>, the first component would show it as plain text and the second would run it. If you really must show user-provided HTML (a rich-text note), clean it with a maintained sanitiser such as DOMPurify first.
Another trap: links. A user-supplied URL such as javascript:... placed in href can run code when clicked. Allow only http: and https: links from users. Also keep user data out of inline <script> blocks and event-handler attributes.
Add browser protection: Content-Security-Policy
A Content-Security-Policy (CSP) header tells the browser which sources of script it may run. A strict policy blocks inline scripts and scripts from unknown sites, so even if an XSS hole slips through, the injected script usually will not execute. It is a second layer, not a replacement for encoding. Here is a small FastAPI middleware that adds it.
from fastapi import FastAPI, Request
app = FastAPI()
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Content-Security-Policy"] = "default-src 'self'; script-src 'self'; object-src 'none'; base-uri 'none'"
response.headers["X-Content-Type-Options"] = "nosniff"
return response
@app.get("/ping")
def ping():
return {"ok": True}
Run uvicorn headers_app:app and open http://127.0.0.1:8000/ping with browser developer tools (Network tab) to see the headers. For a real React app, a strict CSP needs testing because it blocks inline scripts and some third-party widgets; start in report-only mode using the Content-Security-Policy-Report-Only header and tighten step by step.
Also set cookies with HttpOnly (covered in *Passwords, authentication and MFA done right*) so injected script cannot read your session cookie.
Putting it together
One idea, many interpreters
| Injection type | What gets confused | Primary fix | Extra layers |
|---|---|---|---|
| SQL injection | Data becomes SQL | Parameterised queries / ORM | Least-privilege DB user, type validation, hide DB errors |
| Command injection | Data becomes a shell command | Library call, or argument list without shell | Allow-list input, run with low privileges, sandbox |
| XSS | Data becomes HTML or script | Output encoding via framework defaults | CSP, HttpOnly cookies, sanitise any user HTML |
| Others (LDAP, NoSQL, template injection, prompt injection) | Same idea in other interpreters | Keep data and instructions separate | See the AI security lesson for prompt injection |
Injection review checklist
- No SQL is built with
+, f-strings or.format()using user input. - Sort columns and table names come from an allow-list.
- No
shell=True/os.systemwith user data. - User-supplied HTML is never inserted with
innerHTMLordangerouslySetInnerHTMLunless sanitised. - Database user has only the permissions it needs.
- A CSP header is set and tested.
- Static analysis (Semgrep) runs in CI to catch these patterns.
Validate and escape: both, for different reasons
Input validation is useful (quantities must be numbers, phone numbers 10 digits) but it is not the main defence against injection. A customer named O'Brien has a legitimate apostrophe. Encode and parameterise on the way out; validate for correctness on the way in.
You are an application security reviewer. Review the code below for injection vulnerabilities: SQL injection, OS command injection, cross-site scripting and path traversal. For each issue give the line, a one-sentence explanation of the risk, and the fixed code (parameterised query, argument list or output encoding). Do not provide exploit payloads beyond what is needed to explain the risk.
Language and framework: [e.g. Python FastAPI + SQLAlchemy / Node Express + React]
Code:
[paste the code, with secrets removed]What to expect: Check that every fix keeps data and commands separate rather than only filtering characters. Be suspicious of fixes that remove quotes or blocklist words, because filters are easy to bypass. Test your fixed code with an apostrophe and angle brackets in the input.
How this comes up in interviews
Typical questions: "What is SQL injection and how do you prevent it?" (untrusted input changes the SQL; parameterised queries, least privilege). "Why do ORMs not make you immune?" (raw queries and string-built filters). "Stored versus reflected XSS?" "How does React protect against XSS and when does it not?" "What does a CSP do?" Be ready to write a vulnerable and a fixed snippet on a whiteboard.
Try it: Fix and test a search endpoint
Write a FastAPI endpoint GET /menu/search?q= over the menu table from sqli_lab.py using the unsafe string-building style. Write a pytest test that calls it with x' UNION SELECT email, 0 FROM staff -- and shows it leaks a staff email. Then rewrite it with a parameterised query and make the same test assert that the result is an empty list. Finally add an optional sort parameter that accepts only name or price.
Key takeaways
- Injection means untrusted data is interpreted as part of a command or page.
- Use parameterised queries for SQL; placeholders protect values, so allow-list column and table names.
- Never pass user text to a shell; use library calls or argument lists without
shell=True. - Prevent XSS with output encoding (React and Jinja2 do it by default) and avoid
innerHTMLanddangerouslySetInnerHTMLwith user text. - Add layers: least-privilege database user, CSP, HttpOnly cookies and automated scanning.
- Validation helps correctness but is not the main fix; separate data from commands.
Check yourself
Finishing the quiz marks the lesson as done.
1. What makes a parameterised query safe against SQL injection?
2. You must let users pick the sort column in a menu query. What is the safest approach?
3. Why is subprocess.run([prog, user_value]) safer than os.system(prog + ' ' + user_value)?
4. A customer's name is shown in the staff dashboard. Which React code is vulnerable to XSS?
5. What is the role of a Content-Security-Policy header?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.