0%

Who May Do What, and How Often · practice

Answer 429 and Weigh the Lockout

You have a counter that nobody asks. See what that means for the login. In the , run:

python pl_sec.py guessing
pl_sec: guessing, your server on 127.0.0.1 (a port the system chose)
--- six wrong passwords for ana ---
wrong password 1          POST /login -> 401
wrong password 2          POST /login -> 401
wrong password 3          POST /login -> 401
wrong password 4          POST /login -> 401
wrong password 5          POST /login -> 401
wrong password 6          POST /login -> 401
--- then her right password ---
ana's own password        POST /login -> 200  {"user": "ana"}
(the clock moves 300 seconds)
ana's own password        POST /login -> 200  {"user": "ana"}
--- result ---
6 wrong passwords, 0 refused before a password was checked
serve() returned after 8 connections; the server is closed
pl_sec: every thread has stopped

Six guesses, six password checks, and nothing would have stopped six thousand. This is finding F9.

Write the attack first

The test sends a wrong password for ana reading_server.USER_LIMIT times and asserts 401 each time. Those assertions are the precondition: the limit must not refuse too early. Then it sends one more and asserts status 429 and that "retry-after" is in the .

Read the limit from the , as reading_server.USER_LIMIT, and do not write the number. Run python -m pytest -q: 1 failed, 18 passed, with assert 401 == 429.

Ask before you check

login_wait(state, credentials) runs before login_user. It returns 0, or the seconds the client must wait. When it says wait, the login branch answers 429 and does not look at the password at all, even when the password is right. A limit that lets the right guess through is no limit on guessing: the guesser would simply learn that the guess was right.

It also means a refused attempt costs your server no scrypt call.

Why does the server ask the limiter before it checks the password?

The same for a name that does not exist

The key is "user:" plus whatever name was sent, whether that name is a user or not. If only real names were counted, only real names would ever be answered 429. The limit would then tell a stranger which names exist, which Chapter 3 worked to hide.

A request with no credentials tests no password. It is not counted, and login_wait returns 0 for it.

Why count attempts for a name that is not a user?

A second, much higher limit

One password tried against 300 different names never trips a per-name counter. So a second key, "all", counts every attempt that the per-name limit let through, against GLOBAL_LIMIT.

The order matters. Ask the name’s own limit first, and the global limit only when the first one allowed the attempt. Then one client hammering one name is stopped by that name’s limit and does not use up everybody’s share.

When the global limit refuses, the attempt was already recorded under the name. Take it back with the supplied limits.forget_last(state["attempts"], key). A refused attempt is never recorded, under either key. Without that line, someone spraying names would fill the table with names, and would use up each real user’s own five tries while the global limit is full.

The prefix user: keeps the two kinds of key apart. Without it, a user who is called all would share a counter with everyone.

The answer

The supplied too_many(wait) builds the refusal: status 429, a error that names no account, and retry-after set to the wait. The rule at the end of route adds no-store, as for every answer of /login.

After a correct password, remove that user’s key with state["attempts"].pop("user:" + user, None). Nothing else resets a count. A failed attempt must never reset it.

What a limit costs

This defense has a price, and you should be able to state it.

A per-name limit lets anyone lock ana out. They send wrong passwords under her name, and this server has no way to tell that sender from ana.

The global limit caps how fast anyone can guess across all names. One client can reach it: 300 attempts in 300 seconds is one request a second. Once it is full, every user is locked out for a window. A low number makes that cheap. A higher number only raises the cost for that client and never removes it.

What would separate that client from everyone else is something this request cannot see: an address, a device, a second factor. A real service uses those, together with an alert that tells a person.

And the limit does not stop a patient guesser. Five guesses per five minutes is 1440 a day for one name. The limit turns fast guessing into slow guessing, and the next lesson makes it visible.

What can a stranger do to ana with the per-name limit, without knowing her password?

GLOBAL_LIMIT is 300 attempts in 300 seconds. What can one client do with that?

Your task

  1. Write test_attack_guessing_is_limited() and see it fail.

  2. Write login_wait(state, credentials): no credentials is 0; otherwise ask limits.allow_attempt for the user’s key, then for "all", and take the first attempt back when the second answer refuses.

  3. In the login branch, call login_wait before login_user, answer too_many(wait) when the wait is above 0, and remove the user’s key after a correct password.

  4. Fill row F9 of SECURITY.md, name your test, and set its status to closed in 6.4.

Run python -m pytest -q: 19 passed. Then run python pl_sec.py guessing again. The first five wrong passwords still get 401. The sixth line now reads:

wrong password 6          POST /login -> 429  retry-after: 300

ana’s right password gets 429 too, until the clock has moved 300 seconds, and the run ends with the limit answered 429 after 5 attempts; Retry-After: 300.

Your server now refuses in four ways: 401, 403, 404, and 429. It writes none of it down.

Task

Put the limiter in front of the login.

  1. In tests/test_attacks.py, write test_attack_guessing_is_limited(). With state = steps.keyed_state(), send POST /login for ana with a wrong password ({"Authorization": helpers.basic("ana", "a guess")} as the headers) reading_server.USER_LIMIT times, asserting status 401 each time. Send it once more and assert status 429 and that "retry-after" is in the . Run python -m pytest -q: this test must fail first.

  2. In reading_server.py, write login_wait(state, credentials). With no credentials, return 0. Read now from state["clock"](). The user’s key is "user:" + credentials["user"]. Ask limits.allow_attempt(state["attempts"], key, now, limit=USER_LIMIT, window=WINDOW). When that returns 0, ask again with the key "all" and limit=GLOBAL_LIMIT; when this second answer is above 0, take the first attempt back with limits.forget_last(state["attempts"], key). Return the wait.

  3. In the login branch of route: get the credentials, call login_wait, and answer too_many(wait) when the wait is above 0, without calling login_user; too_many is supplied. After a correct password, remove that user’s key with state["attempts"].pop("user:" + user, None).

  4. Fill row F9 of SECURITY.md, name your test in its Test cell, and set the status to closed in 6.4.

Run python -m pytest -q until it ends with 19 passed, then press Check my work. It sets USER_LIMIT, GLOBAL_LIMIT, and WINDOW to smaller numbers of its own and counts answers under a clock it controls: wrong passwords up to the limit and one more, the right password during the wait and after the window, a name that does not exist beside one that does, the count after a correct login, requests without credentials, one name tried many times, one password tried under many names, and a user whose name is all. It counts scrypt calls to see that a refused attempt checks no password; it measures no time. It runs your test four times, against servers it puts in place of reading_server.handle and reading_server.sample_state, each with its own for reading_server.USER_LIMIT: a correct one, where your test must pass; one that never limits a login, one whose 429 has no Retry-After, and one that refuses every request, where it must fail. It reads row F9 of SECURITY.md. Nothing from your is graded.