0%

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 , run:

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 because they are two questions. On the day a book can be shared with a friend, only one of them changes.

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 what to refuse and ends by allowing the rest. Turn it around:

  • With no claims, return unauthorized("Bearer"), for every .

  • GET and HEAD return None when may_view allows.

  • PUT and DELETE return the 403 when the account may not write at all, and None when may_change allows.

  • 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 with Python. 404 is for a book that is not the caller’s, whether it exists or not. 405 is only for a book the caller may view, because a 405 for someone else’s book would confirm that the id exists.

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 keeps only the books 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 , not by the token. With a 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

  1. Write test_attack_another_reader_cannot_delete_a_book() and see it fail.

  2. Give both sample books an owner, give create_book its owner parameter, and keep the stored owner in replace_book.

  3. Write may_view and may_change.

  4. Rewrite collection_refusal and item_refusal so that None is returned only under a rule.

  5. In the list loop in route, keep only the books may_view(claims["user"], book) allows.

  6. Fill row F8 of SECURITY.md, name your test in it, and set its status to closed 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.

  1. In tests/test_attacks.py, write test_attack_another_reader_cannot_delete_a_book(). With state = steps.keyed_state(), get bearer = steps.bearer_for(state, "ben", "battery staple"). Send POST /books with a valid book and ben’s token in the Authorization header, and assert status 201. Send DELETE /books/1 with ben’s token. Assert status 404 and that 1 is still in state["books"]. Run python -m pytest -q: this test must fail first.

  2. In reading_server.py, in sample_state(), give both books "owner": "ana".

  3. Give create_book a third , owner, store it in the book, and pass claims["user"] where route calls it. In replace_book, keep the owner the stored book already has.

  4. Write may_view(user, book) and may_change(user, book): False when user is None or book is None; otherwise whether book.get("owner") equals user.

  5. Rewrite collection_refusal(method, claims): no claims is unauthorized("Bearer") for every ; GET and HEAD are allowed; POST is allowed when claims["can_write"] and is a 403 otherwise; anything else is not_allowed("GET, HEAD, POST").

  6. Rewrite item_refusal(method, claims, book) so that it returns None only when a rule allows: no claims is 401; GET and HEAD need may_view; PUT and DELETE answer 403 when the account may not write and need may_change; any other method on a book the caller may view is not_allowed("GET, HEAD, PUT, DELETE"); the last line returns json_error(404, "no book has that id").

  7. In the in route, keep only the books may_view(claims["user"], book) allows. Leave the two PRIVATE_CACHE lines as they are.

  8. Fill row F8 of SECURITY.md, name your test in its Test cell, and set the status to closed 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 is graded.