OWASP Top 10: injection, XSS, CSRF and SSRF
Tuesday, 15:40: a "review" on Hotel Lisboa's page runs JavaScript in every reader's browser. Since 14:10, 1,830 guests have opened it, and 37 of them had their account email silently changed.
Four classic web weaknesses share one root cause. Once you see it, you'll spot them in code reviews and know the proper fix.
- Problem
StayHub accepts input from millions of strangers: search boxes, reviews, profile photos by URL, booking forms. Some of those strangers are attackers.
- Why the simple system fails
Code that glues input into SQL, HTML or URLs lets the input become instructions. Cookies sent automatically let other sites act as the user. A server that fetches any URL becomes a proxy into the private network.
- Engineering concept
Keep data and code in separate channels (parameters, contextual encoding), require proof of intent for state-changing requests, and restrict where the server may connect. Layer defences, because one will fail.
- Technology
Parameterized queries/ORMs, auto-escaping templates, Content Security Policy, HttpOnly + SameSite cookies, anti-CSRF tokens, egress allow-lists, least-privilege database roles, a WAF as an extra layer.
- Trade-offs
Strict CSP and SameSite=Strict break some legitimate flows; WAFs catch known patterns but generate false positives and can't fix the bug; validation helps but can reject real names like O'Brien.
The problem
2 min- Maya · Product manager37 guests say their account email changed today. All of them read Hotel Lisboa's reviews.
- Omar · On-call SREA review posted at 14:10 contains
<img src=x onerror="fetch('/api/me/email', …)">. 1,830 guests opened that page. - Lina · Senior engineer, your mentorYou lead the fix. I'll check your reasoning.
The big idea
4 minUntrusted data must reach every interpreter as data, never as code: fix the place where it's used, not just the door it came in.
A clerk who reads request slips aloud as instructions obeys "give the bearer the master key". A clerk who copies the name into a fixed box on a form just has a guest with a strange name.
How it works
5 min- 1SQL injection
Injection: input pasted into SQL text changes the query (
' OR '1'='1). Fix: , so values never become SQL. - 2XSS
: input inserted as HTML runs as JavaScript for every reader, acting as them. Fix: render as text, or sanitize rich text with an allow-list; add a .
- 3CSRF
Cross-site request forgery: a stranger's page posts to StayHub and the browser adds our cookie. Fix: SameSite cookies + an anti-CSRF token; never change state on GET.
- 4SSRF
: our server fetches a URL the user chose, including internal ones. Fix: allow-list hosts, check the resolved IP, isolate the fetcher.
- 5The common rule
Each place where data meets an interpreter is a sink. Fix the sink. Validating input is good hygiene, but O'Brien and d'Orsay are real names.
Watch a decision being made
4 minYou fix the reviews XSS, Lina checks
Stop the attack, repair the 37 accounts, and make sure no review, old or new, can run code again.
- 1Contain first
Hide the 14:10 review, revoke the 37 guests' sessions, restore their emails from the audit log, cancel pending password resets.
Why: Lina: "Right order. The attacker is resetting passwords now; accounts come before the code fix."
- 2Find every sink
Search for raw-HTML sinks. The review component uses
dangerouslySetInnerHTMLwith unsanitizedreview.html.bashgrep -rn "dangerouslySetInnerHTML\|innerHTML\|v-html" src/Why: The bug is where data meets the interpreter, and there may be more than one.
- 3Fix it at render time
A vetted sanitizer with a small allow-list (p, b, i, ul, li, br), applied at render.
tsx<div dangerouslySetInnerHTML={{ __html: sanitize(review.html, REVIEW_ALLOWLIST) }} />Why: Fixing at render protects every review already stored. An allow-list refuses what it doesn't know; a block-list misses
onerror. - 4Add layers behind it
A strict CSP with nonces, report-only first, then enforced. Changing email now needs the password and notifies the old address.
Why: If another sink slips through, inline handlers are blocked and reported, and a script can't silently take over an account.
In real life
3 minCloudflare: edge rules, fast
- During Log4Shell (December 2021), publicly described rolling out firewall rules for customers within hours
- Protects many sites before their code is patched
Buys time; the bug is still in the code behind it.
Google: make dangerous code hard to write
- Has publicly championed Trusted Types and strict CSP against DOM XSS
- Raw HTML sinks only accept values from approved functions
Slower to adopt; removes the bug class.
What goes wrong
3 minThe Lisboa payload had no script tag; browsers have many ways to run code.
A maintained sanitizer with an allow-list, or plain text.
A hostname can resolve to an internal IP; redirects bounce inside.
Allow-list hosts, check the resolved IP, refuse redirects, run the fetcher with no cloud credentials.
An <img src="/bookings/42/cancel"> anywhere triggers it with the guest's cookie.
POST/PUT/DELETE with CSRF protection.
Trade-offs
3 min| Option | Choose when | You pay |
|---|---|---|
| WAF at the edge | Emergency shield, or an extra layer | False positives, bypasses, false confidence |
| Fix sinks + strict CSP | Any app rendering user content | Weeks on inline scripts and widgets; pages break if enforced too early |
| SameSite=Lax + anti-CSRF tokens | Most consumer web apps | Token plumbing in every form and client |
- Karim · CTOBuy managed WAF rules. Instant protection, no code changes. Done today.
- Lina · Senior engineer, your mentorToday's payload, yes. Tomorrow's, written to slip past the rules, no. The bug is still in our code.
- Karim · CTOYour CSP plan breaks the chat widget and two analytics scripts. That's weeks.
Your job as tech lead
4 minClaude Code can write the code for this. You still own the decisions, and you have to know what good looks like to accept or reject its work.
- Whether reviews may contain rich text at all. Plain text removes the stored-XSS risk; rich text means a sanitizer and a to maintain.
- That every SQL value goes through a , and sort columns come from an allow-list. No exceptions for "internal" reports.
- What the is for: a temporary shield while the real fix ships, never the fix itself.
Security fix for stored XSS in hotel reviews, plan first. Reviews render with dangerouslySetInnerHTML unsanitized; one changed 37 guests' emails. List every place rendering user-supplied HTML or putting user URLs into href or src, with file and line. Propose: a maintained sanitizer allowing only bold, italics, lists and line breaks; https-only links; a nonce-based CSP in report-only mode. Don't touch sessions. Add tests feeding an img with onerror, a javascript: link and a script tag.
- The sink list has more than one entry; if not, ask Claude to search again.
- The payload tests pass and the rendered output has no
onerrorat all. - The sanitizer is a known library, not a regex Claude wrote.
- It blocks the attack with a WAF rule and calls the bug fixed.
- It fixes SQL injection by rejecting quotes, breaking names like O'Brien.
- It disables the CSP because a third-party widget stopped working.
Check yourself
2 minNext episode: the payment provider's live secret key turns up in a public code repository, and you have one hour.
Mark it complete to update your skill map.
Go deeper (optional)You don't need this to understand the lesson. Open it when a project needs the details: Technical deep dive, Interactive simulation, Visual diagram, Analogy, Check your understanding, Practice ladder, Explain it yourself.
Technical deep dive
You fix the reviews XSS, Lina checks
Stop the attack, repair the 37 accounts, and make sure no review (old or new) can run code again.
- 1Contain first
Hide the 14:10 review, revoke all sessions of the 37 affected guests, restore their previous emails from the audit log, and cancel pending password resets.
Why: Lina's check: "Right order. Guests' accounts come before the code fix. The attacker is resetting passwords now."
- 2Find the sink
Search the codebase for raw-HTML sinks. The review component uses
dangerouslySetInnerHTMLwith unsanitizedreview.html.bashgrep -rn "dangerouslySetInnerHTML\|innerHTML\|v-html" src/Why: The bug is where data meets the interpreter. There may be more than one such place.
- 3Fix it at render time
Sanitize with a vetted HTML sanitizer and a small allow-list (p, b, i, ul, li, br), at render. Plain-text fields render as
{review.text}.tsxconst REVIEW_ALLOWLIST = { ALLOWED_TAGS: ["p", "b", "i", "ul", "li", "br"], ALLOWED_ATTR: [] }; <div dangerouslySetInnerHTML={{ __html: sanitize(review.html, REVIEW_ALLOWLIST) }} />Why: Fixing at render protects every review already stored. An allow-list beats a block-list: unknown things are refused.
- 4Add a CSP as a second layer
Ship a strict Content-Security-Policy with per-response nonces, in report-only mode first, then enforce.
httpContent-Security-Policy: default-src 'self'; script-src 'self' 'nonce-r4nd0m'; object-src 'none'; base-uri 'none'Why: If another sink slips through, inline handlers like
onerrorare blocked and reported instead of run. - 5Protect sensitive actions
Changing email or password now requires re-entering the password (or a fresh login) and notifies the old email address.
Why: Even if a script runs as the guest, it can't silently take over the account.
- 6Prevent the next one
A lint rule flags new raw-HTML sinks and string-built SQL; reviews of those lines need a security reviewer.
Why: Make the safe way the easy way. Code review alone missed this one.
Interactive simulation
Visual diagram
Where each attack enters StayHub and which defence sits on that path.
Analogy
- The hotel front deskClerk reads a guest's note aloud as ordersYour web appSQL injection: input runs as a command
- The hotel front deskClerk copies the name into a fixed boxYour web appQuery parameters: input stays data
- The hotel front deskPublic notes shown behind glassYour web appOutput encoding stops XSS
- The hotel front deskDesk only accepts slips with its own codeYour web appAnti-CSRF tokens and SameSite cookies
- The hotel front deskPorter only delivers to approved addressesYour web appAllow-listing stops SSRF
Where the analogy breaks: a clerk can spot something suspicious. Software follows instructions literally, so the protection must be built into how data flows, not left to judgement.
Check your understanding
Practice ladder
Which of these lines are sinks?
Code Warm-upA teammate's pull request renders hotel reviews in React. Trace each line: is it safe, or an XSS sink? Say why.
1. <h3>{review.title}</h3>
2. <div dangerouslySetInnerHTML={{ __html: review.bodyHtml }} />
3. <a href={review.authorWebsite}>Author's site</a>
4. <img alt={review.title} src="/static/star.png" />
5. element.innerHTML = `<b>${review.guestName}</b> wrote:`;Make the sort column safe
Code PracticeThe extranet bookings list lets staff sort by a column. Values are already parameterized, but the column and direction can't be, so they're pasted in. Fill in the two gaps.
const SORTABLE = /* gap 1 */;
app.get("/extranet/bookings", requireAuth, async (req, res) => {
const column = /* gap 2 */;
const direction = req.query.dir === "desc" ? "DESC" : "ASC";
const rows = await db.query(
`SELECT id, guest, check_in, total FROM bookings WHERE hotel_id = $1 ORDER BY ${column} ${direction}`,
[req.auth.hotelId],
);
res.json(rows);
});Secure the calendar import
Design PracticeHotels that also sell rooms elsewhere can paste an iCal URL; StayHub fetches it every 15 minutes to block dates booked on other sites. Hotels can paste any URL. The fetch runs in the same service as payments today.
Design the import so it can't be used for SSRF or to hurt StayHub in other ways.
Enforce the CSP now, or report-only first?
Decision StretchThe review sink is fixed. The new strict CSP is ready. Report-only data from 2 days shows:
- 0 violations from StayHub's own scripts after the nonce change.
- The third-party chat widget (used by ~20% of guests at checkout) uses inline event handlers: ~4,000 violations a day.
- An old analytics snippet on the hotel pages: ~900 violations a day.
The chat vendor says a CSP-compatible version ships in 6 weeks. Write a short decision: "We chose X over Y because…, accepting the risk of…".
Explain it yourself
Explain the common root cause behind SQL injection and XSS, and why CSRF and SSRF are different kinds of trust mistakes. Give the main defence for each.