Who May Do What, and How Often · practice
Every Book Has an Owner
Lesson 1 predicted that ben can delete ana’s book. Watch your server do it. In the
python pl_sec.py other-reader
pl_sec.py starts your server on 127.0.0.1 and sends it six requests:
pl_sec: other-reader, your server on 127.0.0.1 (a port the system chose)
pl_sec sends the __Host-session cookie by hand; a browser would not send it over plain HTTP
--- ben gets a token of his own ---
ben logs in with a password POST /login -> 200
ben asks for a token, with the cookie POST /tokens -> 200
--- what ben can reach with it ---
ben lists the books GET /books -> 200 {"books": [{"id": 1, "title": "Kindred", "author": "Octavia E. Butler"}, {"id": 2, "title": "Café Society", "author": "Ana Ruiz"}]}
ben deletes book 1, ana's DELETE /books/1 -> 204
ben reads book 1 GET /books/1 -> 404 {"error": "no book has that id"}
a request with no token GET /books -> 200 {"books": [{"id": 2, "title": "Café Society", "author": "Ana Ruiz"}]}
--- result ---
ben deleted ana's book: 204
serve() returned after 6 connections; the server is closed
pl_sec: every thread has stopped
Every credential in that run is real. ben logged in with his own password and used his own token. This is finding F8.
Write the attack first
The test repeats ben’s request. steps.keyed_state() gives a sample state with the test key in it, and steps.bearer_for(state, "ben", "battery staple") logs ben in, asks POST /tokens for a token, and returns the Authorization
First let ben add a book of his own and assert 201. That is the precondition: the test is about a writer whose token works. Then send DELETE /books/1 with the same token, and assert that the status is 404 and that 1 is still in state["books"].
Run python -m pytest -q. It ends with 1 failed, 17 passed, and the failure reads assert 204 == 404.
An owner that the client cannot choose
A book needs an owner before any rule can ask about one. Three places set it.
In sample_state(), give both books "owner": "ana".
create_book gets a third owner, and stores it in the new book. route passes claims["user"]. That name comes from inside a token your server signed, so the client cannot pick it. A body that says "owner": "ana" changes nothing, because create_book still reads only title and author from the body.
replace_book keeps the owner that the stored book already has. Chapter 7 goes one step further and refuses a body that carries fields nobody asked for.
One rule, two questions
may_view(user, book) decides every read, and may_change(user, book) decides every write. Today both are the same sentence: the book’s owner is this user. They are two
Both return False when user is None or book is None, before they compare anything. That closes the None == None trap from Lesson 1.
Refuse unless a rule allows
Now rewrite item_refusal. The supplied version
With no claims, return
unauthorized("Bearer"), for every. GETandHEADreturnNonewhenmay_viewallows.PUTandDELETEreturn the 403 when the account may not write at all, andNonewhenmay_changeallows.Any other method returns
not_allowed("GET, HEAD, PUT, DELETE"), but only for a book the caller may view.The last line returns
json_error(404, "no book has that id").
return None now appears only inside a rule. Everything that no rule allowed, and every case nobody thought of, ends at the last line. That shape is called deny by default.
Three refusals now sit side by side. 403 is for an account that may not write at all, as in
Next year someone lets PATCH reach item_refusal and writes no rule for it. With the function you wrote, what does a PATCH on ana's book by ben get?
Reads are personal now
collection_refusal gets the same first line: no claims, no access, for every method. Reading /books now needs a token, and the list may_view allows. This is a deliberate change from HTTP with Python, where the list was open to everyone.
A response that depends on who asked must not be served to someone else from a cache. The project already sets Cache-Control: private, no-cache on both book reads. private means a shared cache must not store the response. no-cache means what it meant in HTTP with Python: you may keep it, but check with me before you use it.
The second word is needed because a browser’s own cache finds a stored response by its max-age, a list stored for one reader could be shown for a minute to a request that carries another reader’s token. With no-cache the browser asks again with If-None-Match, and the server, which sees the token, decides every time: 304 when the caller’s own list is unchanged, 200 with the caller’s list otherwise.
Whether a cache obeys is the cache’s business. Your server’s part is to say it. GET /about stays public and keeps max-age=60.
ben asks for /books/1, which is ana's, and for /books/4100, which does not exist. Why should the two answers be the same?
What does Cache-Control: private, no-cache ask?
Your task
Write
test_attack_another_reader_cannot_delete_a_book()and see it fail.Give both sample books an owner, give
create_bookitsownerparameter, and keep the stored owner inreplace_book.Write
may_viewandmay_change.Rewrite
collection_refusalanditem_refusalso thatNoneis returned only under a rule.In the list loop in
route, keep only the booksmay_view(claims["user"], book)allows.Fill row F8 of
SECURITY.md, name your test in it, and set its status toclosed in 6.2.
Run python -m pytest -q again: 18 passed. Then run python pl_sec.py other-reader once more. ben’s list is empty, his delete and his read of book 1 both get 404, the request with no token gets 401, and the run ends with ben was refused: 404, and book 1 is still there.
One thing stays visible. Book ids still count upward for everyone, so the id a new book gets shows how many books exist in total. And the login that gave ben his token may still be tried as often as anyone likes. That is the next two lessons.
Task
Give every book an owner, and make the access decision refuse unless a rule allows.
In
tests/test_attacks.py, writetest_attack_another_reader_cannot_delete_a_book(). Withstate = steps.keyed_state(), getbearer = steps.bearer_for(state, "ben", "battery staple"). SendPOST /bookswith a valid book and ben’s token in theAuthorizationheader, and assert status 201. SendDELETE /books/1with ben’s token. Assert status 404 and that1is still instate["books"]. Runpython -m pytest -q: this test must fail first.In
reading_server.py, insample_state(), give both books"owner": "ana".Give
create_booka third, owner, store it in the book, and passclaims["user"]whereroutecalls it. Inreplace_book, keep the owner the stored book already has.Write
may_view(user, book)andmay_change(user, book):FalsewhenuserisNoneorbookisNone; otherwise whetherbook.get("owner")equalsuser.Rewrite
collection_refusal(method, claims): no claims isunauthorized("Bearer")for every; GETandHEADare allowed;POSTis allowed whenclaims["can_write"]and is a 403 otherwise; anything else isnot_allowed("GET, HEAD, POST").Rewrite
item_refusal(method, claims, book)so that it returnsNoneonly when a rule allows: no claims is 401;GETandHEADneedmay_view;PUTandDELETEanswer 403 when the account may not write and needmay_change; any other method on a book the caller may view isnot_allowed("GET, HEAD, PUT, DELETE"); the last line returnsjson_error(404, "no book has that id").In the
in route, keep only the booksmay_view(claims["user"], book)allows. Leave the twoPRIVATE_CACHElines as they are.Fill row F8 of
SECURITY.md, name your test in its Test cell, and set the status toclosed in 6.2.
Run python -m pytest -q until it ends with 18 passed, then press Check my work. It calls may_view, may_change, collection_refusal, and item_refusal with its own users and books. It sends requests through reading_server.handle(...) with tokens it signs itself: a reader’s list and its Cache-Control, another reader’s book on every method, an account that may only read, and bodies that name an owner. It runs your test three times, against servers it puts in place of reading_server.handle and reading_server.sample_state: a correct one, where your test must pass; one whose writes are not checked against the owner, where it must fail; and one that refuses every request, where it must fail too. It reads row F8 of SECURITY.md; the wording of a cell is not judged. Nothing from your