Skip to content
Episode 88 · The review that ran code

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.

30 min intermediateOWASP Top 10 as a risk checklistSQL injection and parameterized queriesXSS: contextual output encoding and CSPCSRF: SameSite cookies and anti-CSRF tokensSSRF: server-side fetches and egress allow-listsNever trust the client
  1. Problem

    StayHub accepts input from millions of strangers: search boxes, reviews, profile photos by URL, booking forms. Some of those strangers are attackers.

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

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

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

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

01

The problem

2 min
Tuesday, 15:40. Support has escalated 37 account takeovers.
  1. Maya · Product manager
    37 guests say their account email changed today. All of them read Hotel Lisboa's reviews.
  2. Omar · On-call SRE
    A review posted at 14:10 contains <img src=x onerror="fetch('/api/me/email', …)">. 1,830 guests opened that page.
  3. Lina · Senior engineer, your mentor
    You lead the fix. I'll check your reasoning.
02

The big idea

4 min
Data that became code
The big idea

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

03

How it works

5 min
Four attacks, one root cause
  1. 1
    SQL injection

    Injection: input pasted into SQL text changes the query (' OR '1'='1). Fix: , so values never become SQL.

  2. 2
    XSS

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

  3. 3
    CSRF

    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.

  4. 4
    SSRF

    : our server fetches a URL the user chose, including internal ones. Fix: allow-list hosts, check the resolved IP, isolate the fetcher.

  5. 5
    The 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.

04

Watch a decision being made

4 min
Worked example

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.

  1. 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."

  2. 2Find every sink

    Search for raw-HTML sinks. The review component uses dangerouslySetInnerHTML with unsanitized review.html.

    bash
    grep -rn "dangerouslySetInnerHTML\|innerHTML\|v-html" src/

    Why: The bug is where data meets the interpreter, and there may be more than one.

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

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

The whole thing in one line: Contain, find the sink, sanitize at render with an allow-list, then add a CSP and re-authentication so the next slip is caught.
05

In real life

3 min
Shield at the edge vs remove the sink

Cloudflare: 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.

06

What goes wrong

3 min
Sanitizing with a regex that strips `<script>`

The Lisboa payload had no script tag; browsers have many ways to run code.

Instead

A maintained sanitizer with an allow-list, or plain text.

Validating the URL string for SSRF

A hostname can resolve to an internal IP; redirects bounce inside.

Instead

Allow-list hosts, check the resolved IP, refuse redirects, run the fetcher with no cloud credentials.

Changing state on GET requests

An <img src="/bookings/42/cancel"> anywhere triggers it with the guest's cookie.

Instead

POST/PUT/DELETE with CSRF protection.

07

Trade-offs

3 min
OptionChoose whenYou pay
WAF at the edgeEmergency shield, or an extra layerFalse positives, bypasses, false confidence
Fix sinks + strict CSPAny app rendering user contentWeeks on inline scripts and widgets; pages break if enforced too early
SameSite=Lax + anti-CSRF tokensMost consumer web appsToken plumbing in every form and client
Wednesday, 10:00. The morning after.
  1. Karim · CTO
    Buy managed WAF rules. Instant protection, no code changes. Done today.
  2. Lina · Senior engineer, your mentor
    Today's payload, yes. Tomorrow's, written to slip past the rules, no. The bug is still in our code.
  3. Karim · CTO
    Your CSP plan breaks the chat widget and two analytics scripts. That's weeks.
08

Your job as tech lead

4 min

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

Decisions you own
  • 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.
Tell Claude Code
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.
Check before you accept
  • 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 onerror at all.
  • The sanitizer is a known library, not a regex Claude wrote.
Red flags
  • 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.
09

Check yourself

2 min
Q1/3Decision
A teammate proposes rejecting any input containing a single quote as the fix for SQL injection. Your best response?
How confident are you?

Next episode: the payment provider's live secret key turns up in a public code repository, and you have one hour.

Finished?

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

Technical deep dive

Injection and forged requests
Worked example

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.

  1. 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."

  2. 2Find the sink

    Search the codebase for raw-HTML sinks. The review component uses dangerouslySetInnerHTML with unsanitized review.html.

    bash
    grep -rn "dangerouslySetInnerHTML\|innerHTML\|v-html" src/

    Why: The bug is where data meets the interpreter. There may be more than one such place.

  3. 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}.

    tsx
    const 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.

  4. 4Add a CSP as a second layer

    Ship a strict Content-Security-Policy with per-response nonces, in report-only mode first, then enforce.

    http
    Content-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 onerror are blocked and reported instead of run.

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

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

The whole thing in one line: You contained the takeovers first, found the raw-HTML sink, sanitized at render time with an allow-list, added a CSP as a safety net, put re-authentication on email changes, and made new sinks visible in review.
11

Interactive simulation

12

Visual diagram

Click a component

Where each attack enters StayHub and which defence sits on that path.

13

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.

14

Check your understanding

Q1/4Reasoning
Match each attack to its primary structural fix:
SQL injection
Stored XSS
CSRF
SSRF
How confident are you?
15

Practice ladder

Step 1 · Trace

Which of these lines are sinks?

Code Warm-up

A 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:`;
Step 2 · Complete

Make the sort column safe

Code Practice

The 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);
});
Step 3 · Solve

Secure the calendar import

Design Practice

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

Step 4 · Decide

Enforce the CSP now, or report-only first?

Decision Stretch

The 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…".

16

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.

0 words