modern-csrf 2026.10.20__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- modern_csrf-2026.10.20/LICENSE +21 -0
- modern_csrf-2026.10.20/PKG-INFO +103 -0
- modern_csrf-2026.10.20/README.md +91 -0
- modern_csrf-2026.10.20/pyproject.toml +34 -0
- modern_csrf-2026.10.20/setup.cfg +4 -0
- modern_csrf-2026.10.20/src/modern_csrf/__init__.py +0 -0
- modern_csrf-2026.10.20/src/modern_csrf/asgi.py +96 -0
- modern_csrf-2026.10.20/src/modern_csrf/base.py +49 -0
- modern_csrf-2026.10.20/src/modern_csrf/py.typed +0 -0
- modern_csrf-2026.10.20/src/modern_csrf/wsgi.py +56 -0
- modern_csrf-2026.10.20/src/modern_csrf.egg-info/PKG-INFO +103 -0
- modern_csrf-2026.10.20/src/modern_csrf.egg-info/SOURCES.txt +13 -0
- modern_csrf-2026.10.20/src/modern_csrf.egg-info/dependency_links.txt +1 -0
- modern_csrf-2026.10.20/src/modern_csrf.egg-info/requires.txt +3 -0
- modern_csrf-2026.10.20/src/modern_csrf.egg-info/top_level.txt +1 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 George Macon
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modern-csrf
|
|
3
|
+
Version: 2026.10.20
|
|
4
|
+
Summary: Modern anti-CSRF middleware
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: asgi
|
|
10
|
+
Requires-Dist: asgiref>=3.10.0; extra == "asgi"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Block Cross-Origin Requests
|
|
14
|
+
|
|
15
|
+
This project implements the modern [Cross-Site Request Forgery (CSRF)][csrf] defenses
|
|
16
|
+
as [described][valsorda] by Filippo Valsorda.
|
|
17
|
+
This is the [Fetch metadata approach][mdn-csrf-fetch] described on MDN.
|
|
18
|
+
Unsafe cross-origin requests are rejected with a 403 Forbidden status by default.
|
|
19
|
+
|
|
20
|
+
[csrf]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
|
|
21
|
+
[valsorda]: https://words.filippo.io/csrf/
|
|
22
|
+
[mdn-csrf-fetch]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#fetch_metadata
|
|
23
|
+
|
|
24
|
+
This approach protects users of browsers less than a few years old
|
|
25
|
+
(and anyone using an older browser than that likely has worse problems to worry about).
|
|
26
|
+
This approach is useful
|
|
27
|
+
because it can be cleanly implemented as a middleware
|
|
28
|
+
without requiring close coordination with the application.
|
|
29
|
+
That doesn't mean that you can't use CSRF tokens too.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
The specific checks performed are:
|
|
34
|
+
|
|
35
|
+
1. Allow requests with one of the safe HTTP methods: `GET`, `HEAD`, or `OPTIONS`.
|
|
36
|
+
1. Allow requests from trusted origins.
|
|
37
|
+
1. If the `Sec-Fetch-Site` header is set,
|
|
38
|
+
allow requests with `Sec-Fetch-Site` set to `same-origin` or `none`, and
|
|
39
|
+
reject other requests.
|
|
40
|
+
1. Allow requests with neither of the `Sec-Fetch-Site` or `Origin` headers set.
|
|
41
|
+
1. If the `Origin` and `Host` headers are both set,
|
|
42
|
+
allow requests where the host-and-port portion of the origin matches the host.
|
|
43
|
+
1. Reject any remaining requests.
|
|
44
|
+
|
|
45
|
+
## Things to watch out for
|
|
46
|
+
|
|
47
|
+
In order for your application to be protected by this middleware,
|
|
48
|
+
you *must not* change the application state
|
|
49
|
+
in response to a request with one of the ["safe" methods][safe]
|
|
50
|
+
(`GET`, `HEAD`, `OPTIONS`).
|
|
51
|
+
Note that this is not an additional requirement because
|
|
52
|
+
there are other places within the HTTP stack
|
|
53
|
+
where this assumption already exists.
|
|
54
|
+
For example,
|
|
55
|
+
requests with these methods may be automatically retried
|
|
56
|
+
without user intervention.
|
|
57
|
+
|
|
58
|
+
[safe]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
|
|
59
|
+
|
|
60
|
+
For this to work well,
|
|
61
|
+
your application should be served from a [trustworthy origin][trustworthy],
|
|
62
|
+
i.e. over HTTPS or via localhost.
|
|
63
|
+
There may be false rejections if you do not use a trustworthy origin.
|
|
64
|
+
|
|
65
|
+
[trustworthy]: https://www.w3.org/TR/secure-contexts/#is-origin-trustworthy
|
|
66
|
+
|
|
67
|
+
## Protecting your application
|
|
68
|
+
|
|
69
|
+
Two versions of the middleware are provided:
|
|
70
|
+
a [Web Server Gateway Interface (WSGI)][wsgi] middleware
|
|
71
|
+
`modern_csrf.wsgi.BlockCrossOriginRequests`,
|
|
72
|
+
and an [Asynchronous Server Gateway Interface (ASGI)][asgi] middleware,
|
|
73
|
+
`modern_csrf.asgi.BlockCrossOriginRequests`.
|
|
74
|
+
Both versions have the same basic shape:
|
|
75
|
+
the middleware is constructed with the next application as the first argument,
|
|
76
|
+
and can be configured by setting keyword arguments.
|
|
77
|
+
The available configuration parameters are:
|
|
78
|
+
|
|
79
|
+
* `trusted_origins`:
|
|
80
|
+
A collection of origins from which requests are always allowed.
|
|
81
|
+
An "origin" is specified as `scheme://host[:port]` string
|
|
82
|
+
and must match *exactly*, including case.
|
|
83
|
+
|
|
84
|
+
Default: `()` (i.e. no trusted origins)
|
|
85
|
+
|
|
86
|
+
Example: `["https://example.com", "https://example.net:1234"]`
|
|
87
|
+
|
|
88
|
+
* `reject_handler`:
|
|
89
|
+
An application used to reject requests that should be rejected.
|
|
90
|
+
|
|
91
|
+
The default value returns a 403 Forbidden status with a fixed message.
|
|
92
|
+
|
|
93
|
+
* `log_rejected_requests`:
|
|
94
|
+
Should detailed information about rejected requests be logged?
|
|
95
|
+
For WSGI, the message is written to the WSGI error stream.
|
|
96
|
+
For ASGI, the message is logged via the standard library logging infrastructure.
|
|
97
|
+
|
|
98
|
+
Default: `False`
|
|
99
|
+
|
|
100
|
+
Example: `True`
|
|
101
|
+
|
|
102
|
+
[wsgi]: https://peps.python.org/pep-3333/
|
|
103
|
+
[asgi]: https://asgi.readthedocs.io/en/latest/
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
# Block Cross-Origin Requests
|
|
2
|
+
|
|
3
|
+
This project implements the modern [Cross-Site Request Forgery (CSRF)][csrf] defenses
|
|
4
|
+
as [described][valsorda] by Filippo Valsorda.
|
|
5
|
+
This is the [Fetch metadata approach][mdn-csrf-fetch] described on MDN.
|
|
6
|
+
Unsafe cross-origin requests are rejected with a 403 Forbidden status by default.
|
|
7
|
+
|
|
8
|
+
[csrf]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
|
|
9
|
+
[valsorda]: https://words.filippo.io/csrf/
|
|
10
|
+
[mdn-csrf-fetch]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#fetch_metadata
|
|
11
|
+
|
|
12
|
+
This approach protects users of browsers less than a few years old
|
|
13
|
+
(and anyone using an older browser than that likely has worse problems to worry about).
|
|
14
|
+
This approach is useful
|
|
15
|
+
because it can be cleanly implemented as a middleware
|
|
16
|
+
without requiring close coordination with the application.
|
|
17
|
+
That doesn't mean that you can't use CSRF tokens too.
|
|
18
|
+
|
|
19
|
+
## How it works
|
|
20
|
+
|
|
21
|
+
The specific checks performed are:
|
|
22
|
+
|
|
23
|
+
1. Allow requests with one of the safe HTTP methods: `GET`, `HEAD`, or `OPTIONS`.
|
|
24
|
+
1. Allow requests from trusted origins.
|
|
25
|
+
1. If the `Sec-Fetch-Site` header is set,
|
|
26
|
+
allow requests with `Sec-Fetch-Site` set to `same-origin` or `none`, and
|
|
27
|
+
reject other requests.
|
|
28
|
+
1. Allow requests with neither of the `Sec-Fetch-Site` or `Origin` headers set.
|
|
29
|
+
1. If the `Origin` and `Host` headers are both set,
|
|
30
|
+
allow requests where the host-and-port portion of the origin matches the host.
|
|
31
|
+
1. Reject any remaining requests.
|
|
32
|
+
|
|
33
|
+
## Things to watch out for
|
|
34
|
+
|
|
35
|
+
In order for your application to be protected by this middleware,
|
|
36
|
+
you *must not* change the application state
|
|
37
|
+
in response to a request with one of the ["safe" methods][safe]
|
|
38
|
+
(`GET`, `HEAD`, `OPTIONS`).
|
|
39
|
+
Note that this is not an additional requirement because
|
|
40
|
+
there are other places within the HTTP stack
|
|
41
|
+
where this assumption already exists.
|
|
42
|
+
For example,
|
|
43
|
+
requests with these methods may be automatically retried
|
|
44
|
+
without user intervention.
|
|
45
|
+
|
|
46
|
+
[safe]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
|
|
47
|
+
|
|
48
|
+
For this to work well,
|
|
49
|
+
your application should be served from a [trustworthy origin][trustworthy],
|
|
50
|
+
i.e. over HTTPS or via localhost.
|
|
51
|
+
There may be false rejections if you do not use a trustworthy origin.
|
|
52
|
+
|
|
53
|
+
[trustworthy]: https://www.w3.org/TR/secure-contexts/#is-origin-trustworthy
|
|
54
|
+
|
|
55
|
+
## Protecting your application
|
|
56
|
+
|
|
57
|
+
Two versions of the middleware are provided:
|
|
58
|
+
a [Web Server Gateway Interface (WSGI)][wsgi] middleware
|
|
59
|
+
`modern_csrf.wsgi.BlockCrossOriginRequests`,
|
|
60
|
+
and an [Asynchronous Server Gateway Interface (ASGI)][asgi] middleware,
|
|
61
|
+
`modern_csrf.asgi.BlockCrossOriginRequests`.
|
|
62
|
+
Both versions have the same basic shape:
|
|
63
|
+
the middleware is constructed with the next application as the first argument,
|
|
64
|
+
and can be configured by setting keyword arguments.
|
|
65
|
+
The available configuration parameters are:
|
|
66
|
+
|
|
67
|
+
* `trusted_origins`:
|
|
68
|
+
A collection of origins from which requests are always allowed.
|
|
69
|
+
An "origin" is specified as `scheme://host[:port]` string
|
|
70
|
+
and must match *exactly*, including case.
|
|
71
|
+
|
|
72
|
+
Default: `()` (i.e. no trusted origins)
|
|
73
|
+
|
|
74
|
+
Example: `["https://example.com", "https://example.net:1234"]`
|
|
75
|
+
|
|
76
|
+
* `reject_handler`:
|
|
77
|
+
An application used to reject requests that should be rejected.
|
|
78
|
+
|
|
79
|
+
The default value returns a 403 Forbidden status with a fixed message.
|
|
80
|
+
|
|
81
|
+
* `log_rejected_requests`:
|
|
82
|
+
Should detailed information about rejected requests be logged?
|
|
83
|
+
For WSGI, the message is written to the WSGI error stream.
|
|
84
|
+
For ASGI, the message is logged via the standard library logging infrastructure.
|
|
85
|
+
|
|
86
|
+
Default: `False`
|
|
87
|
+
|
|
88
|
+
Example: `True`
|
|
89
|
+
|
|
90
|
+
[wsgi]: https://peps.python.org/pep-3333/
|
|
91
|
+
[asgi]: https://asgi.readthedocs.io/en/latest/
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "modern-csrf"
|
|
3
|
+
version = "2026.10.20"
|
|
4
|
+
description = "Modern anti-CSRF middleware"
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
license = "MIT"
|
|
7
|
+
license-files = ["LICENSE"]
|
|
8
|
+
requires-python = ">=3.11"
|
|
9
|
+
dependencies = []
|
|
10
|
+
|
|
11
|
+
[project.optional-dependencies]
|
|
12
|
+
asgi = [
|
|
13
|
+
"asgiref>=3.10.0",
|
|
14
|
+
]
|
|
15
|
+
|
|
16
|
+
[build-system]
|
|
17
|
+
requires = ["setuptools >= 77.0.3"]
|
|
18
|
+
build-backend = "setuptools.build_meta"
|
|
19
|
+
|
|
20
|
+
[dependency-groups]
|
|
21
|
+
dev = [
|
|
22
|
+
"mypy>=1.18.2",
|
|
23
|
+
"python-lsp-ruff>=2.3.0",
|
|
24
|
+
"python-lsp-server>=1.13.1",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[tool.ruff.lint]
|
|
28
|
+
select = ["E4", "E7", "E9", "F", "I"]
|
|
29
|
+
|
|
30
|
+
[tool.mypy]
|
|
31
|
+
files = [
|
|
32
|
+
"src/**/*.py",
|
|
33
|
+
]
|
|
34
|
+
disallow_untyped_defs = true
|
|
File without changes
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import logging
|
|
2
|
+
from collections.abc import Awaitable, Container
|
|
3
|
+
|
|
4
|
+
from asgiref.compatibility import guarantee_single_callable
|
|
5
|
+
from asgiref.typing import (
|
|
6
|
+
ASGI3Application,
|
|
7
|
+
ASGIApplication,
|
|
8
|
+
ASGIReceiveCallable,
|
|
9
|
+
ASGISendCallable,
|
|
10
|
+
HTTPScope,
|
|
11
|
+
Scope,
|
|
12
|
+
)
|
|
13
|
+
|
|
14
|
+
from modern_csrf.base import should_accept_request
|
|
15
|
+
|
|
16
|
+
log = logging.getLogger(__name__)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
async def default_reject_handler(
|
|
20
|
+
scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
|
21
|
+
) -> None:
|
|
22
|
+
await send(
|
|
23
|
+
{
|
|
24
|
+
"type": "http.response.start",
|
|
25
|
+
"status": 403,
|
|
26
|
+
"headers": [(b"Content-Type", b"text/plain")],
|
|
27
|
+
"trailers": False,
|
|
28
|
+
}
|
|
29
|
+
)
|
|
30
|
+
await send(
|
|
31
|
+
{
|
|
32
|
+
"type": "http.response.body",
|
|
33
|
+
"body": b"Unexpected cross-origin request forbidden.",
|
|
34
|
+
"more_body": False,
|
|
35
|
+
}
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class BlockCrossOriginRequests:
|
|
40
|
+
app: ASGI3Application
|
|
41
|
+
trusted_origins: Container[str]
|
|
42
|
+
reject_handler: ASGI3Application
|
|
43
|
+
log_rejected_requests: bool
|
|
44
|
+
_asgi_single_callable: bool
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
app: ASGIApplication,
|
|
49
|
+
*,
|
|
50
|
+
trusted_origins: Container[str] = (),
|
|
51
|
+
reject_handler: ASGIApplication = default_reject_handler,
|
|
52
|
+
log_rejected_requests: bool = False,
|
|
53
|
+
) -> None:
|
|
54
|
+
self.app = guarantee_single_callable(app)
|
|
55
|
+
self.trusted_origins = trusted_origins
|
|
56
|
+
self.reject_handler = guarantee_single_callable(reject_handler)
|
|
57
|
+
self.log_rejected_requests = log_rejected_requests
|
|
58
|
+
|
|
59
|
+
# This is a hint to asgiref,
|
|
60
|
+
# which would otherwise be confused by our `__call__` not being async.
|
|
61
|
+
self._asgi_single_callable = True
|
|
62
|
+
|
|
63
|
+
def __call__(
|
|
64
|
+
self, scope: Scope, receive: ASGIReceiveCallable, send: ASGISendCallable
|
|
65
|
+
) -> Awaitable[None]:
|
|
66
|
+
if scope["type"] != "http":
|
|
67
|
+
return self.app(scope, receive, send)
|
|
68
|
+
|
|
69
|
+
http_scope: HTTPScope = scope
|
|
70
|
+
|
|
71
|
+
method = http_scope["method"]
|
|
72
|
+
origin: str | None = None
|
|
73
|
+
sec_fetch_site: str | None = None
|
|
74
|
+
host: str | None = None
|
|
75
|
+
|
|
76
|
+
for name, value in http_scope["headers"]:
|
|
77
|
+
name = name.lower()
|
|
78
|
+
if name == b"origin":
|
|
79
|
+
origin = value.decode("ascii")
|
|
80
|
+
elif name == b"sec-fetch-site":
|
|
81
|
+
sec_fetch_site = value.decode("ascii")
|
|
82
|
+
elif name == b"host":
|
|
83
|
+
host = value.decode("ascii")
|
|
84
|
+
|
|
85
|
+
if should_accept_request(
|
|
86
|
+
method=method,
|
|
87
|
+
origin=origin,
|
|
88
|
+
sec_fetch_site=sec_fetch_site,
|
|
89
|
+
host=host,
|
|
90
|
+
trusted_origins=self.trusted_origins,
|
|
91
|
+
):
|
|
92
|
+
return self.app(scope, receive, send)
|
|
93
|
+
else:
|
|
94
|
+
if self.log_rejected_requests:
|
|
95
|
+
log.error("Rejecting unexpected cross-origin request: %r", scope)
|
|
96
|
+
return self.reject_handler(scope, receive, send)
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
from collections.abc import Container
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def should_accept_request(
|
|
5
|
+
method: str,
|
|
6
|
+
origin: str | None,
|
|
7
|
+
sec_fetch_site: str | None,
|
|
8
|
+
host: str | None,
|
|
9
|
+
trusted_origins: Container[str],
|
|
10
|
+
) -> bool:
|
|
11
|
+
"""Should the HTTP request be accepted?
|
|
12
|
+
|
|
13
|
+
This evaluates a request based on the fetch metadata headers
|
|
14
|
+
and returns ``True`` if it should be accepted and ``False`` otherwise.
|
|
15
|
+
Requests
|
|
16
|
+
|
|
17
|
+
1. Should be accepted if they use a safe HTTP method.
|
|
18
|
+
2. Should be accepted if they are from a trusted origin.
|
|
19
|
+
3. Should be accepted
|
|
20
|
+
if the browser indicates that it is not a cross-origin request,
|
|
21
|
+
and rejected if the browser indicates that it is a cross-origin request.
|
|
22
|
+
4. Should be accepted if the request is not from a web browser.
|
|
23
|
+
5. Should be accepted if the host matches the origin.
|
|
24
|
+
(Since non-trustworthy origins (i.e. http:) don't send the Sec-* headers.)
|
|
25
|
+
|
|
26
|
+
all other requests are rejected.
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
if method in ("GET", "HEAD", "OPTIONS"):
|
|
30
|
+
return True
|
|
31
|
+
|
|
32
|
+
if origin in trusted_origins:
|
|
33
|
+
return True
|
|
34
|
+
|
|
35
|
+
if sec_fetch_site is not None:
|
|
36
|
+
if sec_fetch_site in ("same-origin", "none"):
|
|
37
|
+
return True
|
|
38
|
+
else:
|
|
39
|
+
return False
|
|
40
|
+
|
|
41
|
+
if sec_fetch_site is None and origin is None:
|
|
42
|
+
return True
|
|
43
|
+
|
|
44
|
+
if origin is not None and host is not None:
|
|
45
|
+
scheme, sep, host_with_port = origin.partition("://")
|
|
46
|
+
if host_with_port == host:
|
|
47
|
+
return True
|
|
48
|
+
|
|
49
|
+
return False
|
|
File without changes
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
from collections.abc import Container, Iterable
|
|
2
|
+
from wsgiref.types import ErrorStream, StartResponse, WSGIApplication, WSGIEnvironment
|
|
3
|
+
|
|
4
|
+
from modern_csrf.base import should_accept_request
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def default_reject_handler(
|
|
8
|
+
environ: WSGIEnvironment, start_response: StartResponse
|
|
9
|
+
) -> Iterable[bytes]:
|
|
10
|
+
start_response("403 Forbidden", [("Content-Type", "text/plain")])
|
|
11
|
+
return [b"Unexpected cross-origin request forbidden."]
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class BlockCrossOriginRequests:
|
|
15
|
+
app: WSGIApplication
|
|
16
|
+
trusted_origins: Container[str]
|
|
17
|
+
reject_handler: WSGIApplication
|
|
18
|
+
log_rejected_requests: bool
|
|
19
|
+
|
|
20
|
+
def __init__(
|
|
21
|
+
self,
|
|
22
|
+
app: WSGIApplication,
|
|
23
|
+
*,
|
|
24
|
+
trusted_origins: Container[str] = (),
|
|
25
|
+
reject_handler: WSGIApplication = default_reject_handler,
|
|
26
|
+
log_rejected_requests: bool = False,
|
|
27
|
+
) -> None:
|
|
28
|
+
self.app = app
|
|
29
|
+
self.trusted_origins = trusted_origins
|
|
30
|
+
self.reject_handler = reject_handler
|
|
31
|
+
self.log_rejected_requests = log_rejected_requests
|
|
32
|
+
|
|
33
|
+
def __call__(
|
|
34
|
+
self, environ: WSGIEnvironment, start_response: StartResponse
|
|
35
|
+
) -> Iterable[bytes]:
|
|
36
|
+
method = environ["REQUEST_METHOD"]
|
|
37
|
+
origin = environ.get("HTTP_ORIGIN")
|
|
38
|
+
sec_fetch_site = environ.get("HTTP_SEC_FETCH_SITE")
|
|
39
|
+
host = environ.get("HTTP_HOST")
|
|
40
|
+
|
|
41
|
+
if should_accept_request(
|
|
42
|
+
method=method,
|
|
43
|
+
origin=origin,
|
|
44
|
+
sec_fetch_site=sec_fetch_site,
|
|
45
|
+
host=host,
|
|
46
|
+
trusted_origins=self.trusted_origins,
|
|
47
|
+
):
|
|
48
|
+
return self.app(environ, start_response)
|
|
49
|
+
else:
|
|
50
|
+
if self.log_rejected_requests:
|
|
51
|
+
errors: ErrorStream = environ["wsgi.errors"]
|
|
52
|
+
errors.write(
|
|
53
|
+
f"Rejecting unexpected cross-origin request: {environ!r}\n"
|
|
54
|
+
)
|
|
55
|
+
errors.flush()
|
|
56
|
+
return self.reject_handler(environ, start_response)
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: modern-csrf
|
|
3
|
+
Version: 2026.10.20
|
|
4
|
+
Summary: Modern anti-CSRF middleware
|
|
5
|
+
License-Expression: MIT
|
|
6
|
+
Requires-Python: >=3.11
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Provides-Extra: asgi
|
|
10
|
+
Requires-Dist: asgiref>=3.10.0; extra == "asgi"
|
|
11
|
+
Dynamic: license-file
|
|
12
|
+
|
|
13
|
+
# Block Cross-Origin Requests
|
|
14
|
+
|
|
15
|
+
This project implements the modern [Cross-Site Request Forgery (CSRF)][csrf] defenses
|
|
16
|
+
as [described][valsorda] by Filippo Valsorda.
|
|
17
|
+
This is the [Fetch metadata approach][mdn-csrf-fetch] described on MDN.
|
|
18
|
+
Unsafe cross-origin requests are rejected with a 403 Forbidden status by default.
|
|
19
|
+
|
|
20
|
+
[csrf]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF
|
|
21
|
+
[valsorda]: https://words.filippo.io/csrf/
|
|
22
|
+
[mdn-csrf-fetch]: https://developer.mozilla.org/en-US/docs/Web/Security/Attacks/CSRF#fetch_metadata
|
|
23
|
+
|
|
24
|
+
This approach protects users of browsers less than a few years old
|
|
25
|
+
(and anyone using an older browser than that likely has worse problems to worry about).
|
|
26
|
+
This approach is useful
|
|
27
|
+
because it can be cleanly implemented as a middleware
|
|
28
|
+
without requiring close coordination with the application.
|
|
29
|
+
That doesn't mean that you can't use CSRF tokens too.
|
|
30
|
+
|
|
31
|
+
## How it works
|
|
32
|
+
|
|
33
|
+
The specific checks performed are:
|
|
34
|
+
|
|
35
|
+
1. Allow requests with one of the safe HTTP methods: `GET`, `HEAD`, or `OPTIONS`.
|
|
36
|
+
1. Allow requests from trusted origins.
|
|
37
|
+
1. If the `Sec-Fetch-Site` header is set,
|
|
38
|
+
allow requests with `Sec-Fetch-Site` set to `same-origin` or `none`, and
|
|
39
|
+
reject other requests.
|
|
40
|
+
1. Allow requests with neither of the `Sec-Fetch-Site` or `Origin` headers set.
|
|
41
|
+
1. If the `Origin` and `Host` headers are both set,
|
|
42
|
+
allow requests where the host-and-port portion of the origin matches the host.
|
|
43
|
+
1. Reject any remaining requests.
|
|
44
|
+
|
|
45
|
+
## Things to watch out for
|
|
46
|
+
|
|
47
|
+
In order for your application to be protected by this middleware,
|
|
48
|
+
you *must not* change the application state
|
|
49
|
+
in response to a request with one of the ["safe" methods][safe]
|
|
50
|
+
(`GET`, `HEAD`, `OPTIONS`).
|
|
51
|
+
Note that this is not an additional requirement because
|
|
52
|
+
there are other places within the HTTP stack
|
|
53
|
+
where this assumption already exists.
|
|
54
|
+
For example,
|
|
55
|
+
requests with these methods may be automatically retried
|
|
56
|
+
without user intervention.
|
|
57
|
+
|
|
58
|
+
[safe]: https://developer.mozilla.org/en-US/docs/Glossary/Safe/HTTP
|
|
59
|
+
|
|
60
|
+
For this to work well,
|
|
61
|
+
your application should be served from a [trustworthy origin][trustworthy],
|
|
62
|
+
i.e. over HTTPS or via localhost.
|
|
63
|
+
There may be false rejections if you do not use a trustworthy origin.
|
|
64
|
+
|
|
65
|
+
[trustworthy]: https://www.w3.org/TR/secure-contexts/#is-origin-trustworthy
|
|
66
|
+
|
|
67
|
+
## Protecting your application
|
|
68
|
+
|
|
69
|
+
Two versions of the middleware are provided:
|
|
70
|
+
a [Web Server Gateway Interface (WSGI)][wsgi] middleware
|
|
71
|
+
`modern_csrf.wsgi.BlockCrossOriginRequests`,
|
|
72
|
+
and an [Asynchronous Server Gateway Interface (ASGI)][asgi] middleware,
|
|
73
|
+
`modern_csrf.asgi.BlockCrossOriginRequests`.
|
|
74
|
+
Both versions have the same basic shape:
|
|
75
|
+
the middleware is constructed with the next application as the first argument,
|
|
76
|
+
and can be configured by setting keyword arguments.
|
|
77
|
+
The available configuration parameters are:
|
|
78
|
+
|
|
79
|
+
* `trusted_origins`:
|
|
80
|
+
A collection of origins from which requests are always allowed.
|
|
81
|
+
An "origin" is specified as `scheme://host[:port]` string
|
|
82
|
+
and must match *exactly*, including case.
|
|
83
|
+
|
|
84
|
+
Default: `()` (i.e. no trusted origins)
|
|
85
|
+
|
|
86
|
+
Example: `["https://example.com", "https://example.net:1234"]`
|
|
87
|
+
|
|
88
|
+
* `reject_handler`:
|
|
89
|
+
An application used to reject requests that should be rejected.
|
|
90
|
+
|
|
91
|
+
The default value returns a 403 Forbidden status with a fixed message.
|
|
92
|
+
|
|
93
|
+
* `log_rejected_requests`:
|
|
94
|
+
Should detailed information about rejected requests be logged?
|
|
95
|
+
For WSGI, the message is written to the WSGI error stream.
|
|
96
|
+
For ASGI, the message is logged via the standard library logging infrastructure.
|
|
97
|
+
|
|
98
|
+
Default: `False`
|
|
99
|
+
|
|
100
|
+
Example: `True`
|
|
101
|
+
|
|
102
|
+
[wsgi]: https://peps.python.org/pep-3333/
|
|
103
|
+
[asgi]: https://asgi.readthedocs.io/en/latest/
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/modern_csrf/__init__.py
|
|
5
|
+
src/modern_csrf/asgi.py
|
|
6
|
+
src/modern_csrf/base.py
|
|
7
|
+
src/modern_csrf/py.typed
|
|
8
|
+
src/modern_csrf/wsgi.py
|
|
9
|
+
src/modern_csrf.egg-info/PKG-INFO
|
|
10
|
+
src/modern_csrf.egg-info/SOURCES.txt
|
|
11
|
+
src/modern_csrf.egg-info/dependency_links.txt
|
|
12
|
+
src/modern_csrf.egg-info/requires.txt
|
|
13
|
+
src/modern_csrf.egg-info/top_level.txt
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
modern_csrf
|