Sessions That Hold · practice
Sessions That End
Open SECURITY.md and read row F3, which you wrote in Chapter 1: a Max-Age=3600, and that only asks a client to forget its copy after an hour. A copy of the id in a file forgets nothing, and state["sessions"] keeps the entry for as long as the server runs. This lesson gives a session an end that the server decides.
Two limits, for two attackers
A session can be misused in two ways, and each needs its own limit.
An idle limit ends a session that nobody is using. It covers a laptop left open in a library and a cookie left behind on a shared computer: after 30 minutes without a request, the id is worth nothing.
An absolute limit ends a session that somebody keeps using. A thief who copied an id can send a request every few minutes, so the session is never idle. Only its age can end it: 8 hours after the login, the session is over, however busy it was.
idle limit: more than 30 minutes without a request ends the session
login request request 31 minutes pass request
|--------|---------|-------------------------X refused and removed
absolute limit: more than 8 hours after login the session ends,
however busy it is
login one request every ten minutes ... 8 hours
|--|--|--|--|--|--|--|--|--|--|--|--|--|--|--X refused and removed
The first line shows a session with a gap of 31 minutes: the request after the gap is refused. The second shows a session with no gap at all: the first request after the 8 hour mark is refused. The two numbers are in reading_server.py as IDLE_LIMIT = 1800 and ABSOLUTE_LIMIT = 28800, in seconds. They are this service’s choice, not a standard. A service that guards more picks shorter limits.
A thief copies a session id and sends one request with it every ten minutes. Which limit ends that session?
Time is an argument
Both limits compare a stored time with the time now. The server gets the time from state["clock"], a sample_state() sets it to time.time, the real clock. Your code calls state["clock"]() and never calls time.time() itself. That is the injected time from Python in Practice: because the clock comes in through state, a test can decide what time it is.
helpers.fixed_clock(holder) returns a clock function that reads the time from holder["now"]. The test keeps the
clock = {"now": 1000}
state["clock"] = helpers.fixed_clock(clock)
After clock["now"] += 1801, thirty minutes and one second have passed for the server. Nothing waits.
Write the attack first
The attacker here does nothing clever. They use an id long after its owner stopped. The second step in tests/steps.py keeps the test short: steps.login_cookie(state, user, password) logs in through handle() and returns the cookie pair, or None when the login was refused.
The test sets the fixed clock, logs in as ana, and asks GET /me with the cookie. It asserts that the user is ana, which is the precondition: the session works while it is fresh. Then it adds reading_server.IDLE_LIMIT + 1 to clock["now"], asks again, and asserts that the user is None.
Run python -m pytest -q. The run ends with 1 failed, 9 passed, and the failing line is AssertionError: assert 'ana' is None again: half an hour later the session still answers.
One pure question
A session needs two time stamps to answer for itself. start_session reads now = state["clock"]() once and stores it under both created and last_seen.
session_expired(session, now) then answers one question with two subtractions and two comparisons. now - session["last_seen"] is how long the session has been idle, and now - session["created"] is how old it is. The function returns True when the first is more than IDLE_LIMIT or the second is more than ABSOLUTE_LIMIT. It reads no clock and changes nothing, so you can try it with any numbers.
What does this print?
IDLE_LIMIT = 1800
ABSOLUTE_LIMIT = 28800
session = {"created": 1000, "last_seen": 29000}
now = 30000
idle = now - session["last_seen"] > IDLE_LIMIT
too_old = now - session["created"] > ABSOLUTE_LIMIT
print(idle, too_old)
Refuse, remove, and move one field
current_session(request, state) is where the limits take effect, because /me, /prefs, and session_user all go through it. No caller can skip the limits. After it finds the session, it reads the time and asks session_expired:
An expired session is removed from
state["sessions"], and the function returnsNone, exactly as if the cookie named nothing. Removing matters. A session that is refused and kept is still in every copy of the state, and one changed line could bring it back.A live session gets
last_seen = now, and the function returns it.
created never moves. If a request moved it, the session would look new after every request and the absolute limit would never arrive.
The cookie says Max-Age=3600. An attacker saved the id in a file. Two hours later, with nobody having used the session, what stops the id from working?
One more event should end sessions: when a password changes, every session of that user should end. This service has no route that changes a password, so there is nothing to build here.
Your task
In
tests/test_attacks.py, writetest_attack_idle_session_is_refused()and watch it fail.In
start_session, store the time undercreatedandlast_seen.Write
session_expired(session, now).In
current_session, refuse and remove an expired session, and movelast_seenon a live one.In row F3 of
SECURITY.md, name your test and set the status toclosed in 4.3.
Run python -m pytest -q again. It ends with 10 passed: the idle test, the fixation test from Lesson 2, and the eight from earlier chapters.
Then press Check my work. Sessions now end on the server. The cookie that carries the id still says only HttpOnly, Max-Age, and Path, and the next lesson gives it the rest of its instructions.
Task
Write the idle-
In
tests/test_attacks.py, writetest_attack_idle_session_is_refused(). Useclock = {"now": 1000}andstate["clock"] = helpers.fixed_clock(clock). Log in withsteps.login_cookie(state, "ana", "correct horse"). AskGET /mewith theand assert that the user is ana. Addreading_server.IDLE_LIMIT + 1toclock["now"], ask again, and assert that the user isNone. Runpython -m pytest -q: this test must fail first.In
start_session, readnow = state["clock"]()and store it under bothcreatedandlast_seen.Write
session_expired(session, now):Truewhennow - session["last_seen"]is more thanIDLE_LIMIT, ornow - session["created"]is more thanABSOLUTE_LIMIT.In
current_session, after finding the session: read the time fromstate["clock"](); when the session is expired, remove it fromstate["sessions"]and returnNone; otherwise set itslast_seento now and return it.In row F3 of
SECURITY.md, name your test and set the status toclosed in 4.3.
Run python -m pytest -q in the 10 passed.
Then press Check my work. It calls session_expired with sessions of its own, one second inside and one second outside each limit, so > and >= both pass. Under a clock that it moves, far from the real time, it calls start_session and reads the two time stamps, then asks GET /me through handle(): 100 seconds later the session must answer and only last_seen may have moved; past IDLE_LIMIT it must answer null and be gone from state["sessions"]; and a session that is asked again just before each idle limit must end once ABSOLUTE_LIMIT has passed. It runs your test three times, each time against a server it puts in place of reading_server.handle and reading_server.sample_state: one that ends idle sessions, where your test must pass; one that never does, where it must fail; and one that refuses every login, where it must fail. Last, it reads row F3 of SECURITY.md for the name of your test and a status that starts with closed. Nothing from your terminal is graded.