Who May Do What, and How Often · practice
Logs and Errors Without Secrets
ben tried to delete ana’s book and got a 404. Six wrong passwords for ana ended in a 429. Who would ever know? Your server refused both and kept no record of either.
A server that refuses needs to write its refusals down. And what a server writes down is read by more people, and kept for longer, than almost anything else it produces:
What never goes in a log
The
Authorization. For a login it is a password in . For a book request it is a token that works for 15 minutes. A
value. A is the credential once login is over. A password or a token in any other form.
The query
. Tokens, email addresses, and search words end up there.
Python has a logging state["log"], so that a test can read it.
Five fields
One request, one line, five fields with one space between them: the time in whole seconds, the
1000000 POST /login 200 ben
1000000 POST /tokens 200 ben
1000000 DELETE /books/1 404 ben
1000000 POST /login 401 -
log_line(now, request, status, user) returns such a line. int(now) drops the fraction of the time. request["target"].partition("?")[0] drops the query: partition splits text at the first ? into three parts, the text before it, the ? itself, and the text after it, and [0] is the first part. When there is no ?, the first part is the whole text.
Why not urlsplit, which you used in urlsplit("http://[") raises partition works on any text.
What does print("/login?next=/books&token=abc123".partition("?")[0]) print?
The path as it arrived
Log the path exactly as it arrived, still percent-encoded. Do not decode it.
A path may contain %0A. Decoded, that is a line break, and a line break inside a log line starts a new line. A stranger could then write a second line of their own choosing into your log, one that looks like a request that never happened. Kept encoded, %0A is three harmless characters.
This is a first look at a pattern that all of Chapter 7 is about: text from a request that ends up somewhere it is read as structure.
The fix has a limit. Other control characters can still reach a method or a path, and a real log also escapes those before it writes a line.
Whose name
The fifth field is request["user"]. The project sets it in three places: when a token verifies, when a session is live, and when a login succeeds. Otherwise it is None, and the line ends in a dash.
So a failed login is logged with a dash, not with the name that was typed. The name was not proven. It is text a stranger chose, and people sometimes type their password into the name field by mistake.
That choice has a cost. For six wrong passwords and one more attempt, the log shows POST /login 401 - five times and then POST /login 429 - twice. An operator sees that some account is being guessed, and not which one. A real service records the attempted name too, in a separate security log that far fewer people can read.
A login fails. Why does the log line end in a dash and not in the name that was sent?
Write the attack first
steps.bearer_for(state, "ana", "correct horse") makes two requests: a login and a POST /tokens. The test calls it, then asserts two things. The log has exactly two lines. And the password, the Basic value, and the bearer value are each not in the joined text.
Run python -m pytest -q: 1 failed, 19 passed, with assert 0 == 2. The log is empty.
The first assertion is there for a reason. A test that only searched for secrets would pass on a server that logs nothing at all.
Errors are output too
A log is one thing a server writes. An error message is another. When Python code fails, it produces a
Traceback (most recent call last):
File "reading_server.py", line 77, in handle
response = route(request, state)
File "reading_server.py", line 169, in route
for book_id in state["books"]:
~~~~~^^^^^^^^^
KeyError: 'books'
It names files,
{"error": "the server could not handle this request"}
The traceback belongs on standard error, where the operator reads. The next lesson builds that. Today an error inside route() still escapes handle().
Why not send the traceback in the 500 body, so the client can report it?
Your task
Write
test_attack_log_holds_no_secret()and see it fail.Write
log_line(now, request, status, user): the five fields with one space between them, and-when the user isNone.In
handle, keep the response thatroutereturns in a, append log_line(state["clock"](), request, response["status"], request["user"])tostate["log"], and return the response.
Run python -m pytest -q: 20 passed. Then run python pl_sec.py other-reader and python pl_sec.py guessing again. Each now prints what your server wrote in its log.
One honest note. state["log"] grows for as long as the server runs. A real service writes each line to a stream that something else keeps and trims. This list is there so that a test can read it.
Task
Write one log line for every request, with no secret in it.
In
tests/test_attacks.py, writetest_attack_log_holds_no_secret(). Withstate = steps.keyed_state(), callbearer = steps.bearer_for(state, "ana", "correct horse"). Join the log withtext = "\n".join(state["log"]). Assert thatlen(state["log"]) == 2, and that"correct horse",helpers.basic("ana", "correct horse"), andbearerare each not intext. Runpython -m pytest -q: this test must fail first.In
reading_server.py, writelog_line(now, request, status, user). Return the five fields with one space between them:int(now), the, request["target"].partition("?")[0], the status, and the user, or-when the user isNone.In
handle, keep the responseroutereturns in a, append log_line(state["clock"](), request, response["status"], request["user"])tostate["log"], and return the response.
Run python -m pytest -q until it ends with 20 passed, then press Check my work. It calls log_line with requests of its own: a target with a query, headers that hold credentials, a time with a fraction, a path with a percent-encoded line break, and a target that urlsplit cannot parse. It sends five requests through reading_server.handle(...) under a clock it controls, among them a refused request with a token and a failed login, expects five lines, and searches the log for every credential it sent. It runs your test four times, against servers it puts in place of reading_server.handle and reading_server.sample_state: a correct one, where your test must pass; and one that writes the