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.
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?
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?
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?
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.
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.
Less classic, more instructive โ two boundaries this app draws tighter than most:
user_id, date so meal content never even enters the
process, and nothing keyed by user may be serialized into the stats
payload (invariant #7, asserted by test_m10).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.
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.
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.
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).