FastHTML and CSRF Tokens
PythonUPDATES
23/09/26
-
Small errata on code examples.
-
Explain where to use CSRF tokens. (Thanks, Rob!)
-
Briefly explain Sec-Fetch-Site. (Thanks, Rob!)
A Cross Site Request Forgery attack is where a hostile website is able to make requests to another website using the user's cookies to perform actions as that user without that user's knowledge or consent. It's a long-standing issue in web development, and many frameworks shield against it by one of the following:
-
Setting a token and putting that token in a form on the website. The hostile site can't make requests without that token, and it can't access that token. This token is checked on the back-end and compared to a token set locally, which the hostile site also can't access.
-
Setting the token in a header from the website. Like the above option, the hostile website can't see this header so cannot use it.
FastHTML's documentation doesn't describe how to do this, so here's how I do it.
Setting the token
Suppose you have a login route, "/login".For the sake of this exercise
let's assume you have a Users model and can handle authentication through
the model, along with a Home page defined somewhere.
from app.pages import Home
@route("/login")
def post(request, session, username, password):
user = users_model.find_user(username)
if not user: raise Exception("User not registered.")
if not user.validate_password(password): raise Exception("Incorrect password.")
else:
session.auth["user"] = user.id
return redirect("/home")
We want to add a CSRF token. We can do that by generating a random token and attaching it to the session.
from app.pages import Home
from secrets import token_hex
@route("/login")
def post(request, session, username, password):
user = users_model.find_user(username)
if not user: raise Exception("User not registered.")
if not user.validate_password(password): raise Exception("Incorrect password.")
else:
session.auth["user"] = user.id
session.auth["csrf_token"] = token_hex(16)
return Home()
Note that this attaches it to the FastHTML session, which is stored in browser but can be accessed through the server. This is kept in the local storage of the browser, but it is not sufficient to prevent CSRF attacks because a third party site can still send requests to your site using the session. You want to attach something to the page document itself and verify that. You can attach it in two places:
- Every input form which calls something that verifies the token;
- HTMX allows headers on the body object which cascade down to the element making the request.
I prefer using HTMX to make requests, so let's use option 2.
import json
from secrets import token_hex
from app.pages import Home
@route("/login")
def post(request, session, username, password):
user = users_model.find_user(username)
if not user: raise Exception("User not registered.")
if not user.validate_password(password): raise Exception("Incorrect password.")
else:
csrf_token = token_hex(16)
session.auth["user"] = user.id
session.auth["csrf_token"] = csrf_token
htmx_headers = json.dumps({"x-csrf-token": csrf_token})
return Home(hx_headers=htmx_headers)
Using the token
Recall that the token is kept with the main html element as part of
hx-headers, under x-csrf-token. The magic of HTMX allows any
child element to use that token in the header of any request a HTMX element
can make.
This, at least, verifies that the request came from our document, and not somebody else's.
Let's suppose you have a route which allows a mutable operation to happen on the site. Let's call it "ban_user", which allows an administrator to ban a user. It's on an administration page somewhere and is attached to a input box with a "ban user" button on it. The details here are irrelevant, it's an HTMX input box, and as such has this token attached to it.
@route("/ban-user")
def post(request, session, user_to_ban):
current_user_id = session.auth["id"]
is_admin = users_model.find_user_by_id(current_user_id).is_admin
if not is_admin:
raise Exception("Unauthorised.")
else:
user = users_model.find_user(username)
if not user: raise Exception("User not found.")
else:
user.disable()
return "User banned"
We want a CSRF token. HTMX has already sent it in the request headers, so
we can access it using request.headers['x-csrf-token'].
@route("/ban-user")
def post(request, session, user_to_ban):
has_csrf_token = request.headers['x-csrf-token'] == session['csrf_token']
if not has_csrf_token: raise Exception("Unauthorised")
current_user_id = session.auth["id"]
is_admin = users_model.find_user_by_id(current_user_id).is_admin
if not is_admin: raise Exception("Unauthorised.")
else:
user = users_model.find_user(username)
if not user: raise Exception("User not found.")
else:
user.disable()
return "User banned"
Testing
If we're using Starlette's TestClient (and we should!), we cannot send
requests from the document as the document requires JavaScript to have
executed.
from starlette.testclient import TestClient
def mock_admin_auth(client: TestClient):
client.post("/login") # logs in as test user
return client
def test_csrf_token():
ts = mock_admin_auth(TestClient())
ts.post("/ban-user", {"username": "other_user"})
assert ts.status == 200
This won't work because the TestClient isn't sending requests with HTMX, and as such doesn't have that header. You will want to extract the set header from the HTML document and send that. We can do that with lxml.
import json
from starlette.testclient import TestClient
import lxml
def extract_hx_hdr(html: str):
html_tag = lx.fromstring(html).xpath('/html')[0]
assert 'hx-headers' in html_tag.attrib
hx_hdr = html_tag.attrib['hx-headers']
return json.loads(hx_hdr)
def mock_admin_auth(client: TestClient):
client.post("/login") # logs in as test user
return client
def test_csrf_token():
ts = mock_admin_auth(TestClient())
hx_hdr = extract_hx_hdr(ts.response)
ts.post("/ban-user",
{"username": "other_user"},
headers=hx_hdr
)
assert ts.status == 200
Done!
It's worth pointing out that there are other ways to handle CSRF tokens, but this is one I have found to work. Most other web frameworks do this for you, I think there is a case for FastHTML to do it for you too, but until that happens, you need to roll it yourself.
Postscript
Where to put CSRF protection
Usual practice is to apply CSRF protection on routes which alter the state of
the website, mainly POST, PUT, PATCH, and DELETE operations.
However, consider the following: CSRF affects GET requests too. The page can't
see the output because of the Same Origin
Policy,
but the action has still taken place.
Suppose you have something on your web service that locks up the rest of the
service, like a dashboard or something that makes a really big SELECT against
a database. If an attacker requests that resource by putting it in an
<img> tag on his site, that request is still made, tying up the web
service for every other user. Maybe this is the role of making pages efficient.
Other mitigations
There are other ways of protecting against CSRF attacks. One is the
Sec-Fetch-Site header. Setting it to same-site, which can't be set via
JavaScript.
The thinking is that just checking this header should be sufficient. The request can't be tinkered with by an attacking site through JavaScript as this header is set by the browser.
The standard was formalised and all browsers after 2023 should use it.
In theory at least, all you would need to do check for sec-fetch-site
in your request headers.
def same_origin(request):
return 'Sec-Fetch-Site' in request.headers \
and request.headers['Sec-Fetch-Site'] == "same-origin"
@route("/ban-user")
def post(request, session, user_to_ban):
if not same_origin(request): raise Exception("Unauthorised")
current_user_id = session.auth["id"]
is_admin = users_model.find_user_by_id(current_user_id).is_admin
if not is_admin: raise Exception("Unauthorised.")
else:
user = users_model.find_user(username)
if not user: raise Exception("User not found.")
else:
user.disable()
return "User banned"
You could put it in Beforeware.
def same_origin(request, session):
is_same_origin = "Sec-Fetch-Site" in request.headers \
and (request.header["Sec-Fetch_Site"] == "same-origin")
if not is_same_origin: return "Access denied"
origin_beforeware = Beforeware(
same_origin,
skip=[r'/favicon\.ico', r'/static/.*', r'.*\.css', '/login', '/send_login']
)
If you're very flash, you could implement this as a decorator.
def same_origin(request):
return 'Sec-Fetch-Site' in request.headers \
and request.headers['Sec-Fetch-Site'] == "same-origin"
def require_same_origin(func):
@wraps(func)
async def wrapper(request, session, *args, **kwargs):
if not same_origin(request):
raise HTTPException(status_code=403)
return await func(request, session, *args, **kwargs)
return wrapper
@require_same_origin
@route("/ban-user")
def post(request, session, user_to_ban):
current_user_id = session.auth["id"]
is_admin = users_model.find_user_by_id(current_user_id).is_admin
if not is_admin: raise Exception("Unauthorised.")
else:
user = users_model.find_user(username)
if not user: raise Exception("User not found.")
else:
user.disable()
return "User banned"
If backwards compatability isn't a large concern this could be the way to go.