Sessions That Hold · capstone
Capstone project: Project: Sessions That Hold
Three lessons made three repairs: a new id at login, two limits, and a
The policy
| Question | Answer on the reading |
|---|---|
| When does a | When a visitor saves a preference, and at every login, always under a new id from secrets |
| What does it hold? | user, page_size, created, last_seen |
| What moves? | last_seen, on every request that finds the session live |
| When does it end? | After IDLE_LIMIT seconds without a request, ABSOLUTE_LIMIT seconds after it started, at logout, and at login, when a new session replaces it |
| What does the cookie say? | __Host-session, with Secure, HttpOnly, SameSite=Lax, Path=/, and no Domain |
| What may a cache keep? | Nothing about a session: every response that sets a cookie, and every session route, says no-store |
Every row except one is finished. The gap is in “When does it end?”.
The session nobody comes back to
current_session removes an expired session when its id is used. A visitor who never returns never uses the id, so the entry stays in state["sessions"] for as long as the server runs. And POST /prefs hands a session to anyone who asks.
sweep_sessions(state) closes the gap. It reads the time from state["clock"](), removes every session for which session_expired(...) is true, and returns how many it removed. start_session calls it first, so the store is cleaned whenever a session starts.
One detail decides how to write it. Removing an entry from a for RuntimeError: dictionary changed size during iteration as soon as the loop takes its next step. So sweep in two passes: first collect the expired ids in a list, then remove them in a second loop over that list.
Why does sweep_sessions first collect the expired ids in a list and remove them in a second loop?
Milestones
1. A busy session still ends. Write test_attack_busy_session_still_ends(). It is the thief of Lesson 3 who sends a request every ten minutes. Log in under a fixed clock that starts at 1000. Then repeat: add 600 to clock["now"], ask GET /me, and assert that the user is ana. Keep going while the next step is still short of the limit:
while clock["now"] + 600 < 1000 + reading_server.ABSOLUTE_LIMIT:
After the loop the session is 28200 seconds old. Add 601, which puts it one second past the limit, ask again, and assert that the user is None.
2. Logout ends the session on the server. Write test_attack_logout_ends_the_session_on_the_server(). Log in with steps.login_cookie, ask GET /me and assert ana, send POST /logout with the cookie, then ask GET /me with the same cookie and assert None. The attacker is anyone who kept a copy of the cookie from before the logout.
Run python -m pytest -q. It ends with 13 passed: both new tests pass the first time. Lesson 3 already built the absolute limit, and
Your logout test passes the first time you run it. What is it for?
3. The sweep. Write sweep_sessions(state) and call it as the first line of start_session. No test of yours changes color: python -m pytest -q still ends with 13 passed.
4. Watch the policy. Run the sessions scenario:
python pl_sec.py sessions
It logs in, lets 31 minutes pass on a clock it owns, asks again, then logs in and out and tries the old cookie. The ids are shortened here:
--- a session that is left alone ---
ana logs in POST /login -> 200
set-cookie: __Host-session=<id>; HttpOnly; Max-Age=28800; Path=/; SameSite=Lax; Secure
ana asks who she is GET /me -> 200 {"user": "ana", "page_size": 20}
(the clock moves 31 minutes)
the same cookie, 31 minutes later GET /me -> 200 {"user": null, "page_size": 20}
--- a session that is logged out ---
ana logs in again POST /login -> 200
ana logs out POST /logout -> 204
set-cookie: __Host-session=""; HttpOnly; Max-Age=0; Path=/; SameSite=Lax; Secure
the cookie from before the logout GET /me -> 200 {"user": null, "page_size": 20}
--- result ---
after 31 idle minutes the session answered as: nobody
after logout the old cookie answered as: nobody
5. The findings. In SECURITY.md, check that rows F3, F4, and F5 each name a test and say closed.
What is still open
A sweep bounds how long a dead session stays in the store. It does not bound how many live visitor sessions a stranger can open in half an hour: each POST /prefs without a cookie creates one, and none of them expires for 30 minutes. A real service adds a cap, or waits to create a session until it has something worth remembering. The sweep also reads the whole store each time a session starts, so the same flood makes every POST /prefs slower. This course names both problems and builds neither fix.
Then press Check my work. Your sessions now hold. The bearer tokens do not: they are still two fixed
Task
Finish the
In
tests/test_attacks.py, writetest_attack_busy_session_still_ends()andtest_attack_logout_ends_the_session_on_the_server()as the lesson describes. Both usesteps.login_cookie(state, "ana", "correct horse"). The busy test runs underhelpers.fixed_clock(...), asksGET /meevery 600 seconds and assertsanaeach time while the next step is still short ofreading_server.ABSOLUTE_LIMITafter the login, then moves the clock to one second past that limit and assertsNone. The logout test assertsanabefore the logout andNoneafter it, with the same. In
reading_server.py, writesweep_sessions(state): read the time fromstate["clock"](), remove every session for whichsession_expired(...)is true, and return how many you removed.Call
sweep_sessions(state)as the first line ofstart_session.Run
python -m pytest -q(13 tests pass) andpython pl_sec.py sessions.In
SECURITY.md, make sure rows F3, F4, and F5 each name a test and sayclosed.
Then press Check my work. It repeats the checks of Lessons 2, 3, and 4 with other clock times, ids, and page sizes: the two limits and the clock first, then the cookie’s name and flags, then the new id at login and no-store. It calls sweep_sessions on a store with one live, one idle, and one too-old session, which must return 2 and keep the live one, and calls start_session on such a store, which must sweep it; a sweep that builds a new http_adapter as bytes, without opening a set-cookie and cache-control lines that come back. It runs your five tests of this chapter by name against servers it puts in place of reading_server.handle and reading_server.sample_state: all five must pass against a correct server; each must fail against the one mistake it is for (a login that keeps the id, no idle limit, no absolute limit, a logout that keeps the session, a cookie without Secure); and all five must fail against a server that refuses every login. Last, it reads rows F3, F4, and F5 of SECURITY.md. Nothing from your