Capstone: Harden the Reading List · capstone
Capstone project: Close the Server-Side Holes
You have eleven red tests and a server whose weaknesses you have not yet seen in the code. This lesson closes the first nine. They all live on the server’s side: in reading_server.py, signing.py, and passwords.py. Findings 10 and 11 are about requests from other pages, and they wait for the next lesson.
The
Run one test and read its failure.
Find the code the failure points at.
Change it.
Run all the tests, to see that the one you wanted turned green and that nothing else turned red.
python -m pytest tests/test_attacks.py -q -k form_cannot_delete
-k selects the tests whose names contain that text. Without it, all eleven run.
From a symptom to a line
Here is the whole path once, for finding 7. The other eight are yours.
The symptom is “Another reader can delete my book with the form on the page.” Your test logs Ben in, posts the form for book 1 with Ben’s own
E assert 303 == 404
A 303 is the answer to a delete that worked. So the request passed every check on its way: the method, the Origin, the
The form posts to /books/delete. In route(), that branch finds the session and calls delete_from_form(). Read that DELETE /books/1 with a token asks may_change() through item_refusal(). The form is a second path to the same delete, and it skipped the question. That was the lesson of Chapter 6: an owner rule checked on one path and not on another is not a rule.
The fix is one
Notice what found the line. It was not a search for “bug”. It was the test’s failure, the order of checks in the contract, and one rule you already know. Each of the other findings yields to the same three things. The rule is in the chapter that first closed the finding, and the function you wrote then is usually still in the file, just no longer called or no longer complete.
Do not close the door on everyone
A finding is closed when its attack is refused and every honest request still works. The second half is easy to lose. Take finding 9. This change makes its test pass at once:
def safe_cover_path(folder, name):
return None
No name can leave the folder now. No cover is served either, and SECURITY.md says under “Must still work” that GET /covers/kindred.txt returns the cover. Check my work reads that
A change makes safe_cover_path() return None for every name. The test for finding 9 passes. Is the finding closed?
The same care applies to the status a refusal gets. The contract answers 404 for a book that is not the caller’s, so that the answer does not confirm the id, and 422 for a body that names a field the client may not set. A fix that answers 403, or that quietly drops the field, closes the hole and breaks the contract.
Your task
Close findings 1 to 9, in the contract’s order. After each one, run all the tests:
python -m pytest tests/test_attacks.py -q
The count of failures should go down by one each time:
| After finding | Failures to expect |
|---|---|
| 1 | 10 |
| 2 | 9 |
| 3 | 8 |
| 4 | 7 |
| 5 | 6 |
| 6 | 5 |
| 7 | 4 |
| 8 | 3 |
| 9 | 2 |
If the count drops by two, read the diff again: one change closed something you did not mean to touch, and you want to know why. If it does not drop, the failure message of that one test is the place to look.
Two hints about where things are. Findings 1 and 2 are not in reading_server.py: a login’s work is done in passwords.py, and a tag is compared in signing.py. Findings 5 and 6 are in signing.py too. Everything else is in reading_server.py.
After this lesson two tests still fail. Which, and why is that expected?
Then press Check my work. It calls your functions and handle() with users, books, a key, and clock times you have not seen, once for each of the nine findings, and it does not look at how a fix is written, only at what the code does. It checks every row of “Must still work”. It runs your own tests for findings 1 to 9 against your files. And it runs its own copy of the attack tests you wrote in Chapters 1 to 10, because the test file in this chapter started empty and nothing else guards those earlier rows.
Task
Close findings 1 to 9 of SECURITY.md in reading_server.py, signing.py, and passwords.py, without breaking anything under “Must still work”.
After your changes:
A login for a name that does not exist costs one
hashlib.scryptcall at the usual cost, like a wrong password, and gets the same answer.signing.verify()decides throughhmac.compare_digest.A login always gets a new
. The session the visitor held is removed, and its page size is kept. A session that has not been used for longer than
IDLE_LIMITis refused and removed. A session in use still lasts untilABSOLUTE_LIMIT.A token is refused when any part of its payload was edited,
expincluded. A token the server made is accepted untilexp.signing.load_keys()raiseswhen READING_KEYis missing, empty, or shorter than 32 bytes, andmain()then prints one line on standard error and returns 1.The delete form answers 404 for a book that is not the caller’s, and the book stays.
A book body that names
owneroridis answered 422 with the key in the message, and nothing is stored.safe_cover_path()returns a path only for a file inside the resolved covers folder.
Leave findings 10 and 11, and mini_browser.py, for the next lesson.
Run python -m pytest tests/test_attacks.py -q after each finding. The failures go from 11 down to 2.
Then press Check my work. It checks each of the nine findings through handle() and your hashlib.scrypt and hmac.compare_digest where a finding is about work done. It checks the rows of “Must still work”: one password hash for a correct login, a session that stays alive while it is used, a token under the previous key, a reader’s own delete, a book with all five fields, a cover, and the routes of the contract. It runs your tests for findings 1 to 9 against your files, and its own copy of the attack tests from Chapters 1 to 10. It never opens a