4.5.23 · D3Software Engineering

Worked examples — Security — OWASP Top 10, input validation, authentication vs authorization

3,166 words14 min readBack to topic

The scenario matrix

Every security bug on this topic falls into one of these case classes. Think of it like the quadrants of a graph: miss one, and that's exactly where you get breached.

Cell Case class The question it asks Covered by
C1 Injection — data becomes code Can user input change the parse tree? Example 1
C2 Empty / degenerate input What if the input is "", null, or absurdly long? Example 2
C3 Valid-but-hostile input Input passes the type check yet still attacks Example 3 (XSS)
C4 AuthN present, AuthZ absent Logged in ≠ allowed (the IDOR trap) Example 4
C5 Anonymous subject AuthZ when there is no identity yet Example 5
C6 Cryptographic storage Secret at rest — plaintext vs salted-slow-hash Example 6
C7 Real-world word problem End-to-end: signup → login → action Example 7
C8 Exam twist — two look-alike fixes Blocklist vs parameterize; which is real? Example 8
Figure — Security — OWASP Top 10, input validation, authentication vs authorization

The worked examples

Example 1 — Injection: data becomes code · Cell C1

Step 1 — Substitute the attacker's text literally. The + operator just pastes characters. So the final string is:

SELECT * FROM users WHERE name = 'x' OR '1'='1'

Why this step? String concatenation has no idea what a quote means — to it, ' is just character number 39. We must read the result as raw text before the database parses it.

Step 2 — Let the database parse that text into a WHERE condition. The condition is now name = 'x' OR '1'='1'. This is two comparisons joined by OR. Why this step? The SQL engine builds a parse tree from the text. The attacker's ' OR '1'='1 closed our string early and added a new node to that tree — an OR branch we never wrote.

Step 3 — Evaluate the condition for a typical row. '1'='1' is always true. anything OR true is true. So the WHERE clause is true for every row. Why this step? A WHERE clause that is always true is the same as no filter at all — the query returns the entire table.

Step 4 — The fix: parameterize.

cur.execute("SELECT * FROM users WHERE name = ?", (name,))

Why this step? Now the driver compiles the template ... name = ? first, freezing the parse tree, then binds name as a pure value at a leaf. The string x' OR '1'='1 becomes a username to search for — no rows match it, and it cannot add an OR node.

Verify: Suppose the users table has 3 rows. The vulnerable query returns all 3 (WHERE is always true). The parameterized query searches for a user literally named x' OR '1'='1, which does not exist → 0 rows. Sanity check: a safe search for a nonexistent name should return 0, and it does.


Example 2 — Empty & degenerate input · Cell C2

Step 1 — Empty string "". An empty comment might slip past a naive if comment: save() check (empty string is falsy) — but if it does save, an empty comment corrupts your UI and can break downstream code that assumes non-empty text. Why this step? Degenerate inputs test your assumptions. Code that assumes "there's always some text" fails silently on "".

Step 2 — null / missing field. If the field is absent, request.form["comment"] raises KeyError, and a verbose error page may leak a stack trace. Why this step? A crash that prints internals is Security Misconfiguration (A05) — the error itself hands the attacker a map of your code.

Step 3 — 5-million-character string. No length cap means one request can exhaust memory or fill the database → Denial of Service. Why this step? "It's just text" is false: size is an attack surface. Allowlist validation includes a maximum length.

Step 4 — The fix: allowlist with bounds. Accept comments matching: non-empty, at most (say) 2000 characters, printable characters only. Why this step? You enumerate the small set of good (1–2000 printable chars), so every degenerate input — empty, null, giant — is rejected by one rule.

Verify: Length check: a 5-million-char string has length → rejected. Empty string has length , and (below the minimum of 1) → rejected. Both fail the single bound . Sanity: a normal 50-char comment satisfies → accepted. ✓


Example 3 — Valid-but-hostile input (XSS) · Cell C3

Step 1 — Identify the destination context. The bio is later rendered into an HTML page: <div> + bio + </div>. Why this step? Input isn't "safe" or "unsafe" in a vacuum — it's safe for a context. This same bio is fine in a plain-text log but dangerous in HTML. (See HTTP and Web Fundamentals for how the browser parses HTML.)

Step 2 — See what the browser does. The browser reads <script>steal()</script> as a real script tag and runs steal() in the victim's session. Why this step? This is Injection → Cross-Site Scripting: data was interpreted as code, exactly the C1 pattern but the "interpreter" is now the browser, not the database.

Step 3 — Encode for the HTML-body context. Replace the special characters: So the stored bio renders as &lt;script&gt;steal()&lt;/script&gt;. Why this step? Now the browser displays the literal text <script>steal()</script> instead of executing it. Same bytes stored — neutralized at output for its specific context.

Step 4 — Why not just delete <script>? Because <img src=x onerror=steal()>, <svg onload=...>, and case-variants all bypass a blocklist. Encoding at output is contextual and complete; blocklisting tags is the infinite game.

Verify: The rendered output contains zero literal <script> open-tags after encoding — every < became &lt;. Character count sanity: <script>steal()</script> has 2 literal < characters; after encoding, 0 remain and 2 &lt; sequences appear. ✓


Example 4 — AuthN present, AuthZ absent (IDOR) · Cell C4

Step 1 — Confirm authentication happened. Bob's token is valid → the server knows who is asking: current_user.id = Bob. Why this step? AuthN answers "who are you?" and it succeeded. That's the trap — everything feels legitimate.

Step 2 — Notice what's missing. Nowhere does the code ask "does Bob own order 1042?" It returns any order by ID. Why this step? Authentication ≠ authorization. Knowing who Bob is says nothing about whether Bob may see object 1042. This is Broken Access Control (A01) via Insecure Direct Object Reference.

Step 3 — The fix: authorize per-object, default-deny.

def get_order(order_id):
    order = db.orders[order_id]
    if order.owner_id != current_user.id:
        return Response(status=403)
    return order

Why this step? On every request we re-check ownership. See Principle of Least Privilege: Bob gets only the objects that are his.

Verify: With Bob (id=Bob) requesting order 1042 whose owner_id = Alice: the check Alice != Bob is True → returns 403 (denied). Bob requesting his own order 1099 (owner_id = Bob): Bob != Bob is False → returns the order (allowed). Both correct. ✓


Example 5 — Anonymous subject · Cell C5

Step 1 — Give the anonymous visitor an identity anyway. Treat "not logged in" as a real subject: current_user = anonymous, with role set {public}. Why this step? The parent note's key insight: anonymous access is just AuthZ with subject = "anonymous." We never skip authorization; we authorize the anonymous subject.

Step 2 — Authorize the public read. Rule: reading a public post requires role public. Anonymous has publicallowed (200). Why this step? Public access is a deliberate grant, not the absence of a check.

Step 3 — Authorize the admin delete. Rule: /admin/delete requires role admin. Anonymous's roles are {public}, which does not contain admindenied (403), default-deny. Why this step? Least privilege means the default answer is no unless a rule grants yes.

Verify: Anonymous roles = {public}. Read requires public: public ∈ {public}True → allowed. Delete requires admin: admin ∈ {public}False → denied. Two endpoints, two correct outcomes from one uniform check. ✓


Example 6 — Cryptographic storage · Cell C6

Step 1 — Plaintext. Both rows store hunter2. The attacker reads the password directly and sees they match. Why this step? Plaintext is total failure — a leak is instant compromise (Cryptographic Failures, A02).

Step 2 — Unsalted MD5. md5("hunter2") is a fixed value, so both rows store the same hash. The attacker: (i) sees Alice and Bob share a password, and (ii) reverses it via a precomputed rainbow table because MD5 is fast. Why this step? Identical input → identical hash means the hash leaks equality. Speed means it's cheaply brute-forced. (See Cryptography Basics.)

Step 3 — Salted bcrypt. Give each user a random salt: store bcrypt(salt_A + "hunter2") and bcrypt(salt_B + "hunter2"). Because salt_A ≠ salt_B, the two hashes differ — the attacker cannot even tell the passwords match. bcrypt's tunable work factor makes each guess slow. Why this step? Salt kills rainbow tables and equality-leaks; a slow work factor makes mass guessing economically infeasible.

Verify: With salts, the stored value for Alice is H(salt_A ++ pw) and for Bob H(salt_B ++ pw). Since salt_A ≠ salt_B, the inputs to H differ, so the outputs differ (for a good hash). Therefore "hashes equal?" is False under salting but True under unsalted MD5. Exactly the desired distinction. ✓


Example 7 — Real-world word problem (end-to-end) · Cell C7

Step 1 — Validate the input (C1/C2/C3). The account ID must match an allowlisted shape (e.g. a UUID, length-bounded). Reject empty, null, oversized, or malformed IDs. Why this step? Constrain data before it reaches any interpreter — closes injection and degenerate-input classes at the boundary.

Step 2 — Authenticate (C5→identity). Verify the session token / JWT → obtain current_user. No valid token → 401. Why this step? We must know who before deciding what. See Sessions and Tokens (JWT, OAuth).

Step 3 — Authorize per-object (C4). Check target_account.owner_id == current_user.id (or user has admin). Otherwise 403. Why this step? Being logged in isn't enough — prevent IDOR by confirming ownership on this object.

Step 4 — Perform the action with least privilege. The DB credential used can delete this user's rows only, never the whole table. Why this step? Defense in depth — even if steps 1–3 had a bug, the blast radius is bounded (Principle of Least Privilege).

Verify: Count the independent gates a request must pass: (1) validation, (2) authentication, (3) authorization, (4) least-privilege execution = 4 controls. A request missing any one (bad token, wrong owner) is stopped before the DELETE. Sanity: the legitimate owner passes all 4 and the account is deleted; everyone else is stopped at gate 2 or 3. ✓


Example 8 — Exam twist: two look-alike fixes · Cell C8

Step 1 — Test Fix A against the classic attack. x' OR '1'='1 contains ' → blocked. Looks fixed. Why this step? This is the seduction of blocklists — they pass the demo.

Step 2 — Find a bypass for Fix A. Try 1 OR 1=1 (a numeric context with no quotes), or a UNION with encoded whitespace, or Unicode homoglyph quotes (). None contain a banned character. Why this step? Blocklisting is the infinite game: you must enumerate every bad input, which is impossible. One missed encoding breaks it.

Step 3 — Analyze Fix B structurally. Parameterization fixes the parse tree from the template alone (Example 1). Any input — quotes, DROP, Unicode, whatever — binds as a leaf value and cannot add a node. Why this step? Fix B removes the entire class, not one example. It's the difference between patching a symptom and closing the wound. See Threat Modeling for reasoning about attack classes rather than instances.

Step 4 — Verdict. Fix B is correct. Fix A blocks some inputs and is bypassable; it protects the demo, not the system.

Verify: The attack input 1 OR 1=1 contains none of {', ;, DROP} → Fix A's blocklist returns "allowed" (a bypass exists). Fix B's parameterized query treats 1 OR 1=1 as a literal name to search → matches nothing → 0 rows, no injection. So: Fix A has a bypass (blocked = False for a hostile input), Fix B does not. ✓


Recall Feynman: the whole matrix in one breath

Every security bug is a cell you forgot to check. Did you trust text that became code (C1)? Did you assume there'd always be text, so empty or giant input crashed you (C2)? Did valid-looking text attack a different room like the browser (C3)? Did you let a logged-in person touch someone else's stuff (C4)? Did you forget that "nobody" is still a subject to authorize (C5)? Did you store secrets naked (C6)? The pro doesn't memorize attacks — they walk the grid, cell by cell, and refuse to ship until every cell answers "handled."


Flashcards

Why does parameterization beat blocklisting for SQL injection?
Parameterization fixes the parse tree from the template alone so input is always a leaf value; blocklisting must enumerate the infinite set of bad inputs and is bypassable.
In the IDOR example, why is a logged-in user still denied another user's order?
Authentication proved WHO (Bob), but authorization must still confirm Bob OWNS the object; default-deny returns 403 when owner_id ≠ current_user.id.
How is anonymous access modeled in authorization?
As a real subject "anonymous" with a limited role set (e.g. {public}); you never skip authorization, you authorize the anonymous subject.
Why does salting passwords hide that two users share a password?
Different random salts make identical passwords produce different hashes, so equal passwords no longer look equal and rainbow tables fail.