0%

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 for four connections and plays both people. It sends four requests. The change on every run, so they are shortened here:

--- 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 a response sets, which is exactly what a client sends back in its Cookie header, or None when the response sets no cookie.

The test has four steps:

  1. Build a state with reading_server.sample_state().

  2. The attacker: send POST /prefs with the data {"page_size": 5} and keep steps.cookie_pair(...) of the response. That is the id the attacker knows.

  3. The victim: send POST /login for 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.

  4. The attacker again: send GET /me with the same cookie and assert that the user is None.

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 over, the visitor’s page size, by passing it as 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 that copies named fields is an allowlist: a new field stays behind until someone decides it is safe to carry. A function that copies the whole record will carry the next field over too, and nobody will have decided anything.

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 with Python:

    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 , and add a second that sets 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

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

  2. In reading_server.py, write rotate_session(state, old_id, user).

  3. In the login branch, replace the four supplied lines with session_id = rotate_session(state, session_id_from(request), user).

  4. At the end of handle(), make the no-store rule cover /prefs and every response that has a set-cookie header.

  5. 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 .

  1. In tests/test_attacks.py, write test_attack_fixed_session_is_not_promoted(). Build a state with reading_server.sample_state(). Send POST /prefs with the data {"page_size": 5} and keep steps.cookie_pair(...) of the response: that is the id the attacker knows. Send POST /login for ana with her password and that in a Cookie header, and assert status 200. Send GET /me with the same cookie and assert that helpers.json_of(...)["user"] is None. Run python -m pytest -q: this test must fail.

  2. In reading_server.py, write rotate_session(state, old_id, user). Remove the entry old_id names from state["sessions"] if there is one; old_id may be None or an id the server never issued. Start the new session with start_session(state, user, page_size=...), passing the old session’s page size, or DEFAULT_PAGE_SIZE when there was no old session. Return the new id.

  3. In the login branch, replace the four supplied lines with session_id = rotate_session(state, session_id_from(request), user).

  4. At the end of handle(), make the no-store rule cover /prefs and every response that has a set-cookie header.

  5. Fill row F5 of SECURITY.md, name your test in it, and set its status to closed in 4.2 once the test passes.

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