0%

Sessions That Hold · practice

Sessions That End

Open SECURITY.md and read row F3, which you wrote in Chapter 1: a that was ever copied keeps working. The says 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 that returns seconds as a number. 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 and changes it:

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 returns None, 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

  1. In tests/test_attacks.py, write test_attack_idle_session_is_refused() and watch it fail.

  2. In start_session, store the time under created and last_seen.

  3. Write session_expired(session, now).

  4. In current_session, refuse and remove an expired session, and move last_seen on a live one.

  5. In row F3 of SECURITY.md, name your test and set the status to closed 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- attack as a test, then give every session two limits.

  1. In tests/test_attacks.py, write test_attack_idle_session_is_refused(). Use clock = {"now": 1000} and state["clock"] = helpers.fixed_clock(clock). Log in with steps.login_cookie(state, "ana", "correct horse"). Ask GET /me with the and assert that the user is ana. Add reading_server.IDLE_LIMIT + 1 to clock["now"], ask again, and assert that the user is None. Run python -m pytest -q: this test must fail first.

  2. In start_session, read now = state["clock"]() and store it under both created and last_seen.

  3. Write session_expired(session, now): True when now - session["last_seen"] is more than IDLE_LIMIT, or now - session["created"] is more than ABSOLUTE_LIMIT.

  4. In current_session, after finding the session: read the time from state["clock"](); when the session is expired, remove it from state["sessions"] and return None; otherwise set its last_seen to now and return it.

  5. In row F3 of SECURITY.md, name your test and set the status to closed in 4.3.

Run python -m pytest -q in the until it ends with 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.