Skip to content

Someone typed a quote into our search box and read other users’ notes

  • Frameworks · Foundations
  • 20 min read, about 1 h 10 min with practice
  • Concept
  • Asked often: medium
  • Covers OWASP Top 10 2021, Django 5.2, Python 3.11+
  • Reviewed 2026-11

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.

Write find_notes(db, user_id, text): the notes of one user whose body contains the text.

  • Input: a sqlite3 connection, 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'Brien and 100%.
  • 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.

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.

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 &amp;, < becomes &lt;, and quotes become &quot; and &#x27;. 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.

Text that became code

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 --

Three versions of find_notes. The tests attack each one, and also search for real names.

Okay

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.

Better

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 sqlite3
import 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 sqlite3
import 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.

Best

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 sqlite3
import 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 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 to db.execute.
  • Escape \, % and _ in the text, and end the SQL with ESCAPE '\'.
  • Give back the bodies in id order.

Search notes safely

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: User 1 searches “milk” → ['Buy milk']
  2. Not run yet: User 1 searches ' OR owner = 2 -- → []
  3. Not run yet: User 1 searches “O’Brien” → ["Call O'Brien"]
  4. Not run yet: User 1 searches “100%” → ['Sale: 100% off']
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. Not run yet: Extra test

Extra tests check tricky cases. You see their input only if they fail.

Try your own input
WayAttackReal text like O’BrienGoodNot so goodUse it when
Okay: paste into SQLWorksCrashesShortAnyone can read or delete anythingNever
Better: refuse odd charactersBlockedFinds nothingStops this attackBlocks real searches; still builds SQL from textOnly as an extra check on ids or codes
Best: parameters + escaped LIKEBlockedFoundSafe for any textA little more to write; the ORM does it for youEvery query that takes input
  • The ORM has raw doors. .raw(), .extra() and RawSQL in Django, and text() in SQLAlchemy, take SQL. Pass parameters to them too.
  • |safe and Markup turn 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 and onclick attributes 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.

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/41 to /notes/42 shows 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.

Three problems, from easy to medium. Each has hints, tests you can run here, and its own Okay, Better and Best answers.

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">&lt;b&gt;hi&lt;/b&gt;</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 &quot; and ' becomes &#x27;.

Show a comment safely

Ctrl + Enter runs

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: render_comment('Ana', 'Hello') → '<p class="comment" title="Ana">Hello</p>'
  2. Not run yet: A comment holding a script → '<p class="comment" title="Ana">&lt;script&gt;steal()&lt;/script&gt;</p>'
  3. Not run yet: render_comment('Ana', 'Tom & Jerry') → '<p class="comment" title="Ana">Tom &amp; Jerry</p>'
  4. Not run yet: A name that tries to add an attribute → '<p class="comment" title="&quot; onmouseover=&quot;steal()">hi</p>'
  5. 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
Okay

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("<", "&lt;")
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.

Better

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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("<", "&lt;")
text = text.replace("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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.

Best

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("&", "&amp;").replace("<", "&lt;").replace(">", "&gt;")
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;amp;.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: No failures → True
  2. Not run yet: Five failures in the last minute → False
  3. Not run yet: Five failures, over an hour ago → True
  4. Not run yet: Four recent failures → True
  5. Not run yet: Extra test
  6. 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
Okay

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.

Better

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 locked

Where 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.

Best

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 = 5
WINDOW = 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 = 5
WINDOW = 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 < LIMIT

When 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.

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

Loads about 6 MB the first time. Best on Wi-Fi.

  1. Not run yet: Ana edits her own note → True
  2. Not run yet: Ben edits Ana’s note → False
  3. Not run yet: Ben, after Ana shared it with him → True
  4. Not run yet: Nobody logged in → False
  5. Not run yet: Extra test
  6. Not run yet: Extra test
  7. 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
Okay

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.

Better

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.

Best

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.

A few quick questions. Ones you miss are remembered, so you can find your weak spots later.

  1. Spot the bugWhat is wrong with this query?
    sql = f"SELECT * FROM users WHERE email = '{email}'"
    user = db.execute(sql).fetchone()
  2. Pick the patternA comment shows up on the page, and a script in it runs in every reader’s browser. What fixes it?
  3. Multiple choiceBen changes /notes/41 to /notes/42 and sees Ana’s note. What is this called?
  4. Predict the outputIn SQL, what does body LIKE '%100%%' match, if % is not escaped?
  5. Fill in the answerStore passwords with a slow hash and a random ____ for each user.

Add these to your review deck. Each card comes back a little later every time you get it right.

  • 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 _ in LIKE.
  • HTML: let the template escape; avoid |safe on 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.

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