0%

Origins and Cross-Site Requests · practice

A Token the Other Page Cannot Know

Your attack test is red. Look at what delete_from_form gets to see of the forged request and of the honest one: a POST for /books/delete, Ana’s , and a body that says id=1. Everything the looks at is the same in both. To tell them apart, the server needs something that its own page has and that the other page cannot get.

A secret in the page

Lesson 4 gave you the rule that makes this possible. A page on notes.reading.example may send requests to the reading , and it may not read the responses. So it cannot read GET /, and it cannot read a that your server writes into that page. The now works for the server.

That value is a token: a random text from secrets.token_urlsafe(32), the function that also makes your , here with 32 bytes. The server stores it in the session and writes it into a hidden field of each delete form. page.html already has the field, and render_page(books, user, token="") already fills it, escaped like every other value. Until now nobody passed a token, so the field was empty. When a form comes in, the server compares its field with the token in the session.

the server                        the browser

session of ana holds the token T
GET /  ->  page with csrf = T ->  page of reading.example
                                  posts id=1&csrf=T  ->  T matches: 303

                                  page of notes.reading.example
                                  may not read that page
                                  posts id=1         ->  no token: 403

In words: the server keeps the token T in Ana’s session and writes it into the page that it serves her. The reading list’s own page posts T back with the form, and the book is deleted. A page on the neighbor cannot read the reading list’s page. Its form carries no T, and the server answers 403.

Why can a page on notes.reading.example not put the right token in its forged form?

Bound to the session, renewed at login

There is one token per session, and it is made where the session is made: in start_session. A token that the server accepted for any session would be worth nothing. The attacker has a session too, and can read a token in a page of their own.

Making the token there has a second effect. In Chapter 4 you wrote rotate_session so that a login ends the old session and starts a new one, and copies the page size and nothing else. So a login gets a new token without one more line of code. The reason is the one you know from the session id: a token that survived the login would be a token that the holder of the visitor session already knew.

A server accepts any token that exists in any session. What can Ben do?

Compare it like a secret

The token is a secret that the server compares with one expected value, so the comparison goes through hmac.compare_digest, as in Chapter 2. One detail is new. The field holds whatever the client sent, and compare_digest refuses text that is not ASCII:

hmac.compare_digest("é", "abc")
TypeError: comparing strings with non-ASCII characters is not supported

Inside your server that would turn a forged form into a 500. So encode both sides and compare bytes:

hmac.compare_digest(sent.encode("utf-8"), session["csrf"].encode("utf-8"))

A form without the field is compared as empty text, fields.get("csrf", ""), and fails like any other wrong token.

The refusal is a 403 and not a 401. The server knows whose session this is, and logging in again would not help. It refuses this one request.

A logged-in session posts the form without a token. Which status?

Only from the form body

Read the token from the form body and from nowhere else. A token that the server also accepts in the query , as in /books/delete?csrf=..., ends up in logs, in the browser’s history, and in links that people share.

Why is a token in ?csrf=... not accepted?

Your task

Make five changes. Four are in reading_server.py:

  1. In start_session, add "csrf": secrets.token_urlsafe(32) to the session .

  2. In the "/" branch of route, pass session["csrf"] to render_page as its third .

  3. Write csrf_matches(session, sent) with the comparison above. Spell the call hmac.compare_digest(...).

  4. In delete_from_form, after the form is read, return json_error(403, "the form did not carry this session's token") unless csrf_matches(session, fields.get("csrf", "")).

  5. In the comment at the top of tests/test_attacks.py, take your test of Lesson 5 off the list of tests that are expected to fail.

Then run the tests:

python -m pytest -q

The run ends with 30 passed: the forged form carries no token, and the book stays.

The token has limits. It protects the form of this page, and it does not help when an attacker’s script runs inside the page itself. Keeping such a script out is the job of the escaping you wrote in Chapter 7. Two variants have names that you may meet, per-request tokens and double-submit cookies, and this course teaches neither.

One more forgery deserves a few sentences: a forged login. A page that could log Ana’s browser in with the attacker’s own credentials would log her in as the attacker. Here a login needs a Basic header with the attacker’s own name and password, and a form cannot set that header. By the standards a browser can still be made to send one: a form whose address carries a user name and password, such as https://ben:pw@reading.example/login, may answer the server’s 401 with them, and some browsers warn first. The project in Lesson 8 puts the Origin check on /login, and that check refuses such a request either way. Do not read this as “Basic authentication cannot be forged”: once a browser has asked its user for Basic credentials, it remembers them and attaches them to later requests, as it does a cookie.

Then press Check my work.

Task

Give every a token, write it into the page’s form, and refuse a delete form that does not carry it.

  1. In start_session in reading_server.py, add "csrf": secrets.token_urlsafe(32) to the session .

  2. In the "/" branch of route, call render_page(books, session["user"], session["csrf"]).

  3. Write csrf_matches(session, sent). It returns hmac.compare_digest(sent.encode("utf-8"), session["csrf"].encode("utf-8")): True only when sent is the token of this session. Call compare_digest through the , spelled hmac.compare_digest(...).

  4. In delete_from_form, after the form is read and before the id is looked at, return json_error(403, "the form did not carry this session's token") unless csrf_matches(session, fields.get("csrf", "")). Read the token from the form body only.

  5. In the comment at the top of tests/test_attacks.py, remove test_attack_another_origin_cannot_delete_a_book from the tests that are expected to fail.

Run python -m pytest -q until it ends with 30 passed.

Then press Check my work. It logs users of its own in and drives your server in memory, with a browser model of its own. A new session must hold a token of at least 22 characters that came from secrets, called through the module, and two sessions must hold different tokens. The page from GET / is read with Python’s HTML parser: each delete form must carry the session’s token in its csrf field, and the page’s own form must still delete. Then forms are posted from a neighboring host that the check has added to state["trusted_origins"]: with a wrong token, another session’s token, no token, an empty one, a token that is not ASCII, and the right token in the query . Each must leave the book in place and get a 403. A login that arrives with a visitor session’s must end with a new token. A recording is put in place of reading_server.hmac.compare_digest: the comparison must go through it. Last, your attack test of Lesson 5 is run once against your own server and must pass. Nothing from your is graded.