Who May Do What, and How Often · capstone
Capstone project: Project: Your List and Nobody Else's
Ownership, a login limit, and a log each work, and each has a test. This project states them as one policy, and closes the last gap in it: what happens when your own code fails.
The policy
| Route | Who may call it | A refusal looks like | Logged as |
|---|---|---|---|
GET, HEAD /about | anyone | 405 for another | - |
GET, HEAD /books, /books/{id} | a token that verifies; the caller’s own books only | 401 without a token; 404 for a book that is missing or someone else’s | the token’s user |
POST /books, PUT and DELETE /books/{id} | a token whose user may write; the caller’s own books only | 401; 403 for an account that may not write; 404 for a book that is not the caller’s | the token’s user |
POST /login | Basic credentials | 401; 429 with Retry-After when the name or the whole service is over its limit | the user after a correct password, - otherwise |
POST /tokens | a | 401 | the session’s user |
GET /me, POST /prefs | anyone | none | the session’s user when the request reads a live session, - otherwise |
POST /logout | anyone | none | - |
Every request that reaches handle() is written down once, in five fields and without a credential. The book reads answer private, no-cache, and the five session routes answer no-store.
When your own code fails
The table says what the server does with requests it understands. One case is missing: a mistake in the server itself. Today an error inside route() escapes handle(). The client gets no answer from your code, and the log gets no line.
handle() is the one place every response your routes build passes through, so the guard goes there. (The 411, 413, and 400 that read_body answers before a route runs do not pass through it, and are not logged.) Wrap the call to route in try. In except Exception:, build the answer:
traceback.print_exc()prints theto standard error, where the reads. The tracebackis new, the project already imports it, and this one call is all you need from it. json_error(500, ...)with a fixed message of your choice.cache-controlset tono-store.An empty body when the method is
HEAD. That is the rule fromwith Python, which routedid not get to apply this time.
Keep the log line after the try, so that the failed request is logged too, as a 500. Lesson 5 wrote log_line so that it cannot fail, so nothing after the try can raise.
Why Exception, and not a bare except? Pressing Ctrl+C raises KeyboardInterrupt, which is how you stop a server from the keyboard. KeyboardInterrupt is not an Exception, so this guard lets it through, and the server still stops.
Python in Practice taught you to catch narrowly, and that still holds everywhere else. This is the one place where a wide catch is the right boundary, and it is the outermost one: whatever went wrong inside route(), the client gets an answer and the operator gets the detail.
The operator’s stream is the right place for a traceback. It is not a safe place. A traceback can hold a
Why does the guard catch Exception and not every possible exception?
Milestones
1. The attack test. A client can already make your server raise. Send GET /books/ followed by 5,000 digits: int() refuses a number that long with a handle(), and the client gets no answer at all. A guard is for that mistake and for the ones nobody has found yet. The test uses a failure that is quicker to write down: it breaks the state by hand. With steps.keyed_state() and a token for ana, it removes the books with state.pop("books") and sends GET /books. It asserts status 500, and that neither b"Traceback" nor b"KeyError" is in the body. Run python -m pytest -q: 1 failed, 20 passed. The KeyError escapes handle() and fails the test.
2. The guard. Write it in handle, as described above.
3. Everything together. python -m pytest -q ends with 21 passed. Then run both scenarios:
python pl_sec.py other-reader
python pl_sec.py guessing
Each ends with your server’s log. In the first you can read ben’s attempt:
1000000 DELETE /books/1 404 ben
4. The findings. In SECURITY.md, rows F8 and F9 each name their test and say closed.
ben's refused delete is in the log as ... DELETE /books/1 404 ben. What does that line give an operator that the 404 alone did not?
What is still open
F14 from Chapter 5 is still open: a token that leaks works until it expires.
And look at the books once more. A title may be any text a client sends, up to the body limit. Your server stores it and hands it back without a second look. The next chapter gives a book three more fields, and asks what happens when text from a request is read as something more than text.
Task
Close the last gap: an error in your own code must become a fixed 500.
In
tests/test_attacks.py, writetest_attack_error_body_shows_no_traceback(). Withstate = steps.keyed_state()andbearer = steps.bearer_for(state, "ana", "correct horse"), remove the books withstate.pop("books"), sendGET /bookswith the token in theAuthorizationheader, and assert that the status is 500 and that neitherb"Traceback"norb"KeyError"is in the body. Runpython -m pytest -q: this test must fail first.In
handleinreading_server.py, put the call torouteinsidetry. Inexcept Exception:, calltraceback.print_exc(), buildjson_error(500, ...)with a fixed message, set itscache-controltono-store, and empty its body when theis HEAD. Keep the log line after thetry, so that it is written for a 500 too.Run
python -m pytest -quntil it ends with21 passed, then runpython pl_sec.py other-readerandpython pl_sec.py guessing.In
SECURITY.md, make sure rows F8 and F9 each name a test and sayclosed.
Then press Check my work. It puts a reading_server.route and calls reading_server.handle(...): the answer must be status 500 with a fixed no-store, an empty body for HEAD, a log line with status 500, and a KeyboardInterrupt must pass through. It repeats the checks of Lessons 2 to 5 with new users, books, and times. It runs the four tests of this chapter, selected by name, against servers it puts in place of reading_server.handle and reading_server.sample_state: all four must pass against a correct one, each must fail against a server with its own mistake, and all must fail against one that refuses every request. It reads rows F8 and F9 of SECURITY.md. Nothing from your