xtr-security-csrf 5.0.0__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.
- xtr_security_csrf-5.0.0/LICENSE +22 -0
- xtr_security_csrf-5.0.0/PKG-INFO +207 -0
- xtr_security_csrf-5.0.0/README.md +186 -0
- xtr_security_csrf-5.0.0/pyproject.toml +139 -0
- xtr_security_csrf-5.0.0/pyproject.toml.orig +104 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/__init__.py +45 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token.py +46 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token_manager.py +181 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/csrf_token_manager_interface.py +56 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/__init__.py +19 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/csrf_error.py +15 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/invalid_argument_error.py +16 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/exception/token_not_found_error.py +33 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/py.typed +0 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/__init__.py +11 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/token_generator_interface.py +20 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_generator/uri_safe_token_generator.py +50 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/__init__.py +13 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/clearable_token_storage_interface.py +24 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/session_token_storage.py +120 -0
- xtr_security_csrf-5.0.0/src/xtr_security_csrf/token_storage/token_storage_interface.py +41 -0
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 xterr
|
|
4
|
+
Copyright (c) 2004-present Fabien Potencier
|
|
5
|
+
|
|
6
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
7
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
8
|
+
in the Software without restriction, including without limitation the rights
|
|
9
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
10
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
11
|
+
furnished to do so, subject to the following conditions:
|
|
12
|
+
|
|
13
|
+
The above copyright notice and this permission notice shall be included in all
|
|
14
|
+
copies or substantial portions of the Software.
|
|
15
|
+
|
|
16
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
17
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
18
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
19
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
20
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
21
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
22
|
+
SOFTWARE.
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: xtr-security-csrf
|
|
3
|
+
Version: 5.0.0
|
|
4
|
+
Summary: Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them.
|
|
5
|
+
Keywords: security,csrf,tokens,session
|
|
6
|
+
Author: Xterr
|
|
7
|
+
Author-email: Xterr <me@xterr.dev>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Intended Audience :: Developers
|
|
12
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
13
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
14
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
15
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
16
|
+
Classifier: Typing :: Typed
|
|
17
|
+
Requires-Dist: typing-extensions>=4.12
|
|
18
|
+
Requires-Dist: xtr-http-foundation>=5.0,<6
|
|
19
|
+
Requires-Python: >=3.11
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
<div align="center">
|
|
23
|
+
|
|
24
|
+
# xtr-security-csrf
|
|
25
|
+
|
|
26
|
+
**Cross-site request forgery protection: a manager that mints and checks tokens by id, and the session storage that keeps them.**
|
|
27
|
+
|
|
28
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
29
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
30
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
31
|
+
|
|
32
|
+
</div>
|
|
33
|
+
|
|
34
|
+
---
|
|
35
|
+
|
|
36
|
+
## Why?
|
|
37
|
+
|
|
38
|
+
A browser submits a form because a page told it to — and a page on someone else's site can
|
|
39
|
+
tell it to submit yours, carrying the user's cookies as though the request were the user's.
|
|
40
|
+
A **cross-site request forgery token** breaks that: the page that renders a form mints a
|
|
41
|
+
value into it, and the request that submits the form carries the value back, where it is
|
|
42
|
+
checked against what was minted. Another site's page cannot read the value — it is on your
|
|
43
|
+
origin's page, not theirs — so its forged submit does not check out.
|
|
44
|
+
|
|
45
|
+
This package is that mechanism, in three small pieces:
|
|
46
|
+
|
|
47
|
+
- 🎫 **A token** — `CsrfToken(token_id, value)`: the id names what the token guards, the
|
|
48
|
+
value answers for it.
|
|
49
|
+
- 🖋️ **A manager** — `CsrfTokenManager(storage, generator=None, namespace=None)` mints a
|
|
50
|
+
value per id, stores it, and masks what it hands out: every value a caller sees is
|
|
51
|
+
masked with a fresh key, so two renders of one form carry different strings — and both
|
|
52
|
+
check out, because what is *stored* never changes until `refresh_token` replaces it.
|
|
53
|
+
`is_token_valid` compares in constant time and never raises on what it is handed.
|
|
54
|
+
- 🗄️ **A storage** — `TokenStorageInterface` keeps values by id; `SessionTokenStorage`
|
|
55
|
+
keeps them in the [xtr-http-foundation](../xtr-http-foundation) session, under
|
|
56
|
+
`_csrf/<token_id>`, and `ClearableTokenStorageInterface.clear()` drops every token it
|
|
57
|
+
minted — the session strategy's call when a re-authentication rotates the session, so
|
|
58
|
+
a value stolen before the rotation cannot answer after it.
|
|
59
|
+
|
|
60
|
+
It ships no bundle of its own: it is a library member of the security family —
|
|
61
|
+
[xtr-security](../xtr-security) is the bundle that wires the family into an application on
|
|
62
|
+
[xtr-dependency-injection](../xtr-dependency-injection).
|
|
63
|
+
|
|
64
|
+
## Install
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
uv add xtr-security-csrf
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Requires Python 3.11+. One sibling comes with it:
|
|
71
|
+
[xtr-http-foundation](../xtr-http-foundation), whose session the storage keeps tokens in.
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
Everything below runs without a container: a session on the memory storage, the storage
|
|
76
|
+
over it, the manager over that. Mint a token where a form renders, check it where the form
|
|
77
|
+
submits:
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from __future__ import annotations
|
|
81
|
+
|
|
82
|
+
import asyncio
|
|
83
|
+
|
|
84
|
+
from xtr_http_foundation import MemorySessionStorage, Session
|
|
85
|
+
from xtr_security_csrf import CsrfToken, CsrfTokenManager, SessionTokenStorage
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
async def main() -> None:
|
|
89
|
+
session = Session(MemorySessionStorage())
|
|
90
|
+
manager = CsrfTokenManager(SessionTokenStorage(session))
|
|
91
|
+
|
|
92
|
+
# The page that renders the form mints the token into it.
|
|
93
|
+
token = await manager.get_token("newsletter")
|
|
94
|
+
print("render:", token.get_value())
|
|
95
|
+
|
|
96
|
+
# The request that submits the form carries the value back.
|
|
97
|
+
print("carried:", await manager.is_token_valid(token))
|
|
98
|
+
|
|
99
|
+
# A value nobody minted does not check out.
|
|
100
|
+
print("forged:", await manager.is_token_valid(CsrfToken("newsletter", "nope")))
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
asyncio.run(main())
|
|
104
|
+
```
|
|
105
|
+
|
|
106
|
+
```console
|
|
107
|
+
render: 3.kV3xtkP… (masked anew on every run)
|
|
108
|
+
carried: True
|
|
109
|
+
forged: False
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
The manager's four calls:
|
|
113
|
+
|
|
114
|
+
| Call | What it does |
|
|
115
|
+
|---|---|
|
|
116
|
+
| `await get_token(token_id)` | Returns the token for the id, minting one where none is stored |
|
|
117
|
+
| `await refresh_token(token_id)` | Mints a fresh value for the id, stored or not — for once-only tokens |
|
|
118
|
+
| `await remove_token(token_id)` | Drops the value, returning what it held, or `None` |
|
|
119
|
+
| `await is_token_valid(token)` | Tells whether the token carries the value stored under its id |
|
|
120
|
+
|
|
121
|
+
### The masked value
|
|
122
|
+
|
|
123
|
+
What a caller receives is never the stored value: `get_token` masks it with a fresh random
|
|
124
|
+
key every call — three dot-separated parts, a key-derived prefix, the key, the masked
|
|
125
|
+
value. The manager unmasks what comes back before comparing, so any string a request
|
|
126
|
+
carries can be handed to `is_token_valid` without a guard: one that does not read as a
|
|
127
|
+
masked value is compared as it came, and does not check out.
|
|
128
|
+
|
|
129
|
+
### The namespace
|
|
130
|
+
|
|
131
|
+
Stored ids carry a prefix: `CsrfTokenManager(storage, namespace="https-")` stores under
|
|
132
|
+
`https-<token_id>`, so tokens minted for one origin do not answer for another. A string is
|
|
133
|
+
used as it is; a callable is asked each time — the scheme a request arrived under can
|
|
134
|
+
change between calls. `None`, the default, prefixes nothing.
|
|
135
|
+
|
|
136
|
+
### The storage
|
|
137
|
+
|
|
138
|
+
`SessionTokenStorage(session, namespace="_csrf")` keeps each value under
|
|
139
|
+
`<namespace>/<token_id>` in the session's attributes, and keeps the list of its ids under
|
|
140
|
+
the namespace key itself — the session offers no way to walk its attributes, so the list
|
|
141
|
+
is what `clear()` drops them from: every token this storage minted, and nothing the rest of
|
|
142
|
+
the session holds. A token written under the namespace by something else than this storage
|
|
143
|
+
is not in the list, and survives a clear.
|
|
144
|
+
|
|
145
|
+
`clear()` is the session strategy's call, on the re-authentication that rotates the
|
|
146
|
+
session: the tokens minted under the old one must not answer for the new. `remove_token`
|
|
147
|
+
is the logout listener's — it drops the ids a listener knows by name.
|
|
148
|
+
|
|
149
|
+
## Errors
|
|
150
|
+
|
|
151
|
+
Everything this library raises derives from `CsrfError`, and carries what went wrong as
|
|
152
|
+
typed attributes rather than only a message.
|
|
153
|
+
|
|
154
|
+
| Error | Base (besides `CsrfError`) | Raised when |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
| `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed — a generator asked for less entropy than a byte holds |
|
|
157
|
+
| `TokenNotFoundError` | — | a value was asked for under an id that holds nothing; carries `get_token_id()` |
|
|
158
|
+
|
|
159
|
+
## Layout
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
xtr_security_csrf/
|
|
163
|
+
├── csrf_token.py CsrfToken, the id and the value a caller holds
|
|
164
|
+
├── csrf_token_manager_interface.py CsrfTokenManagerInterface, the four calls
|
|
165
|
+
├── csrf_token_manager.py CsrfTokenManager, minting, masking, checking
|
|
166
|
+
├── token_generator/
|
|
167
|
+
│ ├── token_generator_interface.py TokenGeneratorInterface, the minting seam
|
|
168
|
+
│ └── uri_safe_token_generator.py UriSafeTokenGenerator, URI-safe values
|
|
169
|
+
├── token_storage/
|
|
170
|
+
│ ├── token_storage_interface.py TokenStorageInterface, values by id
|
|
171
|
+
│ ├── clearable_token_storage_interface.py ClearableTokenStorageInterface, + clear()
|
|
172
|
+
│ └── session_token_storage.py SessionTokenStorage, in the session
|
|
173
|
+
└── exception/ CsrfError, the root of everything raised
|
|
174
|
+
```
|
|
175
|
+
|
|
176
|
+
## Testing
|
|
177
|
+
|
|
178
|
+
The suite runs on `pytest` with `anyio`; the storages are exercised over a real
|
|
179
|
+
[xtr-http-foundation](../xtr-http-foundation) `Session` on `MemorySessionStorage`, so the
|
|
180
|
+
persist-and-reattach path a request actually takes is what the tests prove — not a mock
|
|
181
|
+
of it.
|
|
182
|
+
|
|
183
|
+
## In an application
|
|
184
|
+
|
|
185
|
+
This package works on its own and ships no bundle. The
|
|
186
|
+
[`xtr-security`](../xtr-security#use-in-an-application) bundle wires it: a firewall that
|
|
187
|
+
asks for a token — a form login with `enable_csrf`, a logout's `enable_csrf` or
|
|
188
|
+
`clear_csrf_tokens`, a switch-user's `enable_csrf`, or the firewall's
|
|
189
|
+
`csrf_route_markers` for routes proving themselves with `IsCsrfTokenValid` — has the
|
|
190
|
+
bundle build the manager over the session the request
|
|
191
|
+
reattached, registered as the application's one `CsrfTokenManagerInterface` service. See
|
|
192
|
+
that package's [Use in an application](../xtr-security#use-in-an-application).
|
|
193
|
+
|
|
194
|
+
## Development
|
|
195
|
+
|
|
196
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
197
|
+
`packages/xtr-security-csrf`; run the commands below from there. The `python-xtr-security-csrf`
|
|
198
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
199
|
+
|
|
200
|
+
```sh
|
|
201
|
+
uv sync
|
|
202
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## License
|
|
206
|
+
|
|
207
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# xtr-security-csrf
|
|
4
|
+
|
|
5
|
+
**Cross-site request forgery protection: a manager that mints and checks tokens by id, and the session storage that keeps them.**
|
|
6
|
+
|
|
7
|
+
<img alt="python 3.11+" src="https://img.shields.io/badge/python-%E2%89%A5%203.11-3776AB?logo=python&logoColor=white">
|
|
8
|
+
<img alt="typed" src="https://img.shields.io/badge/typed-ty%20%2B%20basedpyright-1f6feb">
|
|
9
|
+
<img alt="license MIT" src="https://img.shields.io/badge/license-MIT-blue">
|
|
10
|
+
|
|
11
|
+
</div>
|
|
12
|
+
|
|
13
|
+
---
|
|
14
|
+
|
|
15
|
+
## Why?
|
|
16
|
+
|
|
17
|
+
A browser submits a form because a page told it to — and a page on someone else's site can
|
|
18
|
+
tell it to submit yours, carrying the user's cookies as though the request were the user's.
|
|
19
|
+
A **cross-site request forgery token** breaks that: the page that renders a form mints a
|
|
20
|
+
value into it, and the request that submits the form carries the value back, where it is
|
|
21
|
+
checked against what was minted. Another site's page cannot read the value — it is on your
|
|
22
|
+
origin's page, not theirs — so its forged submit does not check out.
|
|
23
|
+
|
|
24
|
+
This package is that mechanism, in three small pieces:
|
|
25
|
+
|
|
26
|
+
- 🎫 **A token** — `CsrfToken(token_id, value)`: the id names what the token guards, the
|
|
27
|
+
value answers for it.
|
|
28
|
+
- 🖋️ **A manager** — `CsrfTokenManager(storage, generator=None, namespace=None)` mints a
|
|
29
|
+
value per id, stores it, and masks what it hands out: every value a caller sees is
|
|
30
|
+
masked with a fresh key, so two renders of one form carry different strings — and both
|
|
31
|
+
check out, because what is *stored* never changes until `refresh_token` replaces it.
|
|
32
|
+
`is_token_valid` compares in constant time and never raises on what it is handed.
|
|
33
|
+
- 🗄️ **A storage** — `TokenStorageInterface` keeps values by id; `SessionTokenStorage`
|
|
34
|
+
keeps them in the [xtr-http-foundation](../xtr-http-foundation) session, under
|
|
35
|
+
`_csrf/<token_id>`, and `ClearableTokenStorageInterface.clear()` drops every token it
|
|
36
|
+
minted — the session strategy's call when a re-authentication rotates the session, so
|
|
37
|
+
a value stolen before the rotation cannot answer after it.
|
|
38
|
+
|
|
39
|
+
It ships no bundle of its own: it is a library member of the security family —
|
|
40
|
+
[xtr-security](../xtr-security) is the bundle that wires the family into an application on
|
|
41
|
+
[xtr-dependency-injection](../xtr-dependency-injection).
|
|
42
|
+
|
|
43
|
+
## Install
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
uv add xtr-security-csrf
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
Requires Python 3.11+. One sibling comes with it:
|
|
50
|
+
[xtr-http-foundation](../xtr-http-foundation), whose session the storage keeps tokens in.
|
|
51
|
+
|
|
52
|
+
## Quick start
|
|
53
|
+
|
|
54
|
+
Everything below runs without a container: a session on the memory storage, the storage
|
|
55
|
+
over it, the manager over that. Mint a token where a form renders, check it where the form
|
|
56
|
+
submits:
|
|
57
|
+
|
|
58
|
+
```python
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import asyncio
|
|
62
|
+
|
|
63
|
+
from xtr_http_foundation import MemorySessionStorage, Session
|
|
64
|
+
from xtr_security_csrf import CsrfToken, CsrfTokenManager, SessionTokenStorage
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
async def main() -> None:
|
|
68
|
+
session = Session(MemorySessionStorage())
|
|
69
|
+
manager = CsrfTokenManager(SessionTokenStorage(session))
|
|
70
|
+
|
|
71
|
+
# The page that renders the form mints the token into it.
|
|
72
|
+
token = await manager.get_token("newsletter")
|
|
73
|
+
print("render:", token.get_value())
|
|
74
|
+
|
|
75
|
+
# The request that submits the form carries the value back.
|
|
76
|
+
print("carried:", await manager.is_token_valid(token))
|
|
77
|
+
|
|
78
|
+
# A value nobody minted does not check out.
|
|
79
|
+
print("forged:", await manager.is_token_valid(CsrfToken("newsletter", "nope")))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
asyncio.run(main())
|
|
83
|
+
```
|
|
84
|
+
|
|
85
|
+
```console
|
|
86
|
+
render: 3.kV3xtkP… (masked anew on every run)
|
|
87
|
+
carried: True
|
|
88
|
+
forged: False
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
The manager's four calls:
|
|
92
|
+
|
|
93
|
+
| Call | What it does |
|
|
94
|
+
|---|---|
|
|
95
|
+
| `await get_token(token_id)` | Returns the token for the id, minting one where none is stored |
|
|
96
|
+
| `await refresh_token(token_id)` | Mints a fresh value for the id, stored or not — for once-only tokens |
|
|
97
|
+
| `await remove_token(token_id)` | Drops the value, returning what it held, or `None` |
|
|
98
|
+
| `await is_token_valid(token)` | Tells whether the token carries the value stored under its id |
|
|
99
|
+
|
|
100
|
+
### The masked value
|
|
101
|
+
|
|
102
|
+
What a caller receives is never the stored value: `get_token` masks it with a fresh random
|
|
103
|
+
key every call — three dot-separated parts, a key-derived prefix, the key, the masked
|
|
104
|
+
value. The manager unmasks what comes back before comparing, so any string a request
|
|
105
|
+
carries can be handed to `is_token_valid` without a guard: one that does not read as a
|
|
106
|
+
masked value is compared as it came, and does not check out.
|
|
107
|
+
|
|
108
|
+
### The namespace
|
|
109
|
+
|
|
110
|
+
Stored ids carry a prefix: `CsrfTokenManager(storage, namespace="https-")` stores under
|
|
111
|
+
`https-<token_id>`, so tokens minted for one origin do not answer for another. A string is
|
|
112
|
+
used as it is; a callable is asked each time — the scheme a request arrived under can
|
|
113
|
+
change between calls. `None`, the default, prefixes nothing.
|
|
114
|
+
|
|
115
|
+
### The storage
|
|
116
|
+
|
|
117
|
+
`SessionTokenStorage(session, namespace="_csrf")` keeps each value under
|
|
118
|
+
`<namespace>/<token_id>` in the session's attributes, and keeps the list of its ids under
|
|
119
|
+
the namespace key itself — the session offers no way to walk its attributes, so the list
|
|
120
|
+
is what `clear()` drops them from: every token this storage minted, and nothing the rest of
|
|
121
|
+
the session holds. A token written under the namespace by something else than this storage
|
|
122
|
+
is not in the list, and survives a clear.
|
|
123
|
+
|
|
124
|
+
`clear()` is the session strategy's call, on the re-authentication that rotates the
|
|
125
|
+
session: the tokens minted under the old one must not answer for the new. `remove_token`
|
|
126
|
+
is the logout listener's — it drops the ids a listener knows by name.
|
|
127
|
+
|
|
128
|
+
## Errors
|
|
129
|
+
|
|
130
|
+
Everything this library raises derives from `CsrfError`, and carries what went wrong as
|
|
131
|
+
typed attributes rather than only a message.
|
|
132
|
+
|
|
133
|
+
| Error | Base (besides `CsrfError`) | Raised when |
|
|
134
|
+
|---|---|---|
|
|
135
|
+
| `InvalidArgumentError` | `ValueError` | a declaration, configuration or call was malformed — a generator asked for less entropy than a byte holds |
|
|
136
|
+
| `TokenNotFoundError` | — | a value was asked for under an id that holds nothing; carries `get_token_id()` |
|
|
137
|
+
|
|
138
|
+
## Layout
|
|
139
|
+
|
|
140
|
+
```
|
|
141
|
+
xtr_security_csrf/
|
|
142
|
+
├── csrf_token.py CsrfToken, the id and the value a caller holds
|
|
143
|
+
├── csrf_token_manager_interface.py CsrfTokenManagerInterface, the four calls
|
|
144
|
+
├── csrf_token_manager.py CsrfTokenManager, minting, masking, checking
|
|
145
|
+
├── token_generator/
|
|
146
|
+
│ ├── token_generator_interface.py TokenGeneratorInterface, the minting seam
|
|
147
|
+
│ └── uri_safe_token_generator.py UriSafeTokenGenerator, URI-safe values
|
|
148
|
+
├── token_storage/
|
|
149
|
+
│ ├── token_storage_interface.py TokenStorageInterface, values by id
|
|
150
|
+
│ ├── clearable_token_storage_interface.py ClearableTokenStorageInterface, + clear()
|
|
151
|
+
│ └── session_token_storage.py SessionTokenStorage, in the session
|
|
152
|
+
└── exception/ CsrfError, the root of everything raised
|
|
153
|
+
```
|
|
154
|
+
|
|
155
|
+
## Testing
|
|
156
|
+
|
|
157
|
+
The suite runs on `pytest` with `anyio`; the storages are exercised over a real
|
|
158
|
+
[xtr-http-foundation](../xtr-http-foundation) `Session` on `MemorySessionStorage`, so the
|
|
159
|
+
persist-and-reattach path a request actually takes is what the tests prove — not a mock
|
|
160
|
+
of it.
|
|
161
|
+
|
|
162
|
+
## In an application
|
|
163
|
+
|
|
164
|
+
This package works on its own and ships no bundle. The
|
|
165
|
+
[`xtr-security`](../xtr-security#use-in-an-application) bundle wires it: a firewall that
|
|
166
|
+
asks for a token — a form login with `enable_csrf`, a logout's `enable_csrf` or
|
|
167
|
+
`clear_csrf_tokens`, a switch-user's `enable_csrf`, or the firewall's
|
|
168
|
+
`csrf_route_markers` for routes proving themselves with `IsCsrfTokenValid` — has the
|
|
169
|
+
bundle build the manager over the session the request
|
|
170
|
+
reattached, registered as the application's one `CsrfTokenManagerInterface` service. See
|
|
171
|
+
that package's [Use in an application](../xtr-security#use-in-an-application).
|
|
172
|
+
|
|
173
|
+
## Development
|
|
174
|
+
|
|
175
|
+
Developed in the [python-xtr](https://github.com/xterr/python-xtr) monorepo, under
|
|
176
|
+
`packages/xtr-security-csrf`; run the commands below from there. The `python-xtr-security-csrf`
|
|
177
|
+
repository is a read-only copy, so send issues and pull requests to the monorepo.
|
|
178
|
+
|
|
179
|
+
```sh
|
|
180
|
+
uv sync
|
|
181
|
+
uv run ruff check && uv run ruff format --check && uv run basedpyright && uv run ty check && uv run pytest
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
## License
|
|
185
|
+
|
|
186
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xtr-security-csrf"
|
|
3
|
+
version = "5.0.0"
|
|
4
|
+
description = "Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
keywords = [
|
|
10
|
+
"security",
|
|
11
|
+
"csrf",
|
|
12
|
+
"tokens",
|
|
13
|
+
"session",
|
|
14
|
+
]
|
|
15
|
+
classifiers = [
|
|
16
|
+
"Development Status :: 3 - Alpha",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"Programming Language :: Python :: 3.11",
|
|
19
|
+
"Programming Language :: Python :: 3.12",
|
|
20
|
+
"Programming Language :: Python :: 3.13",
|
|
21
|
+
"Programming Language :: Python :: 3.14",
|
|
22
|
+
"Typing :: Typed",
|
|
23
|
+
]
|
|
24
|
+
dependencies = [
|
|
25
|
+
"typing-extensions>=4.12",
|
|
26
|
+
"xtr-http-foundation>=5.0,<6",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
[[project.authors]]
|
|
30
|
+
name = "Xterr"
|
|
31
|
+
email = "me@xterr.dev"
|
|
32
|
+
|
|
33
|
+
[dependency-groups]
|
|
34
|
+
dev = [
|
|
35
|
+
"basedpyright>=1.21",
|
|
36
|
+
"ruff>=0.8",
|
|
37
|
+
"pytest>=8",
|
|
38
|
+
"pytest-cov>=5",
|
|
39
|
+
"anyio>=4.0",
|
|
40
|
+
"ty>=0.0.83",
|
|
41
|
+
]
|
|
42
|
+
|
|
43
|
+
[build-system]
|
|
44
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
45
|
+
build-backend = "uv_build"
|
|
46
|
+
|
|
47
|
+
[tool.basedpyright]
|
|
48
|
+
typeCheckingMode = "all"
|
|
49
|
+
pythonVersion = "3.11"
|
|
50
|
+
pythonPlatform = "All"
|
|
51
|
+
include = [
|
|
52
|
+
"src",
|
|
53
|
+
"tests",
|
|
54
|
+
]
|
|
55
|
+
exclude = [
|
|
56
|
+
"**/__pycache__",
|
|
57
|
+
"**/.venv",
|
|
58
|
+
"**/build",
|
|
59
|
+
"**/dist",
|
|
60
|
+
".tmp",
|
|
61
|
+
]
|
|
62
|
+
reportUnusedCallResult = "warning"
|
|
63
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
64
|
+
reportUnusedVariable = "error"
|
|
65
|
+
reportMissingParameterType = "error"
|
|
66
|
+
reportPrivateUsage = "error"
|
|
67
|
+
reportImplicitStringConcatenation = "none"
|
|
68
|
+
|
|
69
|
+
[[tool.basedpyright.executionEnvironments]]
|
|
70
|
+
root = "tests"
|
|
71
|
+
extraPaths = ["."]
|
|
72
|
+
reportPrivateUsage = "none"
|
|
73
|
+
|
|
74
|
+
[tool.ruff]
|
|
75
|
+
target-version = "py311"
|
|
76
|
+
line-length = 100
|
|
77
|
+
src = [
|
|
78
|
+
"src",
|
|
79
|
+
"tests",
|
|
80
|
+
]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint]
|
|
83
|
+
select = ["ALL"]
|
|
84
|
+
ignore = [
|
|
85
|
+
"COM812",
|
|
86
|
+
"ISC001",
|
|
87
|
+
"D203",
|
|
88
|
+
"D213",
|
|
89
|
+
"CPY001",
|
|
90
|
+
"FBT001",
|
|
91
|
+
"FBT002",
|
|
92
|
+
"TD002",
|
|
93
|
+
"TD003",
|
|
94
|
+
"FIX002",
|
|
95
|
+
"TRY003",
|
|
96
|
+
"EM101",
|
|
97
|
+
"EM102",
|
|
98
|
+
]
|
|
99
|
+
fixable = ["ALL"]
|
|
100
|
+
unfixable = []
|
|
101
|
+
|
|
102
|
+
[tool.ruff.lint.per-file-ignores]
|
|
103
|
+
"tests/**/*.py" = [
|
|
104
|
+
"S101",
|
|
105
|
+
"ARG",
|
|
106
|
+
"PLR2004",
|
|
107
|
+
"SLF001",
|
|
108
|
+
"D",
|
|
109
|
+
]
|
|
110
|
+
|
|
111
|
+
[tool.ruff.lint.pydocstyle]
|
|
112
|
+
convention = "google"
|
|
113
|
+
|
|
114
|
+
[tool.ruff.format]
|
|
115
|
+
quote-style = "double"
|
|
116
|
+
indent-style = "space"
|
|
117
|
+
|
|
118
|
+
[tool.ty.src]
|
|
119
|
+
include = [
|
|
120
|
+
"src",
|
|
121
|
+
"tests",
|
|
122
|
+
]
|
|
123
|
+
|
|
124
|
+
[tool.pytest.ini_options]
|
|
125
|
+
minversion = "8.0"
|
|
126
|
+
testpaths = ["tests"]
|
|
127
|
+
addopts = [
|
|
128
|
+
"-ra",
|
|
129
|
+
"--strict-config",
|
|
130
|
+
"--strict-markers",
|
|
131
|
+
]
|
|
132
|
+
filterwarnings = ["error"]
|
|
133
|
+
|
|
134
|
+
[tool.coverage.run]
|
|
135
|
+
source = ["src"]
|
|
136
|
+
branch = true
|
|
137
|
+
|
|
138
|
+
[tool.coverage.report]
|
|
139
|
+
fail_under = 95
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "xtr-security-csrf"
|
|
3
|
+
version = "5.0.0"
|
|
4
|
+
description = "Cross-site request forgery protection for xtr applications: a manager that mints and checks tokens by id, and the session storage that keeps them."
|
|
5
|
+
readme = "README.md"
|
|
6
|
+
requires-python = ">=3.11"
|
|
7
|
+
license = "MIT"
|
|
8
|
+
license-files = ["LICENSE"]
|
|
9
|
+
authors = [
|
|
10
|
+
{ name = "Xterr", email = "me@xterr.dev" }
|
|
11
|
+
]
|
|
12
|
+
keywords = ["security", "csrf", "tokens", "session"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 3 - Alpha",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"Programming Language :: Python :: 3.11",
|
|
17
|
+
"Programming Language :: Python :: 3.12",
|
|
18
|
+
"Programming Language :: Python :: 3.13",
|
|
19
|
+
"Programming Language :: Python :: 3.14",
|
|
20
|
+
"Typing :: Typed",
|
|
21
|
+
]
|
|
22
|
+
dependencies = [
|
|
23
|
+
"typing-extensions>=4.12",
|
|
24
|
+
"xtr-http-foundation>=5.0,<6",
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
[dependency-groups]
|
|
28
|
+
dev = [
|
|
29
|
+
"basedpyright>=1.21",
|
|
30
|
+
"ruff>=0.8",
|
|
31
|
+
"pytest>=8",
|
|
32
|
+
"pytest-cov>=5",
|
|
33
|
+
"anyio>=4.0",
|
|
34
|
+
"ty>=0.0.83",
|
|
35
|
+
]
|
|
36
|
+
|
|
37
|
+
[build-system]
|
|
38
|
+
requires = ["uv_build>=0.9.18,<0.10.0"]
|
|
39
|
+
build-backend = "uv_build"
|
|
40
|
+
|
|
41
|
+
[tool.basedpyright]
|
|
42
|
+
typeCheckingMode = "all"
|
|
43
|
+
pythonVersion = "3.11"
|
|
44
|
+
pythonPlatform = "All"
|
|
45
|
+
include = ["src", "tests"]
|
|
46
|
+
exclude = ["**/__pycache__", "**/.venv", "**/build", "**/dist", ".tmp"]
|
|
47
|
+
reportUnusedCallResult = "warning"
|
|
48
|
+
reportUnnecessaryTypeIgnoreComment = "error"
|
|
49
|
+
reportUnusedVariable = "error"
|
|
50
|
+
reportMissingParameterType = "error"
|
|
51
|
+
reportPrivateUsage = "error"
|
|
52
|
+
|
|
53
|
+
# An error message long enough to be useful has to wrap, and a 100-column
|
|
54
|
+
# limit puts the break inside the string.
|
|
55
|
+
reportImplicitStringConcatenation = "none"
|
|
56
|
+
|
|
57
|
+
# Tests reach into what a class keeps to itself, for the same reason ruff's
|
|
58
|
+
# SLF001 is off for tests; the extra path lets them import the support package.
|
|
59
|
+
[[tool.basedpyright.executionEnvironments]]
|
|
60
|
+
root = "tests"
|
|
61
|
+
extraPaths = ["."]
|
|
62
|
+
reportPrivateUsage = "none"
|
|
63
|
+
|
|
64
|
+
[tool.ruff]
|
|
65
|
+
target-version = "py311"
|
|
66
|
+
line-length = 100
|
|
67
|
+
src = ["src", "tests"]
|
|
68
|
+
|
|
69
|
+
[tool.ruff.lint]
|
|
70
|
+
select = ["ALL"]
|
|
71
|
+
ignore = [
|
|
72
|
+
"COM812", "ISC001", "D203", "D213", "CPY001", "FBT001", "FBT002", "TD002", "TD003", "FIX002",
|
|
73
|
+
# Errors compose their message from typed fields; the string at a raise site is a reason.
|
|
74
|
+
"TRY003", "EM101", "EM102",
|
|
75
|
+
]
|
|
76
|
+
fixable = ["ALL"]
|
|
77
|
+
unfixable = []
|
|
78
|
+
|
|
79
|
+
[tool.ruff.lint.per-file-ignores]
|
|
80
|
+
"tests/**/*.py" = ["S101", "ARG", "PLR2004", "SLF001", "D"]
|
|
81
|
+
|
|
82
|
+
[tool.ruff.lint.pydocstyle]
|
|
83
|
+
convention = "google"
|
|
84
|
+
|
|
85
|
+
[tool.ruff.format]
|
|
86
|
+
quote-style = "double"
|
|
87
|
+
indent-style = "space"
|
|
88
|
+
|
|
89
|
+
[tool.ty.src]
|
|
90
|
+
include = ["src", "tests"]
|
|
91
|
+
|
|
92
|
+
[tool.pytest.ini_options]
|
|
93
|
+
minversion = "8.0"
|
|
94
|
+
testpaths = ["tests"]
|
|
95
|
+
addopts = ["-ra", "--strict-config", "--strict-markers"]
|
|
96
|
+
filterwarnings = ["error"]
|
|
97
|
+
|
|
98
|
+
[tool.coverage.run]
|
|
99
|
+
source = ["src"]
|
|
100
|
+
branch = true
|
|
101
|
+
|
|
102
|
+
[tool.coverage.report]
|
|
103
|
+
# CI runs the tests with --cov; a change taking coverage below this fails there.
|
|
104
|
+
fail_under = 95
|