Drill — Threat Model a New Feature
You've read Threat modeling and worked through the capstone's whole-app STRIDE pass on Vulnerly with OWASP Threat Dragon open and an afternoon set aside. That's not the version of this skill you'll actually use most weeks. Most weeks, what lands in front of you is one paragraph describing one new feature, and the real question is whether you can find its top threats before it merges, alone, in about the time it takes to get a coffee. This drill hands you exactly that paragraph — a new file-upload endpoint — and a 20-minute clock. You draw the data-flow diagram, run the STRIDE sweep, and walk away with three ranked threats and a proposed control for each. No tool to install, no whiteboard partner required.
Threat-modeling a whole finished house takes a weekend — you walk every room, check every window, test every lock. This drill is smaller: a new door is about to be installed next week, and before the carpenter shows up you get five minutes to look at the drawing and ask "where could someone get in through this specific door?" You're not re-checking the whole house. You're checking the one new thing being added to it, fast enough that you can do it every single time something new gets added.
The spec you're given — and nothing else
☺ Like you're 10: Real specs are this thin. You don't get a diagram handed to you — drawing one is the first job.
This is the entire brief. Read it exactly once before you start the clock, the way you'd actually receive it in a pull-request description or a ticket:
Add a POST /api/expenses/:claimId/receipts endpoint to the customer expense portal. An authenticated employee attaches a receipt (JPEG, PNG, or PDF, max 10 MB) to their own expense claim. The API stores the file in the receipts-prod S3 bucket under key {claimId}/{uploadId}.{ext}, writes a row to the uploads table with status=pending, and returns 202 Accepted with the uploadId. A background OCR worker polls the uploads table, downloads new files from S3, sends them to a third-party OCR API to extract the merchant name and amount, and PATCHes the claim record with the results. The employee's browser polls GET /api/expenses/:claimId/receipts/:uploadId/status until it reads status=done.
That's it — no architecture diagram, no list of "known risks," no auth model spelled out beyond "authenticated employee." That gap is deliberate and realistic: most feature specs describe the happy path and leave security implicit, which is exactly why the habit has to be yours, not the spec author's.
Set the clock: 20 minutes, four phases
☺ Like you're 10: A stopwatch is part of the drill. Going over on the diagram means running out of time for the part that actually finds threats.
The single most common way this drill goes wrong isn't missing a threat — it's spending eighteen of your twenty minutes making a beautiful diagram and then rushing the STRIDE sweep in the two minutes left. Hold yourself to this split, out loud if it helps:
| Minutes | Phase | What "done" looks like |
|---|---|---|
| 0–2 | Read & list | Every process, data store, and external entity named in the spec, written down as a plain list — nothing drawn yet |
| 2–8 | Draw the DFD | Boxes, arrows, and every trust boundary marked — rough is fine, complete is what matters |
| 8–16 | STRIDE sweep | Walk every boundary crossing, ask all six questions, write threats fast without debating severity yet |
| 16–20 | Triage | Rank the raw list, cut it to three, write one concrete control per threat |
Solo means solo — no whiteboard partner to bounce ideas off, which is the realistic constraint for most feature-sized changes (a full team session is for the capstone-scale exercise, not for every PR). Grab paper, a text file, or OWASP Threat Dragon if you already have it open — the tool doesn't matter here, the twenty minutes does. Start now.
Step 1 — draw the data-flow diagram (minutes 2–8)
☺ Like you're 10: Same three shapes as the treehouse map from Foundations — a circle for outsiders, a box for something that does work, a lined box for something that stores stuff — plus a dashed line every time trust changes.
Reading the spec once gives you six elements and two trust boundaries — most feature-scale DFDs land in that same small range, which is exactly why this fits in six minutes instead of the capstone's full afternoon:
- Employee (browser) — external entity, outside the app entirely.
- Portal API — process; handles the upload and the status poll.
receipts-prodS3 bucket — data store.- Postgres:
uploads+ claim records — data store. - OCR worker — process; runs unattended, in the background, on whatever file was just uploaded.
- Third-party OCR API — external entity, outside your infrastructure entirely, even though nothing in the spec calls it out as a boundary.
That last point is the one most people miss on a first pass: the spec mentions the OCR vendor in the same breath as the internal database, so it reads like an implementation detail. It isn't one — the moment a receipt's bytes leave your infrastructure for someone else's API, that's a second trust boundary, just as real as the one between the employee's browser and your server, and it gets its own line in the diagram and its own STRIDE pass.
If your six-minute sketch looks rougher than this — boxes that aren't square, arrows that double back — that's fine and expected. What actually matters going into the next phase is that every element above appears somewhere on your page and both trust boundaries are marked, because the STRIDE sweep only finds threats at the boundaries you drew. An element you forgot to draw is a threat you will not find in the next eight minutes, no matter how carefully you ask the six questions.
Step 2 — the STRIDE sweep, fast (minutes 8–16)
☺ Like you're 10: Same six questions from Foundations, asked quickly at every dashed line and every arrow — write the answer down the second it occurs to you, don't stop to argue about how bad it is yet.
Work outward from each trust boundary, asking all six STRIDE questions at every crossing before moving to the next one. The goal in this phase is raw coverage, not judgment — write down anything plausible, even threats you're fairly sure won't make the final three. Eight minutes against this diagram surfaces a list roughly this size:
| Where | STRIDE | Raw threat, as written in eight minutes |
|---|---|---|
| Boundary A, upload | Spoofing | If "uploader" is read from a client-supplied form field instead of the verified session, an attacker can spoof whose upload it claims to be |
| Boundary A, upload | Tampering | Server trusts the declared file type — a disguised or polyglot file (wrong bytes, right extension) sails through |
| Boundary A, both routes | Elevation of privilege | Nothing in the spec checks that :claimId belongs to the caller — any authenticated employee can attach to, or read the status of, any claim |
| Boundary A, upload | Denial of service | No stated rate limit or per-user quota — repeated 10 MB uploads exhaust storage and starve the OCR queue for everyone |
| Internal, S3 key | Tampering | The key {claimId}/{uploadId}.{ext} is fully predictable; if uploadId is sequential, an attacker can guess and overwrite another employee's file |
| Internal, whole flow | Repudiation | No audit trail is named anywhere — a disputed or fraudulent receipt can't be traced to who uploaded it, when, or from where |
| Boundary B, send | Information disclosure | Receipt images often carry PII — a merchant name, a partial card number, a home address — and they leave your infrastructure entirely with no stated data-handling agreement or region constraint |
| Boundary B, return | Tampering | The vendor's extracted merchant/amount is PATCHed straight into the claim with no validation — a compromised or misbehaving vendor response can plant a wrong dollar figure |
| Internal, worker creds | Elevation of privilege | The OCR worker's credentials aren't scoped in the spec — if it shares broad service credentials with the rest of the app, a bug in the code path touching attacker-controlled file bytes has a blast radius of the whole database |
Nine raw threats, all six STRIDE letters represented, in eight minutes — that's the actual target for this phase. Notice what didn't happen: at no point did the sweep stop to ask "is this the real design, though?" or "would the team actually build it this way?" Those are good questions, but they belong to the conversation that happens after this drill, once you bring the list to the person who wrote the spec — not to the eight minutes you spend finding what's on the page as written.
Step 3 — triage to the top three (minutes 16–20)
☺ Like you're 10: Nine sticky notes is too many to hand someone in a code review. Three, each with a fix, is something they'll actually read and act on.
Rank the raw nine by blast radius times likelihood — how much damage if it fires, times how easy it would be to trigger against this exact design — and cut to three. This is the honest, defensible answer for this spec, but it's a judgment call, not an objectively unique one; a real reviewer under time pressure can land on a different three and still be doing the exercise correctly, as long as they can justify the cut. Here's the top three from the sweep above:
| # | Threat | Proposed control |
|---|---|---|
| 1 | Broken object-level authorization on :claimId / :uploadId — any authenticated employee can attach a receipt to, or read the OCR status of, any claim, not just their own. This is OWASP API Security Top 10 #1, Broken Object Level Authorization — the single most common API vulnerability class, and the highest blast-radius item here since it exposes every employee's financial data to every other employee. | Add a server-side ownership check — claim.owner_id === session.employee_id — before create and before every status read, not just once at the top of the handler. Replace any sequential uploadId with a UUIDv4, checked the same way, so guessing an ID is no longer sufficient even if the check were ever missed on one route. |
| 2 | Server-side content is never actually validated — only the declared extension and Content-Type header are trusted, both fully attacker-controlled, and the file goes straight into an OCR pipeline built to parse untrusted input. | Sniff the actual file header (magic bytes) server-side — a library like file-type in Node, or libmagic/python-magic — and reject any mismatch between declared and actual type before the file ever reaches S3 or the OCR worker. Run the OCR worker in a container with no outbound network access beyond the OCR vendor's endpoint and least-privilege IAM, so a parser exploit in the vendor's library or an image-processing step can't pivot into SSRF against internal services — the class of bug the "ImageTragick" ImageMagick CVEs made infamous. |
| 3 | Receipt bytes leave the org for a third-party OCR API with no stated data-handling controls — the spec treats the vendor call as an implementation detail, but a receipt is exactly the kind of document that carries PII, and it now transits infrastructure you don't control. | Name the OCR vendor call as its own line item in vendor risk review before this ships — a signed data-processing agreement, a region-pinned endpoint, and a documented retention limit. Where the vendor supports it, redact non-essential fields (full card numbers, addresses) before sending, and treat this the same way Secure by Design & Threat Intelligence treats any new external dependency: a trust boundary that earns its own review, not a detail that rides along with the feature. |
The remaining six — the DoS gap, the predictable S3 key, the missing audit trail, the unvalidated OCR result, and the overly broad worker credentials — don't disappear. They go into the pull request as a shorter "also noted" list, worth a follow-up ticket, but they didn't make the cut this pass because none of them combines the blast radius and ease-of-exploit of the top three. That's the actual output of this drill: not "everything that could go wrong," but a prioritized, defensible three a reviewer can act on before the feature ships.
First: "we only accept JPEG, PNG, and PDF" in the spec is a client-side promise, not a control — nothing in that sentence says the server checks the bytes, and threat #2 above is exactly what happens when a team reads that line and stops looking. Second: it's tempting to skip Boundary B entirely because the OCR call "isn't really part of the feature," the same way Vulnerly's capstone diagram skipped a debug endpoint nobody drew on the "official" architecture. A trust boundary a spec doesn't call out by name is still a trust boundary — it's on you to notice it, every single time a feature reaches outside your own infrastructure.
Why this stays feature-scale, not capstone-scale
☺ Like you're 10: The whole-house inspection happens sometimes. Checking the one new door happens every time something new gets added — which is why it has to be fast enough to actually do.
The capstone's Part 1 models an entire application in OWASP Threat Dragon, ranks at least five threats, and chains three into full attack paths — appropriately, because it's modeling a whole system once, at a scale where a proper tool and a committed file earn their overhead. This drill is the version of the skill you actually reach for dozens of times a year: one feature, one paragraph, twenty minutes, no tool required beyond paper. Both are "threat modeling"; they're sized for different jobs, the same way a full architecture review and a focused code-review comment are both "code review" without being the same amount of work.
The reason this version matters more day to day is timing: Threat modeling already makes the case that a model done once and never revisited quietly goes stale, and Secure SDLC Gates & the DevSecOps Maturity Model covers where a design-time gate like this sits in a maturity model — but neither point survives contact with a team that only knows how to threat model at whole-app scale, because nobody blocks a sprint to run a two-hour session on one new endpoint. A twenty-minute, solo, repeatable habit is what actually closes that gap. Run this drill enough times and it stops being a drill — it's just what you do before you open the pull request.
Rocky the Raccoon: Twenty minutes, one spec, no whiteboard. Six elements, two boundaries — the OCR vendor counts, even though the spec buries it in the same sentence as the database.
Foxy: "Accepted types are JPEG, PNG, or PDF" — that's a client-side promise. What happens the moment someone renames a different file to end in .png?
Rocky the Raccoon: It sails straight into the OCR worker if nothing checks the actual bytes. That's threat two on my list, and it's staying in the top three.
Pip the Hummingbird: And that OCR vendor — a receipt just left your infrastructure entirely, for an API nobody in this spec vouched for. I don't care that it's "just OCR." Something arrived from outside; someone still has to check what it does with what you sent it.
Professor Owl: Which is exactly why a boundary a spec doesn't name is still a boundary. Rocky, what's actually number one?
Rocky the Raccoon: :claimId with no ownership check. Any employee reads or overwrites any other employee's claim. Highest blast radius, easiest to trigger — that's the one I'd fix first if I only had time for one.
1. Name the two trust boundaries in this feature's DFD, and say exactly what crosses each one. 2. Threat #3 in the raw sweep table maps to a specific, named vulnerability class ranked #1 on a real published list — name the class and the list. 3. Why doesn't checking a file's declared Content-Type or extension count as validating it, and what should the server check instead? 4. Why does the OCR vendor call deserve its own trust boundary even though the spec never calls it out as one? 5. This drill's top three left out the missing rate limit and the missing audit trail — name one honest reason a different reviewer, under the same time box, might have kept one of those instead.
Check your answers
- Boundary A sits between the employee's browser and the Portal API — the upload request and the status poll cross it. Boundary B sits between your trusted application tier and the third-party OCR API — the raw receipt bytes cross it outbound, and the extracted merchant/amount fields cross it back inbound.
- Broken Object Level Authorization (BOLA) — ranked #1 on the OWASP API Security Top 10. It's the class of bug where an API checks that a caller is authenticated but never checks that the specific object they're requesting (here, the claim behind
:claimId) actually belongs to them. - Both the extension and the
Content-Typeheader are values the client fully controls and can set to anything regardless of what the file actually contains — they describe what the uploader claims the file is, not what it is. The server should sniff the file's actual header bytes (a magic-byte / content-type-sniffing check, e.g. viafile-typeorlibmagic) and reject any mismatch before the file is stored or processed. - Because a trust boundary is defined by where data crosses between different levels of trust, not by whether a spec happens to label the crossing — the moment receipt bytes leave your infrastructure for a vendor you don't control, that's a different trust level on the other side, full stop, regardless of how casually the spec mentions it.
- Any defensible answer citing a plausible real-world factor counts — for example: a team that's already had an incident involving upload-driven storage costs might weight the DoS threat higher for likelihood; a team in a regulated industry (finance, healthcare) might weight the missing audit trail higher because repudiation has direct compliance consequences there, independent of technical blast radius. The point of the question is that triage is a judgment call made explicit, not an objectively unique answer.
Run this same twenty-minute shape against the next feature-sized spec that lands in your queue — a new webhook receiver, a new export endpoint, a new integration — and it gets faster every time, the same way any drill does. For the full-scale version of this skill, go back through the capstone's Part 1; for the concepts this drill assumes you already have, revisit Threat modeling and the secure SDLC. Once you've got a control you want enforced automatically rather than remembered, the natural next drill is Write a Policy-as-Code Rule.