Someone typed a quote into our search box and read other users’ notes
See the problem
Section titled “See the problem”Our notes app has a search box. Ana, user 1, searches for “milk” and sees her own notes. Then someone signed in as user 1 types this:
' OR owner = 2 --The results show Ben’s notes, including “Ben’s bank PIN is 4321”. The same week, a comment on a shared page made every reader’s browser send their cookie to another site. Nothing was “hacked” in a clever way. Each time, text from a user was treated as code.
Define the task
Section titled “Define the task”Write find_notes(db, user_id, text): the notes of one user whose body contains the text.
- Input: a
sqlite3connection, the signed-in user’s id, and the search text, typed by anyone. - Output: the matching note bodies, in id order.
- The rule: a user only ever sees their own notes. Any text is a fair search, including
O'Brienand100%. - The hard part: the text must stay data. No character in it may change what the query does.
Example: find_notes(db, 1, "milk") gives ["Buy milk"]. And find_notes(db, 1, "' OR owner = 2 --") gives [], because no note holds that text.
Pause and think
Section titled “Pause and think”Hints open one at a time:
Hint 1 of 3
Write out the SQL with the text pasted in. Where does the quoted text now end?
Hint 2 of 3
After the quote, the rest is read as SQL: OR owner = 2 is a new rule, and -- starts a comment that hides the leftover quote.
Hint 3 of 3
The fix is not to clean the text. It is to never paste it into the SQL at all.
Understand the idea
Section titled “Understand the idea”OWASP (the Open Worldwide Application Security Project) keeps a list of the ten most common web risks. Most of them come down to one of two mistakes: treating input as code, or not checking who is asking. Here are the ones you meet first:
- Broken access control (A01): a user can see or change rows that aren’t theirs, often by changing an id in the URL. Check the owner on every request.
- Injection (A03): text from a user ends up in SQL, a shell command or a template, and runs. Send it as a parameter, apart from the code.
- Cross-site scripting, XSS (also A03): text from a user ends up in a page as HTML, and the browser runs it as a script. Escape it on the way out.
- Authentication failures (A07): weak or fast password checks, endless guessing, sessions that never end. Hash passwords slowly, and limit failed logins.
Query parameters keep text as text. With db.execute("... body LIKE ?", (pattern,)), the database gets the SQL and the value apart. It never reads the value as SQL, whatever quotes or dashes it holds. Every driver and ORM does this; Django’s filter(body__contains=text) builds parameters for you.
Escaping keeps text as text in HTML. & becomes &, < becomes <, and quotes become " and '. The browser then shows the characters instead of reading them as tags. Django and Jinja2 templates escape by default; |safe and Markup turn it off, so use them rarely.
Passwords are stored as slow, salted hashes. A fast hash like SHA-256 lets a thief try billions of guesses a second. Django stores passwords with PBKDF2 and 1,000,000 rounds, or Argon2, each with its own random salt. Never write this yourself; call the framework’s make_password and check_password.
Injection: text pasted into SQL
Parts:
- Attacker’s browser (the request is here)
- Notes app
- Database
- Ana’s browser
Arrows:
- Attacker’s browser sends requests to Notes app
- Notes app sends requests to Database
- Notes app sends requests to Ana’s browser
Step 1 of 7. The attacker, logged in as user 1, types this into the search box: ' OR owner = 2 --
Climb the ladder
Section titled “Climb the ladder”Three versions of find_notes. The tests attack each one, and also search for real names.
Paste the text into the SQL
The idea: Build the query with an f-string, and run it.
It finds “milk”, and the code is easy to read.
import sqlite3
def find_notes(db: sqlite3.Connection, user_id: int, text: str) -> list[str]: sql = f"SELECT body FROM notes WHERE owner = {user_id} AND body LIKE '%{text}%' ORDER BY id" return [row[0] for row in db.execute(sql)]- Attack
Works- O’Brien
Crashes- Why
- Whatever the user types becomes part of the SQL.
Where it breaks: the quote in ' OR owner = 2 -- ends the text early, and the rest runs as SQL. Even an honest search for O'Brien crashes with a syntax error.
Refuse odd characters
The idea: Only search if the text holds just letters, digits and spaces. Otherwise give back nothing.
The waste in Okay: it trusted every character.
Highlighted lines are new in this rung.
import sqlite3import string
SAFE = set(string.ascii_letters + string.digits + " ")
def find_notes(db: sqlite3.Connection, user_id: int, text: str) -> list[str]: if not set(text) <= SAFE: return [] # refuse anything that could break the SQL sql = f"SELECT body FROM notes WHERE owner = {user_id} AND body LIKE '%{text}%' ORDER BY id" return [row[0] for row in db.execute(sql)]- Attack
Blockedwas Works- O’Brien
Finds nothingwas Crashes- Why
- Without quotes or dashes, the text can’t break out of the SQL string.
What changed from the previous rung
import sqlite3import string
SAFE = set(string.ascii_letters + string.digits + " ")
def find_notes(db: sqlite3.Connection, user_id: int, text: str) -> list[str]: if not set(text) <= SAFE: return [] # refuse anything that could break the SQL sql = f"SELECT body FROM notes WHERE owner = {user_id} AND body LIKE '%{text}%' ORDER BY id" return [row[0] for row in db.execute(sql)]Where it breaks: real text has quotes, accents and %. Ana can’t find her note about O’Brien. And the SQL is still built from text, so one missed character, or one new field, opens the hole again.
Parameters, and escape LIKE’s wildcards
The idea: Use ? in the SQL and pass the user id and pattern apart. In the pattern, put \ before %, _ and \ itself.
The waste in Better: it fought the symptom, odd characters, instead of the cause, mixing data into code.
In LIKE, % means “anything” and _ means “any one character”. Without the escape, a search for 100% would also find “100 x faster”. That is not an attack, but it is a wrong answer.
Highlighted lines are new in this rung.
import sqlite3
def find_notes(db: sqlite3.Connection, user_id: int, text: str) -> list[str]: # Escape LIKE's own wildcards, so "100%" means the text "100%". pattern = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") sql = "SELECT body FROM notes WHERE owner = ? AND body LIKE ? ESCAPE '\\' ORDER BY id" return [row[0] for row in db.execute(sql, (user_id, f"%{pattern}%"))]- Attack
Blocked- O’Brien
Foundwas Finds nothing- Why
- The database gets the value apart from the SQL, so no text can change the query.
What changed from the previous rung
import sqlite3import string
SAFE = set(string.ascii_letters + string.digits + " ")
def find_notes(db: sqlite3.Connection, user_id: int, text: str) -> list[str]: if not set(text) <= SAFE: return [] # refuse anything that could break the SQL sql = f"SELECT body FROM notes WHERE owner = {user_id} AND body LIKE '%{text}%' ORDER BY id" return [row[0] for row in db.execute(sql)] # Escape LIKE's own wildcards, so "100%" means the text "100%". pattern = text.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_") sql = "SELECT body FROM notes WHERE owner = ? AND body LIKE ? ESCAPE '\\' ORDER BY id" return [row[0] for row in db.execute(sql, (user_id, f"%{pattern}%"))]When Best isn’t best: in a real app, use the ORM: Note.objects.filter(owner=user, body__contains=text) escapes all of this for you. Parameters can’t hold names like a table or a sort column. For those, pick from an allow list, as in the REST lesson.
Write it yourself
Section titled “Write it yourself”Write find_notes(db, user_id, text) from the task. Each test builds a small notes database first. Press Run tests to check your code.
- Use
?for the user id and the pattern, and pass them as a tuple todb.execute. - Escape
\,%and_in the text, and end the SQL withESCAPE '\'. - Give back the bodies in id order.
Search notes safely
Ctrl + Enter runs- Not run yet:
User 1 searches “milk”→['Buy milk'] - Not run yet:
User 1 searches ' OR owner = 2 --→[] - Not run yet:
User 1 searches “O’Brien”→["Call O'Brien"] - Not run yet:
User 1 searches “100%”→['Sale: 100% off'] - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Compare
Section titled “Compare”| Way | Attack | Real text like O’Brien | Good | Not so good | Use it when |
|---|---|---|---|---|---|
| Okay: paste into SQL | Works | Crashes | Short | Anyone can read or delete anything | Never |
| Better: refuse odd characters | Blocked | Finds nothing | Stops this attack | Blocks real searches; still builds SQL from text | Only as an extra check on ids or codes |
| Best: parameters + escaped LIKE | Blocked | Found | Safe for any text | A little more to write; the ORM does it for you | Every query that takes input |
Common mistakes and edge cases
Section titled “Common mistakes and edge cases”- The ORM has raw doors.
.raw(),.extra()andRawSQLin Django, andtext()in SQLAlchemy, take SQL. Pass parameters to them too. |safeandMarkupturn off escaping. Use them only for HTML you wrote yourself, never for user text.- Escape for the right place. HTML text, an HTML attribute, a URL and JavaScript each need their own escaping. Keep user text out of
<script>blocks andonclickattributes altogether. - Hide whether an account exists. Say “wrong email or password” for both mistakes, so attackers can’t test which emails are signed up.
- Check access on the server, every time. A button you hide in the page can still be called by hand. The third practice problem writes the check.
Say it in the interview
Section titled “Say it in the interview”Questions to ask first
- Where does user input end up: SQL, HTML, a shell, a file path?
- Do we use the ORM and templates, or raw SQL and string building?
- Who may see or change each kind of row?
A 60-second walkthrough
“Most of the OWASP top ten is one of two mistakes: treating input as code, or not checking who is asking. For injection, I never build SQL from strings. I pass values as parameters, or use the ORM, which does that. For XSS, I let the template escape everything, and avoid safe filters on user text. For access, every view checks that the row belongs to the user, not just that someone is logged in. For logins, the framework hashes passwords slowly with a salt. I’d also limit failed logins per account, in a time window, so guessing is slow but users aren’t locked out for ever.”
Likely follow-ups
- Why not just escape quotes? Rules differ between databases and field types, and one forgotten place is enough. Parameters can’t be forgotten that way.
- What is an IDOR? An insecure direct object reference: changing
/notes/41to/notes/42shows someone else’s note. The fix is an owner check in the query. - Why a slow hash for passwords? So a stolen table costs years to crack, not hours. The salt stops one guess from testing every user at once.
- What else is on the list? Security misconfiguration (debug pages on in production), old packages with known holes, and server-side request forgery, where your server fetches a URL the attacker chose.
Practice
Section titled “Practice”Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.
1. Show a comment safely (easy)
Section titled “1. Show a comment safely (easy)”Write render_comment(name, text). It gives back <p class="comment" title="NAME">TEXT</p>, with both parts escaped for HTML: &, <, >, " and '. Use html.escape.
Example: render_comment("Ana", "<b>hi</b>") gives <p class="comment" title="Ana"><b>hi</b></p>.
Hint 1 of 3
Escape & first, or you will escape your own escapes. html.escape does it in the right order.
Hint 2 of 3
The name sits inside title="...". What happens if a name holds a " character?
Hint 3 of 3
html.escape(s) also escapes quotes: " becomes " and ' becomes '.
Show a comment safely
Ctrl + Enter runs- Not run yet:
render_comment('Ana', 'Hello')→'<p class="comment" title="Ana">Hello</p>' - Not run yet:
A comment holding a script→'<p class="comment" title="Ana"><script>steal()</script></p>' - Not run yet:
render_comment('Ana', 'Tom & Jerry')→'<p class="comment" title="Ana">Tom & Jerry</p>' - Not run yet:
A name that tries to add an attribute→'<p class="comment" title="" onmouseover="steal()">hi</p>' - Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Replace < only
The idea: Turn < into < in the text.
It stops the <script> comment.
def render_comment(name: str, text: str) -> str: text = text.replace("<", "<") return f'<p class="comment" title="{name}">{text}</p>'- Stops
Tags in the text- Misses
&, quotes, the name- Why
- Without <, the browser can’t start a tag.
Where it breaks: a lone & makes the HTML wrong, and the name isn’t escaped at all.
Replace &, < and >
The idea: Escape the three tag characters in the text, & first.
The waste in Okay: it escaped one of the characters HTML gives meaning to.
Highlighted lines are new in this rung.
def render_comment(name: str, text: str) -> str: text = text.replace("&", "&").replace("<", "<").replace(">", ">") return f'<p class="comment" title="{name}">{text}</p>'- Stops
Tags in the text- Misses
Quotes, the namewas &, quotes, the name- Why
- The text is now always shown as text.
What changed from the previous rung
def render_comment(name: str, text: str) -> str: text = text.replace("<", "<") text = text.replace("&", "&").replace("<", "<").replace(">", ">") return f'<p class="comment" title="{name}">{text}</p>'Where it breaks: the name sits inside an attribute. A " in the name closes it, and onmouseover="steal()" becomes a real attribute that runs.
html.escape on both parts
The idea: Escape the name and the text with html.escape, which also turns quotes into entities.
The waste in Better: it escaped only one part, for only one place.
Highlighted lines are new in this rung.
from html import escape
def render_comment(name: str, text: str) -> str: # escape() also turns " and ' into entities, so the name can't leave the attribute. return f'<p class="comment" title="{escape(name)}">{escape(text)}</p>'- Stops
Tags and attributeswas Tags in the text- Misses
Nothing in the taskwas Quotes, the name- Why
- Every character with a meaning in HTML text or a quoted attribute is escaped.
What changed from the previous rung
from html import escape
def render_comment(name: str, text: str) -> str: text = text.replace("&", "&").replace("<", "<").replace(">", ">") return f'<p class="comment" title="{name}">{text}</p>' # escape() also turns " and ' into entities, so the name can't leave the attribute. return f'<p class="comment" title="{escape(name)}">{escape(text)}</p>'When Best isn’t best: in a real app, let the template do it: {{ comment.text }} in Django or Jinja2 is escaped already. Escaping by hand and then in the template shows &amp;.
2. Slow down password guessing (medium)
Section titled “2. Slow down password guessing (medium)”Write login_allowed(failures, now). failures holds the times, in seconds, of this account’s failed logins. Allow a new try only if there were fewer than LIMIT = 5 failures in the last WINDOW = 900 seconds (15 minutes). A failure exactly 900 seconds ago no longer counts.
Example: five failures in the last five minutes give False. Five failures an hour ago give True.
Hint 1 of 3
No limit lets an attacker try the 10,000 most common passwords in minutes.
Hint 2 of 3
A limit that never resets lets an attacker lock anyone out on purpose: just guess wrong five times.
Hint 3 of 3
Count only the failures where now - t < WINDOW.
Slow down password guessing
Ctrl + Enter runs- Not run yet:
No failures→True - Not run yet:
Five failures in the last minute→False - Not run yet:
Five failures, over an hour ago→True - Not run yet:
Four recent failures→True - Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
No limit
The idea: Always allow another try.
Users never get stuck.
def login_allowed(failures: list[float], now: float) -> bool: return True # anyone may guess as often as they like- Guesses an hour
As many as the server takes- Locks out users
Never- Why
- Nothing counts the failures.
Where it breaks: an attacker tries every common password, as fast as the server answers.
Five failures, then locked
The idea: Refuse once the account has five failures in total.
The waste in Okay: it let guessing run free.
Highlighted lines are new in this rung.
LIMIT = 5
def login_allowed(failures: list[float], now: float) -> bool: return len(failures) < LIMIT # five wrong tries, ever, and the account is locked- Guesses an hour
5, everwas As many as the server takes- Locks out users
For everwas Never- Why
- After five wrong tries, the account stops answering.
What changed from the previous rung
LIMIT = 5
def login_allowed(failures: list[float], now: float) -> bool: return True # anyone may guess as often as they like return len(failures) < LIMIT # five wrong tries, ever, and the account is lockedWhere it breaks: the lock never ends. A user who mistyped five times last month is stuck, and an attacker can lock out anyone by guessing wrong on purpose.
Five failures in 15 minutes
The idea: Count only the failures inside the window; older ones expire.
The waste in Better: it punished old mistakes for ever.
Highlighted lines are new in this rung.
LIMIT = 5WINDOW = 15 * 60 # seconds
def login_allowed(failures: list[float], now: float) -> bool: recent = sum(1 for t in failures if now - t < WINDOW) return recent < LIMIT- Guesses an hour
About 20was 5, ever- Locks out users
For 15 minutes at mostwas For ever- Why
- Guessing gets very slow, and a real user waits a few minutes at most.
What changed from the previous rung
LIMIT = 5WINDOW = 15 * 60 # seconds
def login_allowed(failures: list[float], now: float) -> bool: return len(failures) < LIMIT # five wrong tries, ever, and the account is locked recent = sum(1 for t in failures if now - t < WINDOW) return recent < LIMITWhen Best isn’t best: an attacker can spread guesses over many accounts. Also limit by IP address, keep the counts in Redis so every server sees them, and add a second factor for important accounts. django-axes does the counting for Django.
3. Who may edit this note? (medium)
Section titled “3. Who may edit this note? (medium)”Write can_edit(user, note). user is None when nobody is logged in, or a dict with id, role and active. note has owner, editors (a list of user ids) and deleted. The rules:
- Nobody logged in, or a user who is not
active: no. - A deleted note: only an
"admin". - Otherwise: the owner, anyone in
editors, or an"admin".
Example: Ben may not edit Ana’s note, unless she added him to its editors.
Hint 1 of 3
Logged in only says who you are. It says nothing about which notes are yours.
Hint 2 of 3
Check the cases that say no first: no user, not active, deleted.
Hint 3 of 3
Then a single line: owner, editor or admin.
Who may edit this note?
Ctrl + Enter runs- Not run yet:
Ana edits her own note→True - Not run yet:
Ben edits Ana’s note→False - Not run yet:
Ben, after Ana shared it with him→True - Not run yet:
Nobody logged in→False - Not run yet: Extra test
- Not run yet: Extra test
- Not run yet: Extra test
Extra tests check tricky cases. You see their input only if they fail.
Try your own input
Show the Okay, Better and Best answers
Logged in is enough
The idea: Allow anyone who is logged in.
It keeps out visitors who aren’t logged in.
def can_edit(user: dict | None, note: dict) -> bool: return user is not None # logged in is enough- Ben edits Ana’s note
Allowed- Edge cases
None checked- Why
- It checks who you are, not what you may touch.
Where it breaks: Ben changes the id in the URL and edits Ana’s note. This is broken access control, number one on the OWASP list.
Owner or admin
The idea: Allow the note’s owner, or an admin.
The waste in Okay: it never looked at the note.
Highlighted lines are new in this rung.
def can_edit(user: dict | None, note: dict) -> bool: return user is not None and (user["id"] == note["owner"] or user["role"] == "admin")- Ben edits Ana’s note
Refusedwas Allowed- Edge cases
Sharing, closed accounts, the binwas None checked- Why
- Each note now belongs to someone.
What changed from the previous rung
def can_edit(user: dict | None, note: dict) -> bool: return user is not None # logged in is enough return user is not None and (user["id"] == note["owner"] or user["role"] == "admin")Where it breaks: a shared note can’t be edited by the people it was shared with. A closed account still works. And the owner can edit a note in the bin.
Say no first, then owner, editor or admin
The idea: Refuse no user, closed accounts and deleted notes (unless admin); then allow owner, editors or admin.
The waste in Better: it had one rule where the app had five.
Highlighted lines are new in this rung.
def can_edit(user: dict | None, note: dict) -> bool: if user is None or not user["active"]: return False if note["deleted"]: return user["role"] == "admin" # only admins may touch the bin return user["id"] == note["owner"] or user["id"] in note["editors"] or user["role"] == "admin"- Ben edits Ana’s note
Refused- Edge cases
All in the taskwas Sharing, closed accounts, the bin- Why
- Every rule is one line, and the ones that refuse come first.
What changed from the previous rung
def can_edit(user: dict | None, note: dict) -> bool: return user is not None and (user["id"] == note["owner"] or user["role"] == "admin") if user is None or not user["active"]: return False if note["deleted"]: return user["role"] == "admin" # only admins may touch the bin return user["id"] == note["owner"] or user["id"] in note["editors"] or user["role"] == "admin"When Best isn’t best: put the rule in the query too, like Note.objects.filter(owner=request.user). Then a list page can’t leak other users’ notes, and a wrong id gives 404, not a hint that the note exists. DRF puts checks like this in a permission class.
Check yourself
Section titled “Check yourself”A few quick questions. Ones you miss are remembered, so you can find your weak spots later.
Flashcards
Section titled “Flashcards”Add these to your review deck. Each card comes back a little later every time you get it right.
Cheat sheet
Section titled “Cheat sheet”- Most risks are input treated as code, or no check on who is asking.
- SQL: parameters (
?,%s) or the ORM. Never f-strings. Escape%and_inLIKE. - HTML: let the template escape; avoid
|safeon user text. - Access: check the owner on the server for every row, ideally in the query.
- Passwords: the framework’s slow, salted hash; limit failures per account in a time window.
Go deeper
Section titled “Go deeper”- OWASP Top 10 (2021), and the cheat sheet series for each risk.
- Django: security overview and password storage.
- Python: sqlite3 placeholders.
- Next on the Frameworks roadmap: ORMs and migrations, the Core level’s start.
Can you do these now?
If you’re not sure of any of these, scroll back to that part before moving on.
Status: Not started