Skip to content

Commit 26542ab

Browse files
authored
Merge pull request #29 from meschac38700/feature/csrf-header_or_body-support
Add support for csrf token location 'header_or_body'
2 parents 2ca67c2 + b18953e commit 26542ab

27 files changed

+1011
-30
lines changed

README.md

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,56 @@ def csrf_protect_exception_handler(request: Request, exc: CsrfProtectError):
8484

8585
```
8686

87+
### 📌 Flexible Mode (fastapi_csrf_protect.flexible)
88+
89+
Some applications combine **Server-Side Rendering (SSR)** with **API endpoints** in the same project.
90+
For example:
91+
- **SSR pages** rendered with Jinja2 templates that use HTML forms (CSRF token in **form body**)
92+
- **AJAX / API calls** (e.g. DELETE, PUT, PATCH) that pass the CSRF token in the **HTTP header**
93+
94+
The main fastapi-csrf-protect package is **opinionated** and expects the CSRF token in **one location only** (either header or body).
95+
For hybrid apps, this can be inconvenient.
96+
97+
The **flexible sub-package** provides a drop-in replacement for CsrfProtect that **always accepts CSRF tokens from either the header or the form body**, with the following priority:
98+
- **Header**: X-CSRFToken
99+
- **Body**: token_key (form-data)
100+
101+
### When to use flexible
102+
103+
Use fastapi_csrf_protect.flexible if:
104+
- You have both SSR pages and API endpoints in the same project.
105+
- Some requests (like DELETE) cannot send a body but still require CSRF validation.
106+
- You want to avoid maintaining two different CSRF configurations.
107+
108+
If your app only uses **one** method to send CSRF tokens, stick to the **core package** for a stricter policy.
109+
110+
### How to send the CSRF token in your client code
111+
112+
#### HTML Form (SSR)
113+
114+
```html
115+
<form method="post" action="/login">
116+
<input type="hidden" name="token_key" value="{{ csrf_token }}">
117+
<!-- other fields -->
118+
</form>
119+
```
120+
#### AJAX (JavaScript)
121+
122+
```javascript
123+
fetch("/items/123", {
124+
method: "DELETE",
125+
headers: {
126+
"X-CSRFToken": getCookie("csrftoken")
127+
},
128+
credentials: "include"
129+
});
130+
```
131+
132+
> [!IMPORTANT]
133+
> - The flexible sub-package ignores the token_location setting — tokens from either header or body are always accepted.
134+
> - CSRF token validation still requires a matching CSRF cookie as in the base package.
135+
> - Priority is given to header over body when both are present.
136+
87137
## Contributions
88138

89139
### Prerequisites
@@ -210,6 +260,11 @@ pytest
210260
* Attempted to make `mypyc` compilation; Failed due to dependency injection pattern
211261
* Add `py.typed` to project
212262

263+
### Version 1.0.4
264+
265+
* Add submodule `flexible` where `CsrfProtect` does not pre-determine `token_key` & `token_location`
266+
* Test `fastapi_csrf_protect.flexible.CsrfProtect` with runtime variable `token_location`
267+
213268
### Run Examples
214269

215270
To run the provided examples, first you must install extra dependencies [uvicorn](https://github.com/encode/uvicorn) and [jinja2](https://github.com/pallets/jinja/)

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -66,14 +66,14 @@ readme = 'README.md'
6666
name = 'fastapi-csrf-protect'
6767
repository = 'https://github.com/aekasitt/fastapi-csrf-protect'
6868
requires-python = '>=3.9'
69-
version = '1.0.3'
69+
version = '1.0.4'
7070

7171

7272
[tool.mypy]
7373
disallow_incomplete_defs = true
7474
disallow_untyped_calls = true
7575
disallow_untyped_defs = true
76-
exclude = [ 'examples', 'tests' ]
76+
exclude = [ 'examples' ]
7777
strict = true
7878

7979

src/fastapi_csrf_protect/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
# Copyright (C) 2020-2025 All rights reserved.
33
# FILENAME: ~~/src/fastapi_csrf_protect/__init__.py
4-
# VERSION: 1.0.3
4+
# VERSION: 1.0.4
55
# CREATED: 2020-11-25 14:35
66
# AUTHOR: Sitt Guruvanich <[email protected]>
77
# DESCRIPTION: https://www.w3docs.com/snippets/python/what-is-init-py-for.html
@@ -21,4 +21,4 @@
2121
__all__: Tuple[str, ...] = ("CsrfProtect",)
2222
__name__ = "fastapi-csrf-protect"
2323
__package__ = "fastapi-csrf-protect"
24-
__version__ = "1.0.3"
24+
__version__ = "1.0.4"

src/fastapi_csrf_protect/core.py

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
# Copyright (C) 2020-2025 All rights reserved.
33
# FILENAME: ~~/src/fastapi_csrf_protect/core.py
4-
# VERSION: 1.0.3
4+
# VERSION: 1.0.4
55
# CREATED: 2020-11-25 14:35
66
# AUTHOR: Sitt Guruvanich <[email protected]>
77
# DESCRIPTION:
@@ -172,9 +172,9 @@ async def validate_csrf(
172172
if self._token_location == "header":
173173
token = self.get_csrf_from_headers(request.headers)
174174
else:
175-
if hasattr(request, "_json"):
175+
if hasattr(request, "_json") and isinstance(request._json, dict):
176176
token = request._json.get(self._token_key, "")
177-
elif hasattr(request, "_form") and request._form is not None:
177+
elif hasattr(request, "_form") and isinstance(request._form, dict):
178178
form_data: Union[None, UploadFile, str] = request._form.get(self._token_key)
179179
if not form_data or isinstance(form_data, UploadFile):
180180
raise MissingTokenError("Form data must be of type string")

src/fastapi_csrf_protect/csrf_config.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
# Copyright (C) 2020-2025 All rights reserved.
33
# FILENAME: ~~/src/fastapi_csrf_protect/csrf_config.py
4-
# VERSION: 1.0.3
4+
# VERSION: 1.0.4
55
# CREATED: 2020-11-25 14:35
66
# AUTHOR: Sitt Guruvanich <[email protected]>
77
# DESCRIPTION:

src/fastapi_csrf_protect/exceptions.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#!/usr/bin/env python3
22
# Copyright (C) 2020-2025 All rights reserved.
33
# FILENAME: ~~/src/fastapi_csrf_protect/exceptions.py
4-
# VERSION: 1.0.3
4+
# VERSION: 1.0.4
55
# CREATED: 2020-11-25 14:35
66
# AUTHOR: Sitt Guruvanich <[email protected]>
77
# DESCRIPTION:
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2020-2025 All rights reserved.
3+
# FILENAME: ~~/src/fastapi_csrf_protect/flexible/__init__.py
4+
# VERSION: 1.0.4
5+
# CREATED: 2025-08-11 16:02:06+02:00
6+
# AUTHOR: Eliam Lotonga <[email protected]>
7+
# DESCRIPTION: https://www.w3docs.com/snippets/python/what-is-init-py-for.html
8+
#
9+
# HISTORY:
10+
# *************************************************************
11+
12+
### Standard packages ###
13+
from typing import Tuple
14+
15+
### Local modules ###
16+
from fastapi_csrf_protect.flexible.core import CsrfProtect
17+
18+
__all__: Tuple[str, ...] = ("CsrfProtect",)
Lines changed: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
1+
#!/usr/bin/env python3
2+
# Copyright (C) 2020-2025 All rights reserved.
3+
# FILENAME: ~~/src/fastapi_csrf_protect/flexible/core.py
4+
# VERSION: 1.0.4
5+
# CREATED: 2025-08-11 16:02:06+02:00
6+
# AUTHOR: Eliam Lotonga <[email protected]>
7+
# DESCRIPTION: https://www.w3docs.com/snippets/python/what-is-init-py-for.html
8+
#
9+
# HISTORY:
10+
# *************************************************************
11+
12+
### Standard packages ###
13+
from hashlib import sha1
14+
from os import urandom
15+
from re import match
16+
from typing import Dict, Tuple, Optional, Union
17+
18+
### Third-party packages ###
19+
from itsdangerous import BadData, SignatureExpired, URLSafeTimedSerializer
20+
from pydantic import create_model
21+
from starlette.datastructures import Headers
22+
from starlette.requests import Request
23+
from starlette.responses import Response
24+
25+
### Local modules ###
26+
from fastapi_csrf_protect.exceptions import (
27+
MissingTokenError,
28+
TokenValidationError,
29+
)
30+
from fastapi_csrf_protect.flexible.csrf_config import CsrfConfig
31+
32+
33+
class CsrfProtect(CsrfConfig):
34+
"""Flexible CSRF validation: accepts token from either header or form body.
35+
36+
Priority:
37+
1. Header
38+
2. Body
39+
"""
40+
41+
def generate_csrf_tokens(self, secret_key: Optional[str] = None) -> Tuple[str, str]:
42+
"""
43+
Generate a CSRF token and a signed CSRF token using server's secret key to be stored in cookie.
44+
45+
---
46+
:param secret_key: (Optional) the secret key used when generating tokens for users
47+
:type secret_key: (str | None) Defaults to None.
48+
"""
49+
secret_key = secret_key or self._secret_key
50+
if secret_key is None:
51+
raise RuntimeError("A secret key is required to use CsrfProtect extension.")
52+
serializer = URLSafeTimedSerializer(secret_key, salt="fastapi-csrf-token")
53+
token = sha1(urandom(64)).hexdigest()
54+
signed = serializer.dumps(token)
55+
return token, signed
56+
57+
def get_csrf_from_body(self, data: bytes) -> str:
58+
"""
59+
Get token from the request body
60+
61+
---
62+
:param data: attached request body containing cookie data with configured `token_key`
63+
:type data: bytes
64+
"""
65+
fields: Dict[str, Tuple[type, str]] = {self._token_key: (str, "csrf-token")}
66+
Body = create_model("Body", **fields)
67+
content: str = '{"' + data.decode("utf-8").replace("&", '","').replace("=", '":"') + '"}'
68+
body = Body.model_validate_json(content)
69+
token: str = body.model_dump()[self._token_key]
70+
return token
71+
72+
def get_csrf_from_headers(self, headers: Headers) -> Union[None, str]:
73+
"""
74+
Get token from the request headers
75+
76+
---
77+
:param headers: Headers containing header with configured `header_name`
78+
:type headers: starlette.datastructures.Headers
79+
"""
80+
header_name, header_type = self._header_name, self._header_type
81+
header_parts = None
82+
try:
83+
header_parts = headers[header_name].split()
84+
except KeyError:
85+
return None
86+
token: Union[None, str] = None
87+
if not header_type:
88+
# <HeaderName>: <Token>
89+
if len(header_parts) != 1:
90+
return token
91+
token = header_parts[0]
92+
else:
93+
# <HeaderName>: <HeaderType> <Token>
94+
if not match(r"{}\s".format(header_type), headers[header_name]) or len(header_parts) != 2:
95+
return token
96+
token = header_parts[1]
97+
return token
98+
99+
def set_csrf_cookie(self, csrf_signed_token: str, response: Response) -> None:
100+
"""
101+
Sets Csrf Protection token to the response cookies
102+
103+
---
104+
:param csrf_signed_token: signed CSRF token from `generate_csrf_token` method
105+
:type csrf_signed_token: str
106+
:param response: The FastAPI response object to sets the access cookies in.
107+
:type response: fastapi.responses.Response
108+
"""
109+
if not isinstance(response, Response):
110+
raise TypeError("The response must be an object response FastAPI")
111+
response.set_cookie(
112+
self._cookie_key,
113+
csrf_signed_token,
114+
max_age=self._max_age,
115+
path=self._cookie_path,
116+
domain=self._cookie_domain,
117+
secure=self._cookie_secure,
118+
httponly=self._httponly,
119+
samesite=self._cookie_samesite,
120+
)
121+
122+
def unset_csrf_cookie(self, response: Response) -> None:
123+
"""
124+
Remove Csrf Protection token from the response cookies
125+
126+
---
127+
:param response: The FastAPI response object to delete the access cookies in.
128+
:type response: fastapi.responses.Response
129+
"""
130+
if not isinstance(response, Response):
131+
raise TypeError("The response must be an object response FastAPI")
132+
response.delete_cookie(
133+
self._cookie_key,
134+
path=self._cookie_path,
135+
domain=self._cookie_domain,
136+
secure=self._cookie_secure,
137+
httponly=self._httponly,
138+
samesite=self._cookie_samesite,
139+
)
140+
141+
async def validate_csrf(
142+
self,
143+
request: Request,
144+
cookie_key: Optional[str] = None,
145+
secret_key: Optional[str] = None,
146+
time_limit: Optional[int] = None,
147+
) -> None:
148+
"""
149+
Check if the given data is a valid CSRF token. This compares the given
150+
signed token to the one stored in the session.
151+
152+
---
153+
:param request: incoming Request instance
154+
:type request: fastapi.requests.Request
155+
:param cookie_key: (Optional) field name for the CSRF token field stored in cookies
156+
Default is set in CsrfConfig when `load_config` was called;
157+
:type cookie_key: str
158+
:param secret_key: (Optional) secret key used to decrypt the token
159+
Default is set in CsrfConfig when `load_config` was called;
160+
:type secret_key: str
161+
:param time_limit: (Optional) Number of seconds that the token is valid.
162+
Default is set in CsrfConfig when `load_config` was called;
163+
:type time_limit: int
164+
:raises TokenValidationError: Contains the reason that validation failed.
165+
"""
166+
secret_key = secret_key or self._secret_key
167+
if secret_key is None:
168+
raise RuntimeError("A secret key is required to use CsrfProtect extension.")
169+
cookie_key = cookie_key or self._cookie_key
170+
signed_token = request.cookies.get(cookie_key)
171+
if signed_token is None:
172+
raise MissingTokenError(f"Missing Cookie: `{cookie_key}`.")
173+
time_limit = time_limit or self._max_age
174+
token: None | str = self.get_csrf_from_headers(request.headers)
175+
if not token:
176+
token = self.get_csrf_from_body(await request.body())
177+
serializer = URLSafeTimedSerializer(secret_key, salt="fastapi-csrf-token")
178+
try:
179+
signature: str = serializer.loads(signed_token, max_age=time_limit)
180+
if token != signature:
181+
raise TokenValidationError("The CSRF signatures submitted do not match.")
182+
except SignatureExpired:
183+
raise TokenValidationError("The CSRF token has expired.")
184+
except BadData:
185+
raise TokenValidationError("The CSRF token is invalid.")
186+
187+
188+
__all__: Tuple[str, ...] = ("CsrfProtect",)

0 commit comments

Comments
 (0)