0%

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. over three values of 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 that has a named 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 with 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 "&lt;b&gt;Dune&lt;/b&gt;" 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 value as the first line of safe_redirect_target.

  • Put return pathlib.Path(folder) / name as the first line of safe_cover_path.

  • In render_page, change html.escape(book["title"]) to book["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 is one text or a of texts, and 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 too: a branch that writes a number, such as 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 is 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

  1. The three tests, each seen failing once with its guard switched off. With all guards back, python -m pytest -q ends with 26 passed.

  2. The loop in handle(). The tests still end with 26 passed.

  3. The whole scenario:

python pl_sec.py injection

Read all four parts. The result says:

--- result ---
0 of 4 attacks worked
  1. 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 is test_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 to closed 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.

  1. In tests/test_attacks.py, below the Chapter 7 comment, write three tests. Reach the server through reading_server.handle(...).

    • test_attack_next_cannot_leave_the_site(): for each next in %2F%2Fother.example%2Fwelcome, %2F%5Cother.example, and https:%2F%2Fother.example%2F, log ana in with POST /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): make tmp_path / "covers" with a file kindred.txt, write secret.txt next to the folder, set state["covers_dir"] to the folder, assert 200 for GET /covers/kindred.txt, and assert 404 for GET /covers/..%2fsecret.txt and that the secret’s text is not in the body.

    • test_attack_title_cannot_become_markup(): with steps.keyed_state() and steps.bearer_for(state, "ana", "correct horse"), create a book titled <b>Dune</b> and assert 201; fetch GET / with the from steps.login_cookie(state, "ana", "correct horse") and assert 200; assert that "<b>" is not in the page text and that "&lt;b&gt;Dune&lt;/b&gt;" is.

  2. In handle in reading_server.py, after the try that calls route() and before the log line, over response["headers"] and call header_value() on every value, inside a try. A value is one text or a of texts: check every item of a list. In except (ValueError, TypeError):, replace the response with json_error(500, ...) and the fixed message, set its cache-control to no-store, and empty its body when the is HEAD.

  3. Run python -m pytest -q until it ends with 26 passed, then run python pl_sec.py injection.

  4. In SECURITY.md, fill rows F10 and F11. F10 names test_attack_body_cannot_set_the_owner. F11 names at least one of the other four tests of this chapter. Both say closed 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 whose value holds a line break in place of 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 is graded.