Sessions That Hold · practice
A New Id at Login
Lesson 1 traced the fixation attack on paper. Run it against your server:
python pl_sec.py fixation
The tool starts your server on
--- the attacker gets a session without logging in ---
the attacker saves a preference POST /prefs -> 200
the attacker now knows this cookie: session=<22 characters>
--- the victim logs in ---
the victim's browser now holds the attacker's cookie (pl_sec put it there)
ana logs in, sending that cookie POST /login -> 200
the login reply set this cookie: session=<the same 22 characters>
--- who is who now ---
the attacker, with the cookie from before the login GET /me -> 200 {"user": "ana", "page_size": 5}
ana, with the cookie her login reply set GET /me -> 200 {"user": "ana", "page_size": 5}
--- result ---
the id the attacker knew now answers as: ana
The tool shows the server’s side of the attack. The step in which the id reaches the victim’s browser is one line of text, (pl_sec put it there), because nothing here is a browser.
Write the attack first
The attack becomes a test before you fix anything. Open tests/steps.py, which is new in this chapter. It holds steps a visitor takes, written once so that your tests stay short. This lesson needs one of them: steps.cookie_pair(response) returns the name=value text of the Cookie header, or None when the response sets no cookie.
The test has four steps:
Build a state with
reading_server.sample_state().The attacker: send
POST /prefswith the data{"page_size": 5}and keepsteps.cookie_pair(...)of the response. That is the id the attacker knows.The victim: send
POST /loginfor ana with her password and that cookie, and assert that the status is 200. This is the precondition. Without it, a server that refuses every login would pass the test and would have closed nothing.The attacker again: send
GET /mewith the same cookie and assert that the user isNone.
The login request carries two headers, the credentials and the planted cookie:
headers = {"Authorization": helpers.basic("ana", "correct horse"), "Cookie": planted}
Run the tests:
python -m pytest -q
The run ends with 1 failed, 8 passed, and the failing line reads AssertionError: assert 'ana' is None. That failure is finding F5.
A new id, and almost nothing carried over
rotate_session(state, old_id, user) does three things. It removes the entry old_id names from state["sessions"]. It starts a new session for user with start_session, which makes a new id with secrets.token_urlsafe(16). And it carries one page_size=....
It copies nothing else. Whatever a visitor session holds was put there before login, by whoever held the id, and that may be the attacker. Today a session has two fields, so copying all of it would do no harm. Later chapters add fields. A
Why does rotate_session copy page_size and nothing else from the old session?
Three callers
The login branch calls rotate_session(state, session_id_from(request), user), and session_id_from can return three kinds of value:
None, for a login without a cookie.An id the server never issued, because a client may send any text as a cookie.
An id that names a live session, a visitor’s or an earlier login’s.
All three must end the same way: one new entry for the user, and no entry under the old id. state["sessions"].pop(old_id, None) returns the removed session, or None when no session has that id, so one call covers the last two cases. When there was no old session, the new one gets DEFAULT_PAGE_SIZE.
A cache must not keep a Set-Cookie
A Set-Cookie header does not stop a cache from storing a response. A shared cache that stored one would hand the same session id to every later visitor. The standards already keep a conforming cache away from this service’s login, which is a POST with an Authorization header. The rule below is for every response they would let a cache keep, and for caches that are set up to keep more than they should.
The rule: a response that sets a cookie says Cache-Control: no-store, and so does every session route. The end of handle() has part of it, from
if segments == ["login"] or segments == ["logout"] or segments == ["me"]:
response["headers"]["cache-control"] = "no-store"
/prefs is missing. It arrived with the visitor session, it sets a cookie, and nobody added it to the rule. Add it to the no-store whenever "set-cookie" is in response["headers"]. The second rule covers a route someone adds next year and forgets to list.
Another service starts a visitor session on GET /, with a Set-Cookie header and no Cache-Control. A shared cache in front of it stores that response and serves it to the next visitor. What does that visitor receive?
Your task
In
tests/test_attacks.py, writetest_attack_fixed_session_is_not_promoted()and watch it fail.In
reading_server.py, writerotate_session(state, old_id, user).In the login branch, replace the four supplied lines with
session_id = rotate_session(state, session_id_from(request), user).At the end of
handle(), make theno-storerule cover/prefsand every response that has aset-cookieheader.Fill row F5 of
SECURITY.md: what is exposed, the attack, the fix, and the name of your test.
Run python -m pytest -q again. It now ends with 9 passed. Then run python pl_sec.py fixation once more. The login reply sets a different cookie from the one the attacker knew, the attacker’s GET /me answers {"user": null, "page_size": 20}, ana’s answers {"user": "ana", "page_size": 5}, and the result line reads the id the attacker knew now answers as: nobody.
A finding is closed when its test passes, so set the status of F5 to closed in 4.2.
Then press Check my work. A login is now safe at the moment it happens. After that moment the session lives for as long as the server runs, which the next lesson changes.
Task
Write the fixation attack as a test, then give every login a new
In
tests/test_attacks.py, writetest_attack_fixed_session_is_not_promoted(). Build a state withreading_server.sample_state(). SendPOST /prefswith the data{"page_size": 5}and keepsteps.cookie_pair(...)of the response: that is the id the attacker knows. SendPOST /loginfor ana with her password and thatin a Cookieheader, and assert status 200. SendGET /mewith the same cookie and assert thathelpers.json_of(...)["user"]isNone. Runpython -m pytest -q: this test must fail.In
reading_server.py, writerotate_session(state, old_id, user). Remove the entryold_idnames fromstate["sessions"]if there is one;old_idmay beNoneor an id the server never issued. Start the new session withstart_session(state, user, page_size=...), passing the old session’s page size, orDEFAULT_PAGE_SIZEwhen there was no old session. Return the new id.In the login branch, replace the four supplied lines with
session_id = rotate_session(state, session_id_from(request), user).At the end of
handle(), make theno-storerule cover/prefsand every response that has aset-cookieheader.Fill row F5 of
SECURITY.md, name your test in it, and set its status toclosed in 4.2once the test passes.
Run python -m pytest -q in the 9 passed, and python pl_sec.py fixation to watch the attack fail.
Then press Check my work. It calls rotate_session with a state of its own: a visitor session with a page size and one extra key, no old session, and an id the server never issued. The old entry must be gone, the new session must hold the user and the page size and nothing else from the old one, and the new id must come from secrets with 16 bytes or more; a rotate_session that makes the id itself with secrets.token_hex(16) is accepted. It then calls handle() with users it has not shown you: POST /prefs, a login that sends the visitor’s cookie, and GET /me with the old and the new cookie, and it reads Cache-Control on POST /prefs and on the login. 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 issues a new id at login, where your test must pass; one that keeps the id, where it must fail; and one that refuses every login, where it must fail. Last, it reads row F5 of SECURITY.md: the Exposed, Attack, and Fix cells need text, the Test cell needs the name of your test, and the status must start with closed. The wording of the row is not judged. Nothing from your terminal is graded.