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 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
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 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
| Step | The page is on | It asks for | The server answers | Cookie sent | The page |
|---|---|---|---|---|---|
| 0 | reading.example | log in (a fetch with Basic credentials) | 200 | no | reads it; the cookie is stored |
| 1 | app.reading.example | its own page, from the second site | 200 | no | reads it |
| 2 | app.reading.example | POST /tokens with credentials | 200 | yes | reads the token |
| 3 | app.reading.example | GET /books with the token, after a preflight | 200 | no | reads the list |
| 4 | app.reading.example | PUT /books/1 with the token, after a preflight | 200 | no | reads the book |
| 5 | app.reading.example | GET /me with credentials | 200 | yes | reads ana |
| 6 | notes.reading.example | the delete form, with a guessed token | 403 | yes | cannot read a form’s answer |
| 7 | notes.reading.example | POST /tokens with credentials | 403 | yes | may not read it |
| 8 | notes.reading.example | GET /me with credentials | 200 | yes | may not read it |
| 9 | other.example | the delete form | 403 | no | cannot read a form’s answer |
| 10 | other.example | GET /about | 200 | no | may not read it |
| 11 | other.example | GET /books with a token | never sent; the preflight was refused | no | blocked |
| 12 | none: a client that is not a browser | GET /about with Origin: https://other.example | 200 | no | it 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
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 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
Run
python pl_sec.py two-sitesin the. Its result block must say that all 13 steps match the contract in SECURITY.md, thatserve()returned, and that every thread has stopped.When a step differs, find the test in
tests/test_attacks.pyortests/test_browser.pythat 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 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