๐ŸฅฆNdiro
Chapter 12 of 13

Security: thinking like an attacker

A public web app's threat model is brutal in a specific way: the client is anyone, the requests are arbitrary, and your own UI is the least interesting way to talk to your server. This chapter names the classic attack families, shows Ndiro's counter to each, and turns them into the questions you'll ask in review. CLAUDE.md's "security invariants" section is the same material in checklist form โ€” the app's SLOs for security.

XSS โ€” injecting script into pages

Attack: get your text rendered as markup in someone else's browser; your <script> then runs with their session on your victim's screen. Counter: escaping at every insertion point โ€” Jinja autoescaping server-side (Jinja), textContent-only for dynamic data client-side (JavaScript), |tojson at the script bridge. Review question: does the diff add innerHTML with anything non-literal, or a |safe?

CSRF โ€” riding someone else's cookie

Attack: the browser attaches your Ndiro cookie to any request aimed at your instance's domain โ€” including a form auto-submitted by a page on evil.example that you merely visited. The attacker can't read responses, but state-changing requests fire with your identity. Counter: SameSite=Lax on the session cookie โ€” the browser withholds it on cross-site requests except top-level GET navigations. That's sufficient only because of a second discipline: no GET ever changes application data (the HTTP chapter). Be precise about the residue, because Lax does send the cookie on those top-level GETs: the two exceptions are covered otherwise. /callback is a genuinely state-changing GET โ€” it can create your account and claim an invite โ€” and is guarded by the OAuth state nonce instead; /logout only clears the session, logout-CSRF being a deliberately accepted nuisance-only risk. Review question: does any new GET route write anything?

IDOR โ€” the missing ownership check

Attack: "insecure direct object reference" โ€” the URL or form carries an ID, the server fetches it without asking whose it is. /api/meals?user_id=someone_else. Counter: structural: identity comes only from the session (g.user), and no meal route accepts a user ID at all โ€” the request can't even express the attack (the API chapter). Every storage call keys on session['user_id']. tests/probe_cross_user.py โ€” the repo calls it THE security test โ€” signs in as two users and probes every route cross-tenant. Review question: can any new parameter influence whose data is touched?

Open redirect & token leaks

Two quieter ones from the identity chapter's neighborhood. _safe_next stops /login?next=โ€ฆ from laundering redirects to attacker sites through a trusted domain. And share URLs (/s/<token>) are capability URLs โ€” the token is the access โ€” so keeping it out of side channels matters: base.html sets <meta name="referrer" content="strict-origin"> so a recipient clicking an outbound link never ships the token-bearing path in the Referer header.

Enumeration oracles

Can an attacker learn from your error responses? If a dead share link said "revoked" vs "never existed", token-guessing gains a probe. Ndiro's rule: every dead state of a share link (missing, revoked, expired) or invite link (those three plus already-used) returns a byte-identical 404. Combined with 192-bit random tokens and a 30/min rate limit, guessing is hopeless and unmeasurable. The same thinking prices timing and wording of every error path: failure responses should be as uniform as truth allows.

Trust boundaries inside the house

Less classic, more instructive โ€” two boundaries this app draws tighter than most:

Secrets and the public repo

The repo is public, so configuration is the secret perimeter: everything sensitive arrives as environment variables; .env is git- and docker-ignored; config.py hard-fails without SECRET_KEY (leaking would let anyone mint session cookies โ€” the identity chapter). Public pages like /status show booleans ("photos: on"), never values โ€” and test_m9 literally greps the rendered page for every config value in the test environment. This guide is a public page too, and had to obey the same rule.

๐Ÿ˜ From your world

The mindset transfers directly from multi-tenant infrastructure: XSS is untrusted code reaching a trusted execution context; CSRF is ambient authority (a delegation-token store that attaches tokens to requests you didn't intend); IDOR is a missing ACL check after authentication succeeded โ€” authn without authz. The novelty is only where the boundaries run: through the browser, the cookie jar, and the log files.

โš ๏ธ The reviewer's short list

For any diff: โ‘  innerHTML/|safe near data โ‘ก a user/object ID readable from URL, query, or form โ‘ข a state-changing GET โ‘ฃ a config value reaching a public page or a log line โ‘ค an error path that says more than its siblings โ‘ฅ a new route missing its guard or rate limit. Six greps, most of the audit.

๐Ÿ”ฌ Try it

Create a share link, then revoke it, and fetch it with curl -i both times after revoking and with a mangled token โ€” compare the two 404s byte for byte. Then read probe_cross_user.py end to end: it's the most readable statement of the app's actual security contract, better than any prose (this chapter included).

๐Ÿ