xtr-security-csrf 5.0.0__py3-none-any.whl
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/__init__.py +45 -0
- xtr_security_csrf/csrf_token.py +46 -0
- xtr_security_csrf/csrf_token_manager.py +181 -0
- xtr_security_csrf/csrf_token_manager_interface.py +56 -0
- xtr_security_csrf/exception/__init__.py +19 -0
- xtr_security_csrf/exception/csrf_error.py +15 -0
- xtr_security_csrf/exception/invalid_argument_error.py +16 -0
- xtr_security_csrf/exception/token_not_found_error.py +33 -0
- xtr_security_csrf/py.typed +0 -0
- xtr_security_csrf/token_generator/__init__.py +11 -0
- xtr_security_csrf/token_generator/token_generator_interface.py +20 -0
- xtr_security_csrf/token_generator/uri_safe_token_generator.py +50 -0
- xtr_security_csrf/token_storage/__init__.py +13 -0
- xtr_security_csrf/token_storage/clearable_token_storage_interface.py +24 -0
- xtr_security_csrf/token_storage/session_token_storage.py +120 -0
- xtr_security_csrf/token_storage/token_storage_interface.py +41 -0
- xtr_security_csrf-5.0.0.dist-info/METADATA +207 -0
- xtr_security_csrf-5.0.0.dist-info/RECORD +20 -0
- xtr_security_csrf-5.0.0.dist-info/WHEEL +4 -0
- xtr_security_csrf-5.0.0.dist-info/licenses/LICENSE +22 -0
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
"""Cross-site request forgery tokens, minted and checked by id.
|
|
2
|
+
|
|
3
|
+
A manager mints a value per token id, masks what it hands out, and checks
|
|
4
|
+
what comes back against what it stored; a storage keeps the values — in the
|
|
5
|
+
session a request carries, or wherever the application points them. This
|
|
6
|
+
module gathers the public surface so an application imports it from one
|
|
7
|
+
place.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from importlib.metadata import PackageNotFoundError, version
|
|
13
|
+
|
|
14
|
+
from .csrf_token import CsrfToken
|
|
15
|
+
from .csrf_token_manager import CsrfTokenManager
|
|
16
|
+
from .csrf_token_manager_interface import CsrfTokenManagerInterface
|
|
17
|
+
from .exception import CsrfError, InvalidArgumentError, TokenNotFoundError
|
|
18
|
+
from .token_generator import TokenGeneratorInterface, UriSafeTokenGenerator
|
|
19
|
+
from .token_storage import (
|
|
20
|
+
ClearableTokenStorageInterface,
|
|
21
|
+
SessionTokenStorage,
|
|
22
|
+
TokenStorageInterface,
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
__all__ = [
|
|
26
|
+
"ClearableTokenStorageInterface",
|
|
27
|
+
"CsrfError",
|
|
28
|
+
"CsrfToken",
|
|
29
|
+
"CsrfTokenManager",
|
|
30
|
+
"CsrfTokenManagerInterface",
|
|
31
|
+
"InvalidArgumentError",
|
|
32
|
+
"SessionTokenStorage",
|
|
33
|
+
"TokenGeneratorInterface",
|
|
34
|
+
"TokenNotFoundError",
|
|
35
|
+
"TokenStorageInterface",
|
|
36
|
+
"UriSafeTokenGenerator",
|
|
37
|
+
"__version__",
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
try:
|
|
41
|
+
__version__ = version("xtr-security-csrf")
|
|
42
|
+
except PackageNotFoundError: # pragma: no cover
|
|
43
|
+
# Running from a source tree with no installed metadata to read; having no
|
|
44
|
+
# version is better than refusing to import.
|
|
45
|
+
__version__ = "0+unknown"
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""A cross-site request forgery token, as a caller holds one."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
__all__ = ["CsrfToken"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@final
|
|
13
|
+
class CsrfToken:
|
|
14
|
+
"""The id a token is known under, and the value that answers for it.
|
|
15
|
+
|
|
16
|
+
A token id names what the token guards — a form, an action, a listener —
|
|
17
|
+
and the value is what a request carries to prove that the page which
|
|
18
|
+
rendered it asked for it. The pair travels together: the id says which
|
|
19
|
+
stored value the carried one is checked against.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
__slots__ = ("_id", "_value")
|
|
23
|
+
|
|
24
|
+
def __init__(self, token_id: str, value: str = "") -> None:
|
|
25
|
+
"""Record the id and the value that answers for it.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
token_id: What the token guards, chosen by whoever mints it.
|
|
29
|
+
value: The value that answers for the id; the empty value is
|
|
30
|
+
the token a caller holds before one has been minted.
|
|
31
|
+
"""
|
|
32
|
+
self._id = token_id
|
|
33
|
+
self._value = value
|
|
34
|
+
|
|
35
|
+
def get_id(self) -> str:
|
|
36
|
+
"""Return the id this token is known under."""
|
|
37
|
+
return self._id
|
|
38
|
+
|
|
39
|
+
def get_value(self) -> str:
|
|
40
|
+
"""Return the value that answers for the id."""
|
|
41
|
+
return self._value
|
|
42
|
+
|
|
43
|
+
@override
|
|
44
|
+
def __str__(self) -> str:
|
|
45
|
+
"""Return the value, so a token renders straight into a form or a header."""
|
|
46
|
+
return self._value
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""The default token manager: values minted per id, masked on the way out."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import hashlib
|
|
7
|
+
import hmac
|
|
8
|
+
import secrets
|
|
9
|
+
from typing import TYPE_CHECKING, final
|
|
10
|
+
|
|
11
|
+
from typing_extensions import override
|
|
12
|
+
|
|
13
|
+
from .csrf_token import CsrfToken
|
|
14
|
+
from .csrf_token_manager_interface import CsrfTokenManagerInterface
|
|
15
|
+
from .token_generator.uri_safe_token_generator import UriSafeTokenGenerator
|
|
16
|
+
|
|
17
|
+
if TYPE_CHECKING:
|
|
18
|
+
from collections.abc import Callable
|
|
19
|
+
|
|
20
|
+
from .token_generator.token_generator_interface import TokenGeneratorInterface
|
|
21
|
+
from .token_storage.token_storage_interface import TokenStorageInterface
|
|
22
|
+
|
|
23
|
+
__all__ = ["CsrfTokenManager"]
|
|
24
|
+
|
|
25
|
+
# The key a handed-out value is masked with. Long enough that guessing it is
|
|
26
|
+
# hopeless, short enough that masking costs nothing.
|
|
27
|
+
_MASK_KEY_LENGTH: int = 32
|
|
28
|
+
# A masked value is three dot-separated parts: the prefix, the key, the value.
|
|
29
|
+
_MASKED_PARTS: int = 3
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
@final
|
|
33
|
+
class CsrfTokenManager(CsrfTokenManagerInterface):
|
|
34
|
+
"""Mints a value per token id, stores it, and masks what it hands out.
|
|
35
|
+
|
|
36
|
+
What answers for an id is not the value that is stored: every value
|
|
37
|
+
handed out is masked with a fresh key, so two renders of the same form
|
|
38
|
+
carry different strings, and a page that compresses its response cannot
|
|
39
|
+
be made to leak a token by echoing one back. The masked value is all a
|
|
40
|
+
caller ever sees; the stored one is all the manager ever compares.
|
|
41
|
+
|
|
42
|
+
The id a value is stored under is the namespace followed by the token
|
|
43
|
+
id, so tokens minted for one origin do not answer for another.
|
|
44
|
+
"""
|
|
45
|
+
|
|
46
|
+
__slots__ = ("_generator", "_namespace", "_storage")
|
|
47
|
+
|
|
48
|
+
def __init__(
|
|
49
|
+
self,
|
|
50
|
+
storage: TokenStorageInterface,
|
|
51
|
+
generator: TokenGeneratorInterface | None = None,
|
|
52
|
+
namespace: str | Callable[[], str] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
"""Record where values are kept, what mints them, and how ids are prefixed.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
storage: Where token values are kept, by namespaced id.
|
|
58
|
+
generator: What mints a fresh value; a URI-safe generator when
|
|
59
|
+
nothing is given.
|
|
60
|
+
namespace: The prefix every stored id carries, so tokens minted
|
|
61
|
+
for one origin do not answer for another: a string used as
|
|
62
|
+
it is, or a callable asked each time — the scheme a request
|
|
63
|
+
arrived under can change between calls, so it is read per
|
|
64
|
+
call, not once. ``None`` prefixes nothing.
|
|
65
|
+
"""
|
|
66
|
+
self._storage = storage
|
|
67
|
+
self._generator = generator if generator is not None else UriSafeTokenGenerator()
|
|
68
|
+
self._namespace = namespace
|
|
69
|
+
|
|
70
|
+
@override
|
|
71
|
+
async def get_token(self, token_id: str) -> CsrfToken:
|
|
72
|
+
"""Return the token for ``token_id``, minting one where none is stored.
|
|
73
|
+
|
|
74
|
+
A value already stored is handed out again — masked anew, so the
|
|
75
|
+
string differs, but it answers for the same stored value.
|
|
76
|
+
"""
|
|
77
|
+
namespaced_id = self._namespaced_id(token_id)
|
|
78
|
+
if await self._storage.has_token(namespaced_id):
|
|
79
|
+
value = await self._storage.get_token(namespaced_id)
|
|
80
|
+
else:
|
|
81
|
+
value = self._generator.generate_token()
|
|
82
|
+
await self._storage.set_token(namespaced_id, value)
|
|
83
|
+
|
|
84
|
+
return CsrfToken(token_id, self._randomize(value))
|
|
85
|
+
|
|
86
|
+
@override
|
|
87
|
+
async def refresh_token(self, token_id: str) -> CsrfToken:
|
|
88
|
+
"""Mint a fresh value for ``token_id``, replacing whatever was stored."""
|
|
89
|
+
namespaced_id = self._namespaced_id(token_id)
|
|
90
|
+
value = self._generator.generate_token()
|
|
91
|
+
await self._storage.set_token(namespaced_id, value)
|
|
92
|
+
|
|
93
|
+
return CsrfToken(token_id, self._randomize(value))
|
|
94
|
+
|
|
95
|
+
@override
|
|
96
|
+
async def remove_token(self, token_id: str) -> str | None:
|
|
97
|
+
"""Drop the value under ``token_id``, returning what it held."""
|
|
98
|
+
return await self._storage.remove_token(self._namespaced_id(token_id))
|
|
99
|
+
|
|
100
|
+
@override
|
|
101
|
+
async def is_token_valid(self, token: CsrfToken) -> bool:
|
|
102
|
+
"""Tell whether ``token`` carries the value stored under its id.
|
|
103
|
+
|
|
104
|
+
The comparison is constant-time, and the value it is handed is never
|
|
105
|
+
trusted to be well-formed: one that does not read as a value this
|
|
106
|
+
manager minted is compared as it came, and does not check out.
|
|
107
|
+
"""
|
|
108
|
+
namespaced_id = self._namespaced_id(token.get_id())
|
|
109
|
+
if not await self._storage.has_token(namespaced_id):
|
|
110
|
+
return False
|
|
111
|
+
|
|
112
|
+
stored = await self._storage.get_token(namespaced_id)
|
|
113
|
+
return hmac.compare_digest(
|
|
114
|
+
stored.encode(),
|
|
115
|
+
self._derandomize(token.get_value()).encode(),
|
|
116
|
+
)
|
|
117
|
+
|
|
118
|
+
def _namespaced_id(self, token_id: str) -> str:
|
|
119
|
+
"""Return the id as it is stored: the namespace, then the id."""
|
|
120
|
+
namespace = self._namespace
|
|
121
|
+
if namespace is None:
|
|
122
|
+
return token_id
|
|
123
|
+
if isinstance(namespace, str):
|
|
124
|
+
return namespace + token_id
|
|
125
|
+
return namespace() + token_id
|
|
126
|
+
|
|
127
|
+
def _randomize(self, value: str) -> str:
|
|
128
|
+
"""Mask ``value`` with a fresh key, so what is handed out differs every call.
|
|
129
|
+
|
|
130
|
+
The key travels with the masked value — there is nothing to keep
|
|
131
|
+
between calls — and a short hex prefix of key-derived length heads
|
|
132
|
+
the three parts, so two values of the same token are not even the
|
|
133
|
+
same length. The prefix is never read back; only the key and the
|
|
134
|
+
masked value are.
|
|
135
|
+
"""
|
|
136
|
+
key = secrets.token_bytes(_MASK_KEY_LENGTH)
|
|
137
|
+
masked = _xor(value.encode(), key)
|
|
138
|
+
prefix = hashlib.sha256(key).hexdigest()[: 1 + key[0] % 32]
|
|
139
|
+
return f"{prefix}.{_encode(key)}.{_encode(masked)}"
|
|
140
|
+
|
|
141
|
+
def _derandomize(self, value: str) -> str:
|
|
142
|
+
"""Return the stored value a handed-out string masks, or the string itself.
|
|
143
|
+
|
|
144
|
+
Anything that does not read as a masked value — not three parts, a
|
|
145
|
+
part that is not the encoding it claims, bytes that are not text —
|
|
146
|
+
is returned exactly as it came, and so fails the comparison that
|
|
147
|
+
follows. A caller's string is never trusted to be well-formed.
|
|
148
|
+
"""
|
|
149
|
+
parts = value.split(".")
|
|
150
|
+
if len(parts) != _MASKED_PARTS:
|
|
151
|
+
return value
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
key = _decode(parts[1])
|
|
155
|
+
if not key:
|
|
156
|
+
return value
|
|
157
|
+
masked = _decode(parts[2])
|
|
158
|
+
return _xor(masked, key).decode()
|
|
159
|
+
except ValueError:
|
|
160
|
+
return value
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _xor(value: bytes, key: bytes) -> bytes:
|
|
164
|
+
"""Return ``value`` masked with ``key``, byte for byte.
|
|
165
|
+
|
|
166
|
+
The key is repeated until it covers the value, and the result is as
|
|
167
|
+
long as the shorter of the two — the value, once it is.
|
|
168
|
+
"""
|
|
169
|
+
if len(value) > len(key):
|
|
170
|
+
key = key * ((len(value) + len(key) - 1) // len(key))
|
|
171
|
+
return bytes(left ^ right for left, right in zip(value, key, strict=False))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _encode(data: bytes) -> str:
|
|
175
|
+
"""Return ``data`` as URI-safe base64, without padding."""
|
|
176
|
+
return base64.urlsafe_b64encode(data).rstrip(b"=").decode()
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _decode(data: str) -> bytes:
|
|
180
|
+
"""Return the bytes a URI-safe, unpadded value encodes."""
|
|
181
|
+
return base64.urlsafe_b64decode(data + "=" * (-len(data) % 4))
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""The one surface every token manager answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
if TYPE_CHECKING:
|
|
8
|
+
from .csrf_token import CsrfToken
|
|
9
|
+
|
|
10
|
+
__all__ = ["CsrfTokenManagerInterface"]
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@runtime_checkable
|
|
14
|
+
class CsrfTokenManagerInterface(Protocol):
|
|
15
|
+
"""Mints, renews, drops and checks tokens, by id.
|
|
16
|
+
|
|
17
|
+
Every member is awaited: a manager reaches a storage to answer any of
|
|
18
|
+
them, and one storage must be swappable for another — a remote one is
|
|
19
|
+
awaited — exactly as every matcher is awaited even when it only reads
|
|
20
|
+
the head of a request.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
async def get_token(self, token_id: str) -> CsrfToken:
|
|
24
|
+
"""Return the token for ``token_id``, minting one where none is stored.
|
|
25
|
+
|
|
26
|
+
A token already stored is returned with the value it holds — the
|
|
27
|
+
same stored value, though not the same string, since what is handed
|
|
28
|
+
out is masked per call — so asking twice answers two tokens that
|
|
29
|
+
both check out.
|
|
30
|
+
"""
|
|
31
|
+
...
|
|
32
|
+
|
|
33
|
+
async def refresh_token(self, token_id: str) -> CsrfToken:
|
|
34
|
+
"""Mint a fresh value for ``token_id``, stored or not.
|
|
35
|
+
|
|
36
|
+
For once-only tokens, in places with high security needs: whatever
|
|
37
|
+
was stored under the id is replaced, independent of whether a value
|
|
38
|
+
existed before.
|
|
39
|
+
"""
|
|
40
|
+
...
|
|
41
|
+
|
|
42
|
+
async def remove_token(self, token_id: str) -> str | None:
|
|
43
|
+
"""Drop the token under ``token_id``, if one is stored.
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
The value the id held, or ``None`` when it held nothing.
|
|
47
|
+
"""
|
|
48
|
+
...
|
|
49
|
+
|
|
50
|
+
async def is_token_valid(self, token: CsrfToken) -> bool:
|
|
51
|
+
"""Tell whether ``token`` carries the value stored under its id.
|
|
52
|
+
|
|
53
|
+
Never raises on the value it is handed: one that does not read as
|
|
54
|
+
a value this manager minted simply does not check out.
|
|
55
|
+
"""
|
|
56
|
+
...
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""Every error this library raises.
|
|
2
|
+
|
|
3
|
+
All of them derive from :class:`CsrfError`, so one ``except`` catches
|
|
4
|
+
anything minting or checking a token can go wrong with, and a narrower one
|
|
5
|
+
handles a single cause. Each carries the data a caller needs as typed
|
|
6
|
+
attributes rather than forcing a message to be parsed.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
from .csrf_error import CsrfError
|
|
12
|
+
from .invalid_argument_error import InvalidArgumentError
|
|
13
|
+
from .token_not_found_error import TokenNotFoundError
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"CsrfError",
|
|
17
|
+
"InvalidArgumentError",
|
|
18
|
+
"TokenNotFoundError",
|
|
19
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""The root every error in this library derives from."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
__all__ = ["CsrfError"]
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
class CsrfError(Exception):
|
|
9
|
+
"""Base class for every error raised by this library.
|
|
10
|
+
|
|
11
|
+
Catch this to handle anything minting or checking a cross-site request
|
|
12
|
+
forgery token can go wrong with; catch a subclass to handle one cause.
|
|
13
|
+
Every subclass carries the data a caller needs as typed attributes and
|
|
14
|
+
composes its own message from them.
|
|
15
|
+
"""
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
"""Something was declared or configured in a way this library cannot work with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .csrf_error import CsrfError
|
|
6
|
+
|
|
7
|
+
__all__ = ["InvalidArgumentError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class InvalidArgumentError(CsrfError, ValueError):
|
|
11
|
+
"""A declaration, configuration or call was malformed.
|
|
12
|
+
|
|
13
|
+
A generator asked for less entropy than a byte holds — mistakes caught
|
|
14
|
+
where they are made. Also a :class:`ValueError`, so code guarding its
|
|
15
|
+
configuration with ``except ValueError`` keeps working.
|
|
16
|
+
"""
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
"""A token was asked for under an id nothing is stored under."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .csrf_error import CsrfError
|
|
6
|
+
|
|
7
|
+
__all__ = ["TokenNotFoundError"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class TokenNotFoundError(CsrfError):
|
|
11
|
+
"""No token is stored under the id a caller asked to read.
|
|
12
|
+
|
|
13
|
+
The manager never raises it — where nothing is stored it mints — it is
|
|
14
|
+
the error a storage raises when its ``get_token`` is called directly for
|
|
15
|
+
an id that holds nothing.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
__slots__: tuple[str, ...] = ("_token_id",)
|
|
19
|
+
|
|
20
|
+
_token_id: str
|
|
21
|
+
|
|
22
|
+
def __init__(self, token_id: str) -> None:
|
|
23
|
+
"""Record which id was asked about and found empty.
|
|
24
|
+
|
|
25
|
+
Args:
|
|
26
|
+
token_id: The id whose stored value was asked for.
|
|
27
|
+
"""
|
|
28
|
+
super().__init__(f"The CSRF token with ID {token_id} does not exist.")
|
|
29
|
+
self._token_id = token_id
|
|
30
|
+
|
|
31
|
+
def get_token_id(self) -> str:
|
|
32
|
+
"""Return the id no token is stored under."""
|
|
33
|
+
return self._token_id
|
|
File without changes
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Generators that mint the values tokens answer with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .token_generator_interface import TokenGeneratorInterface
|
|
6
|
+
from .uri_safe_token_generator import UriSafeTokenGenerator
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"TokenGeneratorInterface",
|
|
10
|
+
"UriSafeTokenGenerator",
|
|
11
|
+
]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""The one surface every token generator answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
__all__ = ["TokenGeneratorInterface"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class TokenGeneratorInterface(Protocol):
|
|
12
|
+
"""Mints the value a token id is answered with.
|
|
13
|
+
|
|
14
|
+
Synchronous, and kept that way: minting is pure randomness, it reaches
|
|
15
|
+
nothing — the storage is what a caller waits on.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
def generate_token(self) -> str:
|
|
19
|
+
"""Return a fresh value, never seen before."""
|
|
20
|
+
...
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
"""A generator whose values carry no character a URI would have to encode."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import secrets
|
|
7
|
+
from typing import Final, final
|
|
8
|
+
|
|
9
|
+
from typing_extensions import override
|
|
10
|
+
|
|
11
|
+
from xtr_security_csrf.exception import InvalidArgumentError
|
|
12
|
+
|
|
13
|
+
from .token_generator_interface import TokenGeneratorInterface
|
|
14
|
+
|
|
15
|
+
__all__ = ["UriSafeTokenGenerator"]
|
|
16
|
+
|
|
17
|
+
# A value drawn from fewer than eight bits could be guessed.
|
|
18
|
+
_MINIMUM_ENTROPY: Final = 8
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@final
|
|
22
|
+
class UriSafeTokenGenerator(TokenGeneratorInterface):
|
|
23
|
+
"""Mints URI-safe values: base64 with ``+``, ``/`` and ``=`` gone.
|
|
24
|
+
|
|
25
|
+
A value that carries none of the characters a URI encodes sits in a
|
|
26
|
+
query, a form field or a header as it is, without a reader having to
|
|
27
|
+
decode anything first — which is why the padding is stripped too.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
__slots__ = ("_entropy",)
|
|
31
|
+
|
|
32
|
+
def __init__(self, entropy: int = 256) -> None:
|
|
33
|
+
"""Record how much entropy each minted value carries.
|
|
34
|
+
|
|
35
|
+
Args:
|
|
36
|
+
entropy: The bits of randomness each value is drawn from.
|
|
37
|
+
|
|
38
|
+
Raises:
|
|
39
|
+
InvalidArgumentError: When ``entropy`` is 7 bits or fewer — a
|
|
40
|
+
value that short could be guessed.
|
|
41
|
+
"""
|
|
42
|
+
if entropy < _MINIMUM_ENTROPY:
|
|
43
|
+
raise InvalidArgumentError("The entropy should be greater than 7.")
|
|
44
|
+
self._entropy = entropy
|
|
45
|
+
|
|
46
|
+
@override
|
|
47
|
+
def generate_token(self) -> str:
|
|
48
|
+
"""Return a fresh URI-safe value of the configured entropy."""
|
|
49
|
+
raw = secrets.token_bytes(self._entropy // 8)
|
|
50
|
+
return base64.urlsafe_b64encode(raw).rstrip(b"=").decode()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Storages that keep the values tokens answer with."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from .clearable_token_storage_interface import ClearableTokenStorageInterface
|
|
6
|
+
from .session_token_storage import SessionTokenStorage
|
|
7
|
+
from .token_storage_interface import TokenStorageInterface
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"ClearableTokenStorageInterface",
|
|
11
|
+
"SessionTokenStorage",
|
|
12
|
+
"TokenStorageInterface",
|
|
13
|
+
]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""A token storage that can drop every token it holds at once."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
from .token_storage_interface import TokenStorageInterface
|
|
8
|
+
|
|
9
|
+
__all__ = ["ClearableTokenStorageInterface"]
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
@runtime_checkable
|
|
13
|
+
class ClearableTokenStorageInterface(TokenStorageInterface, Protocol):
|
|
14
|
+
"""A token storage that can also drop everything it holds.
|
|
15
|
+
|
|
16
|
+
The session strategy is the caller: when a re-authentication rotates
|
|
17
|
+
the session, the tokens minted under the old one must not answer for
|
|
18
|
+
the new — a value stolen before the rotation would otherwise still
|
|
19
|
+
check out after it.
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
async def clear(self) -> None:
|
|
23
|
+
"""Drop every token this storage holds."""
|
|
24
|
+
...
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""A token storage that keeps its tokens in the session a request carries."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import TYPE_CHECKING, cast, final
|
|
6
|
+
|
|
7
|
+
from typing_extensions import override
|
|
8
|
+
|
|
9
|
+
from xtr_security_csrf.exception import TokenNotFoundError
|
|
10
|
+
|
|
11
|
+
from .clearable_token_storage_interface import ClearableTokenStorageInterface
|
|
12
|
+
|
|
13
|
+
if TYPE_CHECKING:
|
|
14
|
+
from xtr_http_foundation import SessionInterface
|
|
15
|
+
|
|
16
|
+
__all__ = ["SessionTokenStorage"]
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@final
|
|
20
|
+
class SessionTokenStorage(ClearableTokenStorageInterface):
|
|
21
|
+
"""Keeps token values in the session, under the id they answer for.
|
|
22
|
+
|
|
23
|
+
Each value lives under ``<namespace>/<token_id>``: the namespace keeps
|
|
24
|
+
the tokens out of the attributes the rest of the application keeps in
|
|
25
|
+
the same session, and lets two applications sharing one session keep
|
|
26
|
+
their tokens apart.
|
|
27
|
+
|
|
28
|
+
The storage also keeps the list of its ids under the namespace key
|
|
29
|
+
itself. The session offers no way to walk the attributes it carries, so
|
|
30
|
+
the list is what ``clear()`` drops them from — every token this storage
|
|
31
|
+
minted, and nothing the rest of the session holds. A value written
|
|
32
|
+
under the namespace by something else than this storage is not in the
|
|
33
|
+
list, and survives a clear.
|
|
34
|
+
"""
|
|
35
|
+
|
|
36
|
+
__slots__ = ("_namespace", "_session")
|
|
37
|
+
|
|
38
|
+
def __init__(self, session: SessionInterface, namespace: str = "_csrf") -> None:
|
|
39
|
+
"""Record the session the tokens travel in, and their namespace.
|
|
40
|
+
|
|
41
|
+
Args:
|
|
42
|
+
session: The session whose attributes carry the tokens.
|
|
43
|
+
namespace: The prefix every stored id carries.
|
|
44
|
+
"""
|
|
45
|
+
self._session = session
|
|
46
|
+
self._namespace = namespace
|
|
47
|
+
|
|
48
|
+
@override
|
|
49
|
+
async def get_token(self, token_id: str) -> str:
|
|
50
|
+
"""Return the value stored under ``token_id``.
|
|
51
|
+
|
|
52
|
+
Raises:
|
|
53
|
+
TokenNotFoundError: When no value is stored under the id.
|
|
54
|
+
"""
|
|
55
|
+
name = self._key(token_id)
|
|
56
|
+
if not await self._session.has(name):
|
|
57
|
+
raise TokenNotFoundError(token_id)
|
|
58
|
+
return str(await self._session.get(name))
|
|
59
|
+
|
|
60
|
+
@override
|
|
61
|
+
async def set_token(self, token_id: str, token: str) -> None:
|
|
62
|
+
"""Store ``token`` under ``token_id``, and remember the id."""
|
|
63
|
+
await self._session.set(self._key(token_id), token)
|
|
64
|
+
await self._remember(token_id)
|
|
65
|
+
|
|
66
|
+
@override
|
|
67
|
+
async def has_token(self, token_id: str) -> bool:
|
|
68
|
+
"""Tell whether a value is stored under ``token_id``."""
|
|
69
|
+
return await self._session.has(self._key(token_id))
|
|
70
|
+
|
|
71
|
+
@override
|
|
72
|
+
async def remove_token(self, token_id: str) -> str | None:
|
|
73
|
+
"""Drop the value under ``token_id``, returning what it held."""
|
|
74
|
+
value = await self._session.remove(self._key(token_id))
|
|
75
|
+
await self._forget(token_id)
|
|
76
|
+
return None if value is None else str(value)
|
|
77
|
+
|
|
78
|
+
@override
|
|
79
|
+
async def clear(self) -> None:
|
|
80
|
+
"""Drop every token this storage minted, and nothing else.
|
|
81
|
+
|
|
82
|
+
The ids come from the list kept under the namespace key, so the
|
|
83
|
+
clear is complete across the requests that minted them — a token
|
|
84
|
+
stored two requests ago goes with one stored now. The session
|
|
85
|
+
strategy calls this when a re-authentication rotates the session:
|
|
86
|
+
the tokens minted under the old one must not answer for the new.
|
|
87
|
+
"""
|
|
88
|
+
for token_id in await self._stored_ids():
|
|
89
|
+
_ = await self._session.remove(self._key(token_id))
|
|
90
|
+
_ = await self._session.remove(self._namespace)
|
|
91
|
+
|
|
92
|
+
def _key(self, token_id: str) -> str:
|
|
93
|
+
"""Return the name the value for ``token_id`` is stored under."""
|
|
94
|
+
return f"{self._namespace}/{token_id}"
|
|
95
|
+
|
|
96
|
+
async def _stored_ids(self) -> tuple[str, ...]:
|
|
97
|
+
"""Return every id this storage has minted a token under."""
|
|
98
|
+
ids = await self._session.get(self._namespace)
|
|
99
|
+
if not isinstance(ids, list):
|
|
100
|
+
return ()
|
|
101
|
+
# The list is written by this class, of the ids it minted; the cast
|
|
102
|
+
# says so once rather than at every element the loop reads.
|
|
103
|
+
return tuple(cast("list[str]", ids))
|
|
104
|
+
|
|
105
|
+
async def _remember(self, token_id: str) -> None:
|
|
106
|
+
"""Add ``token_id`` to the ids a ``clear()`` drops, if it is not there."""
|
|
107
|
+
ids = await self._stored_ids()
|
|
108
|
+
if token_id not in ids:
|
|
109
|
+
await self._session.set(self._namespace, [*ids, token_id])
|
|
110
|
+
|
|
111
|
+
async def _forget(self, token_id: str) -> None:
|
|
112
|
+
"""Take ``token_id`` out of the ids a ``clear()`` drops."""
|
|
113
|
+
ids = await self._stored_ids()
|
|
114
|
+
if token_id not in ids:
|
|
115
|
+
return
|
|
116
|
+
remaining = [stored for stored in ids if stored != token_id]
|
|
117
|
+
if remaining:
|
|
118
|
+
await self._session.set(self._namespace, remaining)
|
|
119
|
+
else:
|
|
120
|
+
_ = await self._session.remove(self._namespace)
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The one surface every token storage answers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from typing import Protocol, runtime_checkable
|
|
6
|
+
|
|
7
|
+
__all__ = ["TokenStorageInterface"]
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@runtime_checkable
|
|
11
|
+
class TokenStorageInterface(Protocol):
|
|
12
|
+
"""Keeps token values by id, wherever the application points them.
|
|
13
|
+
|
|
14
|
+
Every member is awaited: one storage must be swappable for another, and
|
|
15
|
+
a remote one is awaited — the session is only the one this library
|
|
16
|
+
ships a storage for.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
async def get_token(self, token_id: str) -> str:
|
|
20
|
+
"""Return the value stored under ``token_id``.
|
|
21
|
+
|
|
22
|
+
Raises:
|
|
23
|
+
TokenNotFoundError: When no value is stored under the id.
|
|
24
|
+
"""
|
|
25
|
+
...
|
|
26
|
+
|
|
27
|
+
async def set_token(self, token_id: str, token: str) -> None:
|
|
28
|
+
"""Store ``token`` under ``token_id``, replacing what was there."""
|
|
29
|
+
...
|
|
30
|
+
|
|
31
|
+
async def remove_token(self, token_id: str) -> str | None:
|
|
32
|
+
"""Drop the value under ``token_id``.
|
|
33
|
+
|
|
34
|
+
Returns:
|
|
35
|
+
The value it held, or ``None`` when it held nothing.
|
|
36
|
+
"""
|
|
37
|
+
...
|
|
38
|
+
|
|
39
|
+
async def has_token(self, token_id: str) -> bool:
|
|
40
|
+
"""Tell whether a value is stored under ``token_id``."""
|
|
41
|
+
...
|
|
@@ -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,20 @@
|
|
|
1
|
+
xtr_security_csrf/__init__.py,sha256=YSIqDdt3kdDIUd9MI9iu_Hsl5cnOs-y-p-gLZHHfxH8,1486
|
|
2
|
+
xtr_security_csrf/csrf_token.py,sha256=q7bnHKIRtBjzowFCxdycudwPxxPRYrEHggx-qbHjrbA,1422
|
|
3
|
+
xtr_security_csrf/csrf_token_manager.py,sha256=RFAfpfG6djy74s0ZqNrx345YVE5HTw5BYHotgibgcTo,7244
|
|
4
|
+
xtr_security_csrf/csrf_token_manager_interface.py,sha256=we3cx-pWakwwhiFPHr4cqWBmAyZ8ZsecLv2vWOFQ--U,1907
|
|
5
|
+
xtr_security_csrf/exception/__init__.py,sha256=gEPvUT7lVok3qDCw7s5uSxYXD261-FBIKWWk5lALp-I,579
|
|
6
|
+
xtr_security_csrf/exception/csrf_error.py,sha256=4ETPOn481Z90UPNJNd9lzSYGHx37qdDL8NLEfPmSr8s,480
|
|
7
|
+
xtr_security_csrf/exception/invalid_argument_error.py,sha256=0mAu-l5wIwg-r8bJmQbfe9ivMHAJP6Chk_h2sT_sHtA,522
|
|
8
|
+
xtr_security_csrf/exception/token_not_found_error.py,sha256=IuIwBMHVqQCN2ymOMJwH6CNJxAaL0al_oSZa5dAnT8E,967
|
|
9
|
+
xtr_security_csrf/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
10
|
+
xtr_security_csrf/token_generator/__init__.py,sha256=BFC4ueg-ZftFKdFdtcGGqNdlD9eEQTHIlLQ6GWQ7AHA,293
|
|
11
|
+
xtr_security_csrf/token_generator/token_generator_interface.py,sha256=Cmdnbx-4PpbNYTxYVVwEKxiuh1ziOWiakERC2wOiQ8U,534
|
|
12
|
+
xtr_security_csrf/token_generator/uri_safe_token_generator.py,sha256=0DFT21UOyTatpNMJp_3KFdHXZ6TJVX97PvgYBUhsg9w,1611
|
|
13
|
+
xtr_security_csrf/token_storage/__init__.py,sha256=DRCYy0_yo91LmAnpM9G3bDtUc9R1WE_fG5aDP7akdr0,394
|
|
14
|
+
xtr_security_csrf/token_storage/clearable_token_storage_interface.py,sha256=SA1w7m9I0A-6Wsw9COTq34vZ52rptwvON7CX9CPt-_4,762
|
|
15
|
+
xtr_security_csrf/token_storage/session_token_storage.py,sha256=Skojw3BAR0QOtQVApszaoZvw5QPrnu1bR1SswN45P5I,4794
|
|
16
|
+
xtr_security_csrf/token_storage/token_storage_interface.py,sha256=rSzWMPJa9Gqg4WiPtR8EjlDCuihWIBk3JoIJxEykH5w,1210
|
|
17
|
+
xtr_security_csrf-5.0.0.dist-info/licenses/LICENSE,sha256=N6mW05zc_GYFRdNovzY4zFuWyBKavZLcouPmDOBHK5o,1106
|
|
18
|
+
xtr_security_csrf-5.0.0.dist-info/WHEEL,sha256=3NO1oYsF7mANFUWVdyEQLM-zmecczWWEmmF5rEbXrWk,80
|
|
19
|
+
xtr_security_csrf-5.0.0.dist-info/METADATA,sha256=5pmMy_ktVCNnx7qc8pq99REuqJ38UfuvRPvFk-aWJjc,9044
|
|
20
|
+
xtr_security_csrf-5.0.0.dist-info/RECORD,,
|
|
@@ -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.
|