0%

Capstone: Harden the Reading List · capstone

Capstone project: Two Sites on Loopback

The proof is done. Eleven tests pass, the model’s six pass, and every one of them ran in memory: a request went into handle() and a response dict came out. This lesson adds no proof. It adds a demonstration: the same service, behind the real adapter, answering real bytes on a , with a second site beside it.

That is worth seeing once, because memory skips things. In memory there is no Content-Length to get wrong, no preflight that travels as its own request, no second server, and no serve() that has to stop.

The sequence

python pl_sec.py two-sites starts two servers on 127.0.0.1. One is your reading , through your make_server() and serve(). The other is a small second site that serves the front end’s own page. Then it plays three kinds of page through your visit.visit() and your mini_browser.py: the front end at https://app.reading.example, the neighbor at https://notes.reading.example, and a page at https://other.example. The names exist only in the model. Every byte goes to this computer.

Ana is logged in at https://reading.example from step 0 on, and the model’s jar holds her session cookie. Before you run anything, cover the last three columns and predict them.

StepThe page is onIt asks forThe server answersCookie sentThe page
0reading.examplelog in (a fetch with Basic credentials)200noreads it; the cookie is stored
1app.reading.exampleits own page, from the second site200noreads it
2app.reading.examplePOST /tokens with credentials200yesreads the token
3app.reading.exampleGET /books with the token, after a preflight200noreads the list
4app.reading.examplePUT /books/1 with the token, after a preflight200noreads the book
5app.reading.exampleGET /me with credentials200yesreads ana
6notes.reading.examplethe delete form, with a guessed token403yescannot read a form’s answer
7notes.reading.examplePOST /tokens with credentials403yesmay not read it
8notes.reading.exampleGET /me with credentials200yesmay not read it
9other.examplethe delete form403nocannot read a form’s answer
10other.exampleGET /about200nomay not read it
11other.exampleGET /books with a tokennever sent; the preflight was refusednoblocked
12none: a client that is not a browserGET /about with Origin: https://other.example200noit prints the body

The status column is what pl_sec.py sees where it carries each request to the server. A page never sees it for a form, and visit() returns only what the page may read.

Steps 6 and 9 get the same status for different reasons, and the cookie column is where they differ. In step 9, SameSite=Lax kept the cookie at home, and the Origin check refused the request as well. In step 6 the cookie went along, because the neighbor is on the same site, and the Origin check refused the form before the token was looked at.

In step 8 the server answers 200 with Ana's name, to a request a page on notes.reading.example caused. Is that a hole?

Run it

python pl_sec.py two-sites

With every finding closed and the model repaired, this is the run:

pl_sec: two-sites, your server and a second site on 127.0.0.1 (a port the system chose)
The names exist only in the model. Every byte goes to this computer, over plain HTTP.
--- ana logs in at the reading list ---
step  0  reading.example        log in                                 -> 200        cookie: no   reads the answer
--- the front end ---
step  1  app.reading.example    its own page, from the second site     -> 200        cookie: no   reads the answer
step  2  app.reading.example    POST /tokens with credentials          -> 200        cookie: yes  reads the answer
step  3  app.reading.example    GET /books with the token              -> 200        cookie: no   reads the answer
step  4  app.reading.example    PUT /books/1 with the token            -> 200        cookie: no   reads the answer
step  5  app.reading.example    GET /me with credentials               -> 200        cookie: yes  reads the answer
--- the neighbor on the same site ---
step  6  notes.reading.example  the delete form, with a guessed token  -> 403        cookie: yes  does not read the answer
step  7  notes.reading.example  POST /tokens with credentials          -> 403        cookie: yes  does not read the answer
step  8  notes.reading.example  GET /me with credentials               -> 200        cookie: yes  does not read the answer
--- a page on another site ---
step  9  other.example          the delete form                        -> 403        cookie: no   does not read the answer
step 10  other.example          GET /about                             -> 200        cookie: no   does not read the answer
step 11  other.example          GET /books with a token                -> never sent cookie: no   does not read the answer
--- a client that is not a browser ---
step 12  GET /about with Origin: https://other.example  -> 200  it prints the body: {"service": "reading list", "version": 1}
--- result ---
book 1 is still in the list, with status 'done'
all 13 steps match the contract in SECURITY.md
serve() returned after 16 connections; the server is closed
pl_sec: every thread has stopped

The reading list handled fourteen requests: thirteen from the model and the plain client’s one. pl_sec.py allows your server sixteen connections, opens and closes the ones nobody used, and then waits for serve() to return, which is why the last lines can say that the server is closed.

When a step differs from the contract, the result block names the step and the column. Do not repair anything from the transcript alone. Find the test that covers that step, in tests/test_attacks.py or tests/test_browser.py, make it fail, and work from there, as in the last two lessons. A difference in the cookie column is the model’s. A difference in the status is the server’s. A difference in what the page may read can be either.

What the run cannot show

It cannot show finding 10. In step 6 the neighbor’s form carries a guessed token, and the Origin check refuses it before the token is looked at. The step would read exactly the same with finding 10 open. Only your test proves that finding, by sending a trusted Origin with another ’s token.

And it does not show a browser. No real browser took part in this course on the site. The three “pages” are request descriptions handed to a model that you wrote and that this chapter had you repair, and the model follows the standards only as far as its rules go.

Step 12 gets the body that step 10 was not allowed to read. What does that say about CORS?

Finish

Run everything one last time:

python -m pytest -q

Seventeen tests pass. SECURITY.md is as frozen as it was, and its state is now this: eleven findings closed, each with a test you wrote without being told where to look, and F14 open on purpose. A signed token that leaks still works until it expires, fifteen minutes at most, and closing that would take a stored list of withdrawn tokens that this service does not have.

Then press Check my work. It cannot see your , so the run is yours alone. It runs the same thirteen steps in memory, with your server and your model, and compares every row with the contract; when a row differs it swaps in a correct model and then a correct server to tell you which side to look at. It runs your eleven tests against your files, and then each of them once more against a server that has its finding, to see that no test was loosened on the way to green. It checks that serve() stops after the connections it was given and closes its server, with a stand-in that opens nothing. And it runs the earlier chapters’ attack tests one more time.

Task

Run the finished service and a second site on the , and repair anything the run shows.

  • Run python pl_sec.py two-sites in the . Its result block must say that all 13 steps match the contract in SECURITY.md, that serve() returned, and that every thread has stopped.

  • When a step differs, find the test in tests/test_attacks.py or tests/test_browser.py that covers it, make it fail, and repair the code from there. Do not loosen a test to get it green.

  • Run python -m pytest -q. All seventeen tests pass.

If Lessons 2 and 3 are complete, this lesson needs no new code.

Then press Check my work. It cannot see your terminal. It runs the same thirteen steps in memory through visit.visit(), your mini_browser.py, and your handle(), and compares the status, the , and what the page may read with the contract; when a row differs, it tries a correct model and a correct server in turn to say which side to look at. It runs your eleven tests against your files, and each of them again against a server that has its finding, where it must still fail. It runs tests/test_browser.py, the rows of “Must still work”, and its own copy of the attack tests from Chapters 1 to 10. It calls serve() with a stand-in server that opens nothing and counts handle_request() calls, and make_server() with http.server replaced, so no is bound.