Input That Becomes Code · capstone
Capstone project: Project: Nothing Crosses Unchecked
This chapter has one rule in two halves. Check a body on the way in, with an allowlist and limits. Guard each place where text goes out into something that is parsed: a header, a redirect, a file path, a page. Neither half stands in for the other. <b>Dune</b> is a valid title, and it still has to be escaped in the page.
The validator and the four guards are written. Two of the five have an attack test. This project writes the other three tests, adds one guard on everything that leaves handle(), and closes two findings.
Three more attack tests
Each test performs an attack and asserts that it is refused, as in Lessons 1 and 4. Each also asserts that an honest request works, so that it cannot pass against a server on which nothing works.
test_attack_next_cannot_leave_the_site. next: %2F%2Fother.example%2Fwelcome, %2F%5Cother.example, and https:%2F%2Fother.example%2F. For each one, log ana in with a fresh state and assert that the status is 303 and that location is exactly /. The 303 is the honest half: the login worked.
test_attack_cover_name_cannot_leave_the_folder. This test brings its own folder. Pytest gives a fresh temporary folder, as a Path, to every test tmp_path. Start like this:
def test_attack_cover_name_cannot_leave_the_folder(tmp_path):
"""A cover name with an encoded ../ in it must not reach a file outside the covers folder."""
covers = tmp_path / "covers"
covers.mkdir()
(covers / "kindred.txt").write_text("a cover", encoding="utf-8")
(tmp_path / "secret.txt").write_text("not a cover", encoding="utf-8")
state = reading_server.sample_state()
state["covers_dir"] = covers
Then assert 200 for GET /covers/kindred.txt. Assert 404 for GET /covers/..%2fsecret.txt, and that b"not a cover" is not in that body.
test_attack_title_cannot_become_markup. With steps.keyed_state() and a token for ana, create a book titled <b>Dune</b> and assert 201. Get her steps.login_cookie(state, "ana", "correct horse"), send GET / with it in a Cookie header, and assert 200. Decode the body, and assert that "<b>" is not in the text and that "<b>Dune</b>" is.
All three pass as soon as you write them, because the guards are already there. That proves little. See each test fail once: switch its guard off by hand, run python -m pytest -q, and switch it on again.
Put
return valueas the first line ofsafe_redirect_target.Put
return pathlib.Path(folder) / nameas the first line ofsafe_cover_path.In
render_page, changehtml.escape(book["title"])tobook["title"].
Each time the run ends with one failure, and it is the test for that guard.
Check my work does the same thing more thoroughly. It runs each of your five tests against a server of its own that has every guard, where the test must pass. Then it runs the test against a server with that one guard missing, where the test must fail.
Your test for the cover route passes. Check my work swaps in a server whose cover route checks nothing, and your test still passes. What does that tell you?
One guard on everything that leaves
redirect() guards one header. Your server writes others, such as etag, allow, and set-cookie, and next month a new branch may build one from something a request sent. Every response passes through handle(), so one loop there covers them all:
try:
for name in response["headers"]:
values = response["headers"][name]
if isinstance(values, str):
values = [values]
for value in values:
header_value(value)
except (ValueError, TypeError):
A header http_adapter writes each item of a list as a line of its own. So the loop turns a single text into a one-item list and checks every item. The except names 0, makes header_value raise it, and that mistake should end in the same fixed 500.
You write the except branch. It replaces the response with the fixed 500 of Chapter 6: json_error(500, ...) with the same message, cache-control set to no-store, and an empty body when the HEAD. Put the whole block after the try that calls route() and before the log line, so that the log shows the status that was sent.
This is fail closed, the rule from Chapter 5, applied to output. A header mistake in some branch becomes an error that you can see in the log, and not a reply with a line that someone else wrote. The loop checks headers only. A body is checked by the code that writes it, as render_page does.
Milestones
The three tests, each seen failing once with its guard switched off. With all guards back,
python -m pytest -qends with26 passed.The loop in
handle(). The tests still end with26 passed.The whole scenario:
python pl_sec.py injection
Read all four parts. The result says:
--- result ---
0 of 4 attacks worked
The findings. In
SECURITY.md, fill rows F10 and F11 in your own words. F10 is the body that set a book’s owner, and its test istest_attack_body_cannot_set_the_owner. F11 is request text that was read as instructions in a header, a redirect, a path, and a page, and it names the other four tests. Set both toclosed in 7.8.
What is still open
Look at the delete form once more. POST /books/delete is authorized by the session cookie alone, and nothing in the request shows that it came from your page. Chapter 8 builds a small model of a browser and uses it to send that form from somebody else’s page.
Task
Write the chapter’s last three attack tests, guard every header that leaves handle(), and close two findings.
In
tests/test_attacks.py, below the Chapter 7 comment, write three tests. Reach the server throughreading_server.handle(...).test_attack_next_cannot_leave_the_site(): for eachnextin%2F%2Fother.example%2Fwelcome,%2F%5Cother.example, andhttps:%2F%2Fother.example%2F, log ana in withPOST /login?next=and that, and assert that the status is 303 and that response["headers"]["location"] == "/".test_attack_cover_name_cannot_leave_the_folder(tmp_path): maketmp_path / "covers"with a filekindred.txt, writesecret.txtnext to the folder, setstate["covers_dir"]to the folder, assert 200 forGET /covers/kindred.txt, and assert 404 forGET /covers/..%2fsecret.txtand that the secret’s text is not in the body.test_attack_title_cannot_become_markup(): withsteps.keyed_state()andsteps.bearer_for(state, "ana", "correct horse"), create a book titled<b>Dune</b>and assert 201; fetchGET /with thefrom steps.login_cookie(state, "ana", "correct horse")and assert 200; assert that"<b>"is not in the page text and that"<b>Dune</b>"is.
In
handleinreading_server.py, after thetrythat callsroute()and before the log line,over response["headers"]and callheader_value()on every value, inside atry. A value is one text or aof texts: check every item of a list. In except (ValueError, TypeError):, replace the response withjson_error(500, ...)and the fixed message, set itscache-controltono-store, and empty its body when theis HEAD.Run
python -m pytest -quntil it ends with26 passed, then runpython pl_sec.py injection.In
SECURITY.md, fill rows F10 and F11. F10 namestest_attack_body_cannot_set_the_owner. F11 names at least one of the other four tests of this chapter. Both sayclosed in 7.8.
Then press Check my work. It repeats the checks of Lessons 1, 2, and 4 to 7 with new values: bodies, header values, redirect targets, a folder of covers of its own, a page read with Python’s HTML parser, and forms. It puts a reading_server.make_etag, and a function that returns a list-valued header in place of reading_server.route: handle() must answer the fixed 500 with no-store, log a 500, and let no line break out, and an ordinary request must still answer 200. It runs your five tests of this chapter, selected by name, against servers it puts in place of reading_server.handle and of the functions the guards live in: each test must pass against a server with every guard, fail against a server with its own guard missing, and fail against a server that refuses everything. No test may open a connection. It reads rows F10 and F11 of SECURITY.md; their wording is not judged. Nothing from your