Cross-Site Request Forgery is one of the easiest attacks to pull off and as a result is a staple of Phishing attacks, Social Engineering attacks, and script-kiddies the world over. But what is it?
A CSRF attack involves getting the user's browser to submit a request to a vulnerable site (or web app) that the user doesn't actually want (or know about). In this worst case, this can be actions like transferring initiating transactions from your bank (or more-likely crypto exchange). On the other hand, it can be as simple as posting or liking something on your social media. Common ways that hackers get these requests to run is through social engineering attacks, such as phishing e-mails, or misleading adverts on websites & social media.
Now, you might be thinking "Wait, doesn't CORS prevent stuff like that?" and to an extent, you'd be right. However, that only prevents malicious scripts from directly making requests, it doesn't stop the submission of good old HTML Forms (even if they're triggered by a script). With the increased popularity of Single-Page Web Apps utilizing APIs rather than static elements like forms, the risks of CSRF attacks are getting lower but they're far from a thing of the past yet.
Make sure to check out our "What is Cross-Site Request Forgery?" article or OWASP's Cheat Sheet for a deeper dive on the How's and What's of CSRF attacks.
With that brief refresher on Cross-Site Request Forgery out of the way, let's dive into how to set-up FastAPI to be safe from CSRF attacks.
FastAPI CSRF Protect
While there are other ways to get CSRF protection in FastAPI (such as using Piccolo-API's middleware), one of the safest and easiest ways to get CSRF protections in place is through using the FastAPI CSRF Protect library which offers a degree of flexibility that others don't.
Inspired by `flask-wtf`
and `fast-api-jwt-auth`
, the library uses an expiring signed blob as a token that can be transmitted via either a Cookie (with the `Same-Site`
, `Secure`
, and `HTTP-Only`
flags set), a simple Header, or both. This flexibility allows easy integration with both modern single-page web apps, as well as templated static content.
Have a Cookie
Using the following code as an example:
```python
from fastapi import FastAPI
from models import Document, Session, select
from validation_models import NewDocument
app = FastAPI()
@app.get("/document/{item_id}")
async def read_document(item_id: int):
async with Session() as session:
result = await session.execute(select(Document).filter(Document.id == item_id))
document: Document = result.one()[0]
return document.as_dict()
@app.post("/document/", response_class=JSONResponse)
async def write_document(new_document: NewDocument):
async with Session() as session:
async with session.begin():
document = Document(name=new_document.name, bodytext=new_document.body, tags=",".join(new_document.tags))
session.add(document)
return JSONResponse(status_code=200, content=document.as_dict())
```
You want to protect the `/document/`
POST handler. To do this, the first thing you need to do is replace the `FastAPI`
import statement with the following:
```python
from fastapi import FastAPI, Request, Depends
from fastapi.responses import JSONResponse
from fastapi_csrf_protect import CsrfProtect
from fastapi_csrf_protect.exceptions import CsrfProtectError
```
Next, you'll need to add the settings class. For pedagogical reasons, we'll hard-code the secret key in our code, but this is far from best practice. In production, this should be supplied securely so as any source-code leaks don't lead to wider breaches (solutions like AWS Secrets Manager, or HashiCorp Vault are great for supplying secrets during run time).
```python
class CsrfSettings(BaseModel):
secret_key:str = 'Kaakaww!'
@CsrfProtect.load_config
def get_csrf_config():
return CsrfSettings()
```
If you're building a templated site then you will need to roll this next step into the form page. But given that you're using FastAPI, it's probably fair to say that you're likely either working on an API Service or a Single-Page App, so you'll want to create a `/csrftoken/`
endpoint.
```python
@app.get("/csrftoken/")
async def get_csrf_token(csrf_protect:CsrfProtect = Depends()):
response = JSONResponse(status_code=200, content={'csrf_token':'cookie'})
csrf_protect.set_csrf_cookie(response)
return response
```
Next, you'll want to modify `write_document`
to be protected by this cookie. To do this you'll need to add the `request`
and `csrf_protect`
arguments to the signature, so that it looks like:
```python
@app.post("/document/", response_class=JSONResponse)
async def write_document(new_document: NewDocument, request: Request, csrf_protect:CsrfProtect = Depends()):
```
Then you'll need to insert the validation line to the top of the function body:
```python
csrf_protect.validate_csrf_in_cookies(request)
```
Finally, you'll need to handle any validation errors that show up, to do with you'll want to add an `exception_handler`
. To do this add the following function:
```python
@app.exception_handler(CsrfProtectError)
def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError):
return JSONResponse(status_code=exc.status_code, content={ 'detail': exc.message })
```
When you're all done, it should look something like this:
```python
from fastapi import FastAPI, Request, Depends
from fastapi.responses import JSONResponse
from fastapi_csrf_protect import CsrfProtect
from fastapi_csrf_protect.exceptions import CsrfProtectError
from models import Document, Session, select
from validation_models import NewDocument
app = FastAPI()
class CsrfSettings(BaseModel):
secret_key:str = 'Kaakaww!'
@CsrfProtect.load_config
def get_csrf_config():
return CsrfSettings()
@app.get("/csrftoken/")
async def get_csrf_token(csrf_protect:CsrfProtect = Depends()):
response = JSONResponse(status_code=200, content={'csrf_token':'cookie'})
csrf_protect.set_csrf_cookie(response)
return response
@app.get("/document/{item_id}")
async def read_document(item_id: int):
async with Session() as session:
result = await session.execute(select(Document).filter(Document.id == item_id))
document: Document = result.one()[0]
return document.as_dict()
@app.post("/document/", response_class=JSONResponse)
async def write_document(new_document: NewDocument, request: Request, csrf_protect:CsrfProtect = Depends()):
csrf_protect.validate_csrf_in_cookies(request)
async with Session() as session:
async with session.begin():
document = Document(name=new_document.name, bodytext=new_document.body, tags=",".join(new_document.tags))
session.add(document)
return JSONResponse(status_code=200, content=document.as_dict())
@app.exception_handler(CsrfProtectError)
def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError):
return JSONResponse(status_code=exc.status_code, content={ 'detail': exc.message })
```
Final Thoughts
The FastAPI CSRF Protect library does a lot of things right, from the time-scoped signed tokens to the secure-by-default Cookie settings, but the reliance on dependency injection means that developers could forget to secure an endpoint, or worse, think that an endpoint is secure because the injection is present, but forgetting to ensure that the validation call is also present.
Hopefully, this will be resolved in a future version of this library, but in the meanwhile more diligence is required during peer reviews of merge requests that introduce new endpoints to the codebase.
This post was written by Tim Armstrong. Tim has worn many hats over the years, from "Dark Lord of Network Operations" at Nerdalize to "Lead Software Engineer" at De Beers. These days, he's going by "Consultant Engineer & Technical Writer." You can find him on Twitter as @omatachyru, and at plaintextnerds.com.