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 id=1. Everything the
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 GET /, and it cannot read a
That value is a token: a random text from secrets.token_urlsafe(32), the function that also makes your 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
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 /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:
In
start_session, add"csrf": secrets.token_urlsafe(32)to the session. In the
"/"branch ofroute, passsession["csrf"]torender_pageas its third. Write
csrf_matches(session, sent)with the comparison above. Spell the callhmac.compare_digest(...).In
delete_from_form, after the form is read, returnjson_error(403, "the form did not carry this session's token")unlesscsrf_matches(session, fields.get("csrf", "")).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
In
start_sessioninreading_server.py, add"csrf": secrets.token_urlsafe(32)to the session. In the
"/"branch ofroute, callrender_page(books, session["user"], session["csrf"]).Write
csrf_matches(session, sent). It returnshmac.compare_digest(sent.encode("utf-8"), session["csrf"].encode("utf-8")):Trueonly whensentis the token of this session. Callcompare_digestthrough the, spelled hmac.compare_digest(...).In
delete_from_form, after the form is read and before theidis looked at, returnjson_error(403, "the form did not carry this session's token")unlesscsrf_matches(session, fields.get("csrf", "")). Read the token from the form body only.In the comment at the top of
tests/test_attacks.py, removetest_attack_another_origin_cannot_delete_a_bookfrom 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 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