Case Study 1: Securing Meridian's Loan-Origination Application
"The code reviewed clean. The design was the bug." — Sam Whitfield, Security Engineer, Meridian Regional Bank (constructed)
Executive Summary
Meridian is replacing its aging loan-origination workflow with a new web application — internal developers, a tight deadline, real customer financial data. The CISO, Dana Okafor, has one rule learned the hard way: security will not be a pre-launch audit that arrives too late to change anything. Instead, the security team embeds the secure-SDLC practices from this chapter into the build itself. This case study follows Sam Whitfield and junior analyst Theo Brandt through a design-and-build engagement: threat-modeling a key feature, reviewing real (vulnerable-then-fixed) code, running SAST/SCA on the codebase, and turning findings into security requirements the team can actually ship against. You will watch the chapter's patterns — input validation, output encoding, parameterized queries, dependency hygiene, STRIDE — stop being concepts and become a working review. The scenario and all code, figures, and findings are constructed for teaching (Tier 3); the Log4j/Log4Shell vulnerability referenced is real (CVE-2021-44228).
Skills applied: application threat modeling (STRIDE); secure-code review; input validation and output encoding; parameterized queries; software composition analysis; writing verifiable security requirements; risk-based prioritization of findings; designing SSDLC security gates.
Background
The "LoanFlow" application lets an applicant start a loan, upload supporting documents, and check status; a loan officer reviews applications, and an underwriter approves them. It is a textbook web app: a browser front end, an application server holding the business logic, a SQL database of applications and customer data, a file store for uploaded documents, and a handful of third-party libraries doing the unglamorous work (a web framework, a PDF parser, a logging library, an HTTP client). It is in scope for PCI-DSS where it touches payment data and for the GLBA Safeguards Rule throughout, because it is built on customer financial information.
The development team is competent and well-meaning, and — like most teams — has never had a security engineer in the room during design. Dana changes that. She does not hand them a 200-page standard; she embeds Sam for the design and a few review cycles, with a single deliverable in mind: a secure LoanFlow and a repeatable process the team can run themselves next time. Sam's working principle, which he repeats to Theo on day one, is the chapter's thesis: "We are not here to find every bug by hand. We are here to make the secure way the easy way — patterns, tooling, and requirements — and to catch the design flaws no tool can."
The Engagement
Phase 1 — Threat-modeling the highest-stakes feature
Sam refuses to threat-model "the whole application" in one sitting — that produces a vague document nobody uses. Instead he picks the feature with the most dangerous trust boundary: an applicant, from the open internet, uploads documents that a loan officer later views. He runs a one-hour STRIDE session with the two developers before any of that feature's code is written.
They draw it first, because you cannot threat-model what you have not drawn (Figure 12.1.1):
APPLICANT BROWSER ║ trust LOANFLOW APP SERVER SQL DB / FILE STORE
(untrusted internet) ║ boundary
│ upload doc ─────────╫──────────▶ [authn?] ─▶ [validate?] ─▶ [store] ─▶ doc row + file
│ ║ │
LOAN OFFICER BROWSER ║ [authz on retrieval?] ◀── file store
│ view doc ◀──────────╫───────────────────────────── │
│ (internal, but ║ the questions in [brackets] are where threats live
│ still a boundary) ║
Figure 12.1.1 — LoanFlow's document feature and its trust boundaries. The bracketed questions — is the uploader authenticated? is the upload validated? is retrieval authorized? — are exactly where STRIDE forces the team to commit to a control.
Walking STRIDE turns up six threats, and one of them stops the room:
| STRIDE | Threat in LoanFlow | Initial design status |
|---|---|---|
| Spoofing | An unauthenticated user posts to the upload endpoint | Endpoint required login — OK |
| Tampering | Document or metadata altered in transit/at rest | TLS present; at-rest integrity unspecified |
| Repudiation | A disputed fraudulent document — who uploaded it? | No upload audit log existed |
| Information disclosure | Loan officer retrieval trusts the document ID in the URL | No per-document authorization check — a textbook IDOR |
| Denial of service | A huge or malicious file exhausts storage/parsing | No size or type limit |
| Elevation of privilege | An uploaded file is later executed or exploits the PDF parser | Files stored in a path the web server could serve directly |
🚪 Threshold Concept: The most expensive vulnerability in this whole case study cost nothing to fix — because it was caught at the whiteboard. The retrieval endpoint, as originally designed, would have returned any document whose ID appeared in the URL to any logged-in user. Changing
doc_id=8801to8802would have exposed another applicant's tax returns: an insecure direct object reference (OWASP A01), the kind of flaw that ends up in breach notifications. No scanner would have flagged it, because the code did exactly what it was designed to do. The design was the bug. This is the entire argument for threat modeling in one finding: it is the only control that catches a flaw which is not a deviation from the design but is the design.
The session's real output is not the table — it is a set of security requirements the team commits to, each written so a tester can confirm it:
- SR-1: The upload endpoint SHALL reject any request lacking a valid authenticated applicant session.
- SR-2: On every document retrieval, the server SHALL verify the requesting user is authorized for that specific document, keyed to the authenticated identity — never to a client-supplied ID alone.
- SR-3: Uploaded files SHALL be stored outside any web-servable or executable path and SHALL never be executed by the server.
- SR-4: The server SHALL accept only an allowlist of document types (PDF, JPEG, PNG) and enforce a maximum file size; all other uploads SHALL be rejected.
- SR-5: Every upload and retrieval SHALL be logged with user identity, document ID, and timestamp.
Phase 2 — Secure-code review: finding the patterns
With the design corrected, the team builds, and Sam reviews the code as it lands. He is not hunting randomly; he is checking the secure-coding patterns from §12.3 and the requirements from Phase 1. Three findings illustrate the review (each shown vulnerable, then fixed — defensively, never as an exploit).
Finding 1 — the application status query (Injection, A03). A developer wrote the applicant-status lookup by gluing the application ID into a SQL string:
# VULNERABLE: user input concatenated into SQL — the database can't tell code from data
def get_status_bad(app_id):
q = "SELECT status FROM applications WHERE id = '" + app_id + "'"
return db.execute(q)
# An input of 8801' OR '1'='1 would turn the WHERE clause into one that always matches.
The fix is the canonical "separate code from data" pattern — a parameterized query, where the database
driver treats app_id as a value that can never become SQL:
# FIXED: parameterized query — app_id is bound as DATA, never parsed as SQL
def get_status_good(app_id):
q = "SELECT status FROM applications WHERE id = ?" # placeholder, not concatenation
return db.execute(q, (app_id,)) # driver binds the value safely
# Expected behavior (hand-traced): a malicious app_id is looked up as a literal string and
# simply matches no row — it can no longer alter the query's structure.
🔗 Connection: Sam does not let the developer "fix" this by trying to strip dangerous characters from
app_id— that is the denylist trap from §12.3, and it never holds. Parameterization moves the code/data boundary into the driver, which is defense in depth (Chapter 3) layered with the input validation that also checksapp_idis a well-formed identifier. Chapter 13 dissects SQL injection in full; here the review just enforces the pattern that prevents it.
Finding 2 — rendering the applicant's name (the output side). The status page greeted the applicant by
name, inserting it into the HTML without encoding. A name is valid input — even O'Brien or a name
containing < is a legitimate name — so input validation alone would (correctly) let it through. The
missing control is output encoding for the HTML context, which the team's templating engine does
automatically once the developer stops bypassing it:
VULNERABLE: <h1>Welcome, {{ raw_name }}</h1> <!-- raw, unencoded into the page -->
FIXED: <h1>Welcome, {{ name }}</h1> <!-- engine HTML-encodes by default -->
The fix is to STOP disabling the framework's automatic, context-correct encoding.
This is the §12.3 lesson made concrete: validation governs what enters the logic; encoding governs how it leaves, for the destination. The same name is fine as data and dangerous as raw HTML. (The full treatment of cross-site scripting — the attack this prevents — is Chapter 13.)
Finding 3 — a hard-coded secret (Cryptographic Failures / secrets). Theo, doing his first review, spots the highest-severity issue of the day in two lines:
# VULNERABLE: a live database credential committed to the repository
DB_PASSWORD = "Sup3rSecret!2024"
conn = connect(user="loanflow_svc", password=DB_PASSWORD)
Theo flags it correctly and learns why it is severe even though it is one easy line to "fix": the secret is now in the repository's history permanently, readable by everyone who has ever cloned it, and must be rotated, not merely deleted. The interim fix is to inject the secret from the environment; the real fix is a secrets manager — which is Chapter 20's whole subject. Sam adds a secret-scanning check to the pipeline so the next hard-coded credential fails the build automatically.
⚠️ Common Pitfall: Treating a hard-coded-secret finding as "just delete the line." The line is the least of it. A secret that touched version control has leaked; the only safe response is to rotate the credential and assume it is compromised. Deleting the line and moving on leaves a live credential in every clone and backup of the repo — a false sense of security that is worse than the visible problem.
Phase 3 — Software composition analysis: the dependencies nobody chose
Sam's last move is the one that scales beyond what any human review can do: he runs software composition analysis on LoanFlow's full dependency tree — not just the four libraries the developers listed, but the dozens those libraries pull in transitively. The SCA report is sobering precisely because the team had never seen most of the components in it. Among the findings: the PDF-parsing library transitively included an old build of a logging component, and an HTTP client carried a known-vulnerable transitive dependency.
Theo runs the chapter's appsec.py scan_dependencies against a pinned export of the tree to confirm and
communicate the findings to the developers:
# Theo's check (bluekit/appsec.py, Chapter 12) against LoanFlow's pinned dependencies
requirements = [
"log4j-core==2.14.1", # pulled in transitively by the PDF/reporting library
"log4j-core==2.17.1", # a second service already on the patched line
"flask==2.0.1", # not in the toy advisory feed
"openssl==1.0.2k", # transitive, older than the fixed 1.0.2u
]
for pkg, ver, advisory in scan_dependencies(requirements):
print(f"VULNERABLE {pkg}=={ver} ({advisory})")
# Expected output (hand-traced):
# VULNERABLE log4j-core==2.14.1 (CVE-2021-44228)
# VULNERABLE openssl==1.0.2k (CVE-2016-2107)
The log4j-core==2.14.1 line is the case study's quiet payoff: a Log4Shell-vulnerable component was in
LoanFlow not because anyone chose Log4j, but because it rode in three layers down inside a library the team
did choose. This is the chapter's anchor in microcosm — the code you didn't write is still your
responsibility — and it is exactly the kind of finding that, left undiscovered, becomes a 2 a.m. incident
when the next advisory drops. The remediation is to upgrade the offending dependencies (or the parents that
pull them) to patched versions, and to make SCA a standing build gate so a newly-disclosed vulnerability
in an existing dependency raises an alert the day it is published — not the day it is exploited.
🛡️ Defender's Lens: Note the division of labor across Phase 1–3. Threat modeling (Phase 1) caught the design flaw (the IDOR) that no tool could find. Code review and SAST (Phase 2) caught the code patterns (injection, missing encoding, hard-coded secret). SCA (Phase 3) caught the imported risk (the transitive Log4j) that lived in code nobody on the team had read. Three different blind spots, three different controls — and a vulnerability that escaped all three would live in business logic, which is why the threat model exists. This is the four-spaces model from §12.5, walked end to end on one app.
Phase 4 — Turning the engagement into a gate
Sam will not be embedded forever, so the final deliverable is process, not a report. He distills LoanFlow's findings into the SSDLC gates Meridian will run on every application going forward — the program increment this chapter contributes (and the design Chapter 31 automates in CI/CD):
DESIGN ─▶ threat-model (STRIDE) any feature crossing a trust boundary; [BLOCKS release if skipped]
record threats as security requirements
CODE ─▶ SAST on every commit; secure-coding standard enforced [BLOCKS on high-severity]
BUILD ─▶ SCA over the full transitive tree; secret scan [BLOCKS on known-critical dep
or any detected secret]
TEST ─▶ DAST on the deployed build; verify each security requirement [BLOCKS on failed SR]
OPERATE ─▶ security events to SIEM (Ch.21); monitor dep advisories [continuous; feeds Ch.23]
Figure 12.1.2 — LoanFlow's findings, generalized into Meridian's secure-SDLC gates. The "BLOCKS" labels are deliberate: a gate that only warns gets ignored. The judgment is in what blocks (high-confidence, high-severity) versus what merely warns — the tuning that keeps developers shipping.
Discussion Questions
- The IDOR in SR-2 was caught at the whiteboard and cost nothing; had it shipped, it could have exposed customer tax documents. What does this say about where a security program should spend its scarcest resource (senior security attention) — at design, in code review, or in testing?
- Sam refused to let a developer fix the SQL-injection finding by stripping dangerous characters, insisting on parameterization instead. Explain why the denylist "fix" is fragile and the parameterized query is robust, in terms of the code/data boundary.
- The hard-coded secret was "one line to delete" but Sam required a credential rotation and a pipeline secret-scan. Was that proportionate, or security overreach? Argue using the idea that version control retains history.
- The most dangerous dependency (Log4j) was one nobody chose. How should a team make "add a dependency" a conscious security decision without grinding development to a halt?
- Every gate in Figure 12.1.2 either blocks or warns. Pick two gates and argue whether they should block or warn for a team that deploys 30 times a day — and what the cost of getting that choice wrong is.
Your Turn
Take a small web application you understand (or design a trivial one: "users upload a profile photo and others can view it"). Reproduce this engagement on paper: (1) draw the feature and its trust boundary; (2) run STRIDE and produce at least five security requirements; (3) write one vulnerable code snippet and its secure fix for an injection-class issue and one for an output-encoding issue; (4) list the dependencies you would run SCA against and name the artifact (Chapter 23/29) that would let you answer "do we use X?" in minutes. Keep it to two pages. If you cannot make a requirement testable, that is a signal it is still advice, not a requirement — sharpen it.
Key Takeaways
- A secure-development engagement is design-first: the costliest flaw in LoanFlow (an IDOR) was a design flaw caught at the whiteboard for free, where no scanner could have found it.
- STRIDE turns a feature into a checklist of threats, and the real deliverable is verifiable security requirements, not a diagram.
- Code review enforces patterns, not luck: parameterized queries for injection (separate code from data), context-correct output encoding for rendering, and no hard-coded secrets (rotate any that reached the repo).
- SCA finds the risk you didn't choose: a Log4Shell-vulnerable component rode into LoanFlow three layers deep, transitively — the chapter's anchor proving "the code you didn't write is still your responsibility."
- Threat modeling, SAST, and SCA cover three different blind spots (design, your code, imported code); the fourth — business logic — is why human judgment and threat modeling cannot be automated away.
- The lasting deliverable is process, not a report: SSDLC gates that block on high-confidence, high-severity issues and warn on the rest — the design Chapter 31 automates in a pipeline.