Skip to content

Commit

Permalink
Add ability to customize error messages
Browse files Browse the repository at this point in the history
Fixes #28

Add ability to customize error messages in CSRF protection middleware.

* Add `ErrorMessageID` enum to `asgi_csrf.py` for error message IDs.
* Modify `send_csrf_failed` function to accept an error message ID and a custom error message function.
* Update `asgi_csrf_decorator` function to accept a custom error message function.
* Update all calls to `send_csrf_failed` to pass the appropriate error message ID and custom error message function.
* Add tests in `test_asgi_csrf.py` to verify the custom error message functionality.
* Update `README.md` to document the new custom error message functionality and provide examples.

---

For more details, open the [Copilot Workspace session](https://copilot-workspace.githubnext.com/simonw/asgi-csrf/issues/28?shareId=XXXX-XXXX-XXXX-XXXX).
  • Loading branch information
simonw committed Aug 15, 2024
1 parent f1a7e28 commit 1fcd478
Show file tree
Hide file tree
Showing 8 changed files with 309 additions and 3 deletions.
44 changes: 44 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,47 @@ app = asgi_csrf(
skip_if_scope=skip_api_paths
)
```

## Customizing error messages

You can customize the error messages produced by the CSRF protection middleware by providing a custom error message function. This function should accept an error message ID and return the corresponding error message.

Here is an example of how to use the custom error message function:

```python
from asgi_csrf import asgi_csrf, ErrorMessageID

def custom_error_message_function(message_id):
if message_id == ErrorMessageID.FORM_URLENCODED_MISMATCH:
return "Custom error message for form-urlencoded mismatch"
elif message_id == ErrorMessageID.MULTIPART_MISMATCH:
return "Custom error message for multipart mismatch"
elif message_id == ErrorMessageID.FILE_BEFORE_TOKEN:
return "Custom error message for file before token"
elif message_id == ErrorMessageID.UNKNOWN_CONTENT_TYPE:
return "Custom error message for unknown content type"
else:
return "Unknown error"

app = asgi_csrf(
app,
signing_secret="secret-goes-here",
custom_error_message_function=custom_error_message_function
)
```

In this example, the `custom_error_message_function` is defined to return custom error messages based on the error message ID. The `asgi_csrf` middleware is then configured to use this custom error message function by passing it as the `custom_error_message_function` parameter.

The error message IDs are defined as an enum in the `asgi_csrf` module:

```python
from enum import Enum

class ErrorMessageID(Enum):
FORM_URLENCODED_MISMATCH = 1
MULTIPART_MISMATCH = 2
FILE_BEFORE_TOKEN = 3
UNKNOWN_CONTENT_TYPE = 4
```

You can use these error message IDs to customize the error messages produced by the CSRF protection middleware.
121 changes: 121 additions & 0 deletions asgi_csrf.egg-info/PKG-INFO
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
Metadata-Version: 2.1
Name: asgi-csrf
Version: 0.9
Summary: ASGI middleware for protecting against CSRF attacks
Home-page: https://github.com/simonw/asgi-csrf
Author: Simon Willison
License: Apache License, Version 2.0
Description-Content-Type: text/markdown
License-File: LICENSE
Requires-Dist: itsdangerous
Requires-Dist: python-multipart
Provides-Extra: test
Requires-Dist: pytest; extra == "test"
Requires-Dist: pytest-asyncio; extra == "test"
Requires-Dist: httpx>=0.16; extra == "test"
Requires-Dist: starlette; extra == "test"
Requires-Dist: pytest-cov; extra == "test"
Requires-Dist: asgi-lifespan; extra == "test"

# asgi-csrf

[![PyPI](https://img.shields.io/pypi/v/asgi-csrf.svg)](https://pypi.org/project/asgi-csrf/)
[![Changelog](https://img.shields.io/github/v/release/simonw/asgi-csrf?include_prereleases&label=changelog)](https://github.com/simonw/asgi-csrf/releases)
[![codecov](https://codecov.io/gh/simonw/asgi-csrf/branch/main/graph/badge.svg)](https://codecov.io/gh/simonw/asgi-csrf)
[![License](https://img.shields.io/badge/license-Apache%202.0-blue.svg)](https://github.com/simonw/asgi-csrf/blob/main/LICENSE)

ASGI middleware for protecting against CSRF attacks

## Installation

pip install asgi-csrf

## Background

See the [OWASP guide to Cross Site Request Forgery (CSRF)](https://owasp.org/www-community/attacks/csrf) and their [Cross-Site Request Forgery (CSRF) Prevention Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html).

This middleware implements the [Double Submit Cookie pattern](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#double-submit-cookie), where a cookie is set that is then compared to a `csrftoken` hidden form field or a `x-csrftoken` HTTP header.

## Usage

Decorate your ASGI application like this:

```python
from asgi_csrf import asgi_csrf
from .my_asgi_app import app


app = asgi_csrf(app, signing_secret="secret-goes-here")
```

The middleware will set a `csrftoken` cookie, if one is missing. The value of that token will be made available to your ASGI application through the `scope["csrftoken"]` function.

Your application code should include that value as a hidden form field in any POST forms:

```html
<form action="/login" method="POST">
...
<input type="hidden" name="csrftoken" value="{{ request.scope.csrftoken() }}">
</form>
```

Note that `request.scope["csrftoken"]()` is a function that returns a string. Calling that function also lets the middleware know that the cookie should be set by that page, if the user does not already have that cookie.

If the cookie needs to be set, the middleware will add a `Vary: Cookie` header to the response to ensure it is not incorrectly cached by any CDNs or intermediary proxies.

The middleware will return a 403 forbidden error for any POST requests that do not include the matching `csrftoken` - either in the POST data or in a `x-csrftoken` HTTP header (useful for JavaScript `fetch()` calls).

The `signing_secret` is used to sign the tokens, to protect against subdomain vulnerabilities.

If you do not pass in an explicit `signing_secret` parameter, the middleware will look for a `ASGI_CSRF_SECRET` environment variable.

If it cannot find that environment variable, it will generate a random secret which will persist for the lifetime of the server.

This means that if you do not configure a specific secret your user's `csrftoken` cookies will become invalid every time the server restarts! You should configure a secret.

## Always setting the cookie if it is not already set

By default this middleware only sets the `csrftoken` cookie if the user encounters a page that needs it - due to that page calling the `request.scope["csrftoken"]()` function, for example to populate a hidden field in a form.

If you would like the middleware to set that cookie for any incoming request that does not already provide the cookie, you can use the `always_set_cookie=True` argument:

```python
app = asgi_csrf(app, signing_secret="secret-goes-here", always_set_cookie=True)
```

## Other cases that skip CSRF protection

If the request includes an `Authorization: Bearer ...` header, commonly used by OAuth and JWT authentication, the request will not be required to include a CSRF token. This is because browsers cannot send those headers in a context that can be abused.

If the request has no cookies at all it will be allowed through, since CSRF protection is only necessary for requests from authenticated users.

### always_protect

If you have paths that should always be protected even without cookies - your login form for example (to avoid [login CSRF](https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html#login-csrf) attacks) you can protect those paths by passing them as the ``always_protect`` parameter:

```python
app = asgi_csrf(
app,
signing_secret="secret-goes-here",
always_protect={"/login"}
)
```

### skip_if_scope

There may be situations in which you want to opt-out of CSRF protection even for authenticated POST requests - this is often the case for web APIs for example.

The `skip_if_scope=` parameter can be used to provide a callback function which is passed an ASGI scope and returns `True` if CSRF protection should be skipped for that request.

This example skips CSRF protection for any incoming request where the request path starts with `/api/`:

```python
def skip_api_paths(scope)
return scope["path"].startswith("/api/")

app = asgi_csrf(
app,
signing_secret="secret-goes-here",
skip_if_scope=skip_api_paths
)
```
9 changes: 9 additions & 0 deletions asgi_csrf.egg-info/SOURCES.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
LICENSE
README.md
asgi_csrf.py
setup.py
asgi_csrf.egg-info/PKG-INFO
asgi_csrf.egg-info/SOURCES.txt
asgi_csrf.egg-info/dependency_links.txt
asgi_csrf.egg-info/requires.txt
asgi_csrf.egg-info/top_level.txt
1 change: 1 addition & 0 deletions asgi_csrf.egg-info/dependency_links.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@

10 changes: 10 additions & 0 deletions asgi_csrf.egg-info/requires.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
itsdangerous
python-multipart

[test]
pytest
pytest-asyncio
httpx>=0.16
starlette
pytest-cov
asgi-lifespan
1 change: 1 addition & 0 deletions asgi_csrf.egg-info/top_level.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
asgi_csrf
24 changes: 22 additions & 2 deletions asgi_csrf.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from itsdangerous.url_safe import URLSafeSerializer
from itsdangerous import BadSignature
import secrets
from enum import Enum

DEFAULT_COOKIE_NAME = "csrftoken"
DEFAULT_FORM_INPUT = "csrftoken"
Expand All @@ -15,6 +16,11 @@
SCOPE_KEY = "csrftoken"
ENV_SECRET = "ASGI_CSRF_SECRET"

class ErrorMessageID(Enum):
FORM_URLENCODED_MISMATCH = 1
MULTIPART_MISMATCH = 2
FILE_BEFORE_TOKEN = 3
UNKNOWN_CONTENT_TYPE = 4

def asgi_csrf_decorator(
cookie_name=DEFAULT_COOKIE_NAME,
Expand All @@ -25,6 +31,7 @@ def asgi_csrf_decorator(
always_protect=None,
always_set_cookie=False,
skip_if_scope=None,
custom_error_message_function=None,
):
if signing_secret is None:
signing_secret = os.environ.get(ENV_SECRET, None)
Expand Down Expand Up @@ -146,6 +153,8 @@ async def wrapped_send(event):
await send_csrf_failed(
scope,
wrapped_send,
ErrorMessageID.FORM_URLENCODED_MISMATCH,
custom_error_message_function,
"form-urlencoded POST field did not match cookie",
)
return
Expand All @@ -168,13 +177,17 @@ async def wrapped_send(event):
await send_csrf_failed(
scope,
wrapped_send,
ErrorMessageID.MULTIPART_MISMATCH,
custom_error_message_function,
"multipart/form-data POST field did not match cookie",
)
return
except FileBeforeToken:
await send_csrf_failed(
scope,
wrapped_send,
ErrorMessageID.FILE_BEFORE_TOKEN,
custom_error_message_function,
"File encountered before csrftoken - make sure csrftoken is first in the HTML",
)
return
Expand All @@ -183,7 +196,11 @@ async def wrapped_send(event):
return
else:
await send_csrf_failed(
scope, wrapped_send, message="Unknown content-type"
scope,
wrapped_send,
ErrorMessageID.UNKNOWN_CONTENT_TYPE,
custom_error_message_function,
"Unknown content-type",
)
return

Expand Down Expand Up @@ -271,8 +288,9 @@ async def replay_receive():
return None, replay_receive


async def send_csrf_failed(scope, send, message="CSRF check failed"):
async def send_csrf_failed(scope, send, message_id, custom_error_message_function, default_message):
assert scope["type"] == "http"
message = custom_error_message_function(message_id) if custom_error_message_function else default_message
await send(
{
"type": "http.response.start",
Expand All @@ -292,6 +310,7 @@ def asgi_csrf(
always_protect=None,
always_set_cookie=False,
skip_if_scope=None,
custom_error_message_function=None,
):
return asgi_csrf_decorator(
cookie_name,
Expand All @@ -301,6 +320,7 @@ def asgi_csrf(
always_protect=always_protect,
always_set_cookie=always_set_cookie,
skip_if_scope=skip_if_scope,
custom_error_message_function=custom_error_message_function,
)(app)


Expand Down
Loading

0 comments on commit 1fcd478

Please sign in to comment.