cherrypy-foundation 1.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.
- cherrypy_foundation/__init__.py +0 -0
- cherrypy_foundation/components/ColorModes.jinja +70 -0
- cherrypy_foundation/components/Datatable.css +47 -0
- cherrypy_foundation/components/Datatable.jinja +63 -0
- cherrypy_foundation/components/Datatable.js +358 -0
- cherrypy_foundation/components/Field.css +10 -0
- cherrypy_foundation/components/Field.jinja +66 -0
- cherrypy_foundation/components/Field.js +56 -0
- cherrypy_foundation/components/Fields.jinja +4 -0
- cherrypy_foundation/components/Flash.jinja +13 -0
- cherrypy_foundation/components/Icon.jinja +3 -0
- cherrypy_foundation/components/LocaleSelection.jinja +13 -0
- cherrypy_foundation/components/LocaleSelection.js +26 -0
- cherrypy_foundation/components/SideBySideMultiSelect.css +25 -0
- cherrypy_foundation/components/SideBySideMultiSelect.jinja +9 -0
- cherrypy_foundation/components/SideBySideMultiSelect.js +9 -0
- cherrypy_foundation/components/Typeahead.css +55 -0
- cherrypy_foundation/components/Typeahead.jinja +106 -0
- cherrypy_foundation/components/Typeahead.js +8 -0
- cherrypy_foundation/components/__init__.py +51 -0
- cherrypy_foundation/components/tests/__init__.py +0 -0
- cherrypy_foundation/components/tests/test_static.py +90 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.css +2106 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/bootstrap-icons.min.css +5 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff +0 -0
- cherrypy_foundation/components/vendor/bootstrap-icons/fonts/bootstrap-icons.woff2 +0 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css +9262 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.css.map +95 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css +6 -0
- cherrypy_foundation/components/vendor/bootstrap5/css/bootstrap.min.css.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js +4846 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.js.map +1 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/bootstrap.min.js.map +7 -0
- cherrypy_foundation/components/vendor/bootstrap5/js/color-modes.js +80 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.css +849 -0
- cherrypy_foundation/components/vendor/datatables/css/dataTables.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_asc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_both.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc.png +0 -0
- cherrypy_foundation/components/vendor/datatables/images/sort_desc_disabled.png +0 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.js +14073 -0
- cherrypy_foundation/components/vendor/datatables/js/dataTables.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.css +556 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/css/buttons.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.js +1700 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/buttons.html5.min.js +8 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.js +2944 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Buttons/js/dataTables.buttons.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.css +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/css/fixedHeader.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.js +1202 -0
- cherrypy_foundation/components/vendor/datatables-extensions/FixedHeader/js/dataTables.fixedHeader.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.js +11577 -0
- cherrypy_foundation/components/vendor/datatables-extensions/JSZip/jszip.min.js +13 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.css +194 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/css/responsive.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.js +1861 -0
- cherrypy_foundation/components/vendor/datatables-extensions/Responsive/js/dataTables.responsive.min.js +4 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.js +75023 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/pdfmake.min.js +3 -0
- cherrypy_foundation/components/vendor/datatables-extensions/pdfmake/build/vfs_fonts.js +6 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.css +53 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/css/rowGroup.dataTables.min.css +1 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.js +485 -0
- cherrypy_foundation/components/vendor/datatables-extensions/rowgroup/js/dataTables.rowGroup.min.js +4 -0
- cherrypy_foundation/components/vendor/jquery/jquery.min.js +2 -0
- cherrypy_foundation/components/vendor/multi/LICENSE +7 -0
- cherrypy_foundation/components/vendor/multi/README.md +109 -0
- cherrypy_foundation/components/vendor/multi/multi.css +95 -0
- cherrypy_foundation/components/vendor/multi/multi.js +328 -0
- cherrypy_foundation/components/vendor/popper/popper.js +1825 -0
- cherrypy_foundation/components/vendor/popper/popper.min.js +6 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.css +1 -0
- cherrypy_foundation/components/vendor/typeahead/jquery.typeahead.min.js +10 -0
- cherrypy_foundation/error_page.py +94 -0
- cherrypy_foundation/flash.py +50 -0
- cherrypy_foundation/form.py +119 -0
- cherrypy_foundation/logging.py +103 -0
- cherrypy_foundation/passwd.py +65 -0
- cherrypy_foundation/plugins/__init__.py +0 -0
- cherrypy_foundation/plugins/db.py +286 -0
- cherrypy_foundation/plugins/ldap.py +257 -0
- cherrypy_foundation/plugins/restapi.py +74 -0
- cherrypy_foundation/plugins/scheduler.py +287 -0
- cherrypy_foundation/plugins/smtp.py +223 -0
- cherrypy_foundation/plugins/tests/__init__.py +0 -0
- cherrypy_foundation/plugins/tests/test_db.py +118 -0
- cherrypy_foundation/plugins/tests/test_ldap.py +451 -0
- cherrypy_foundation/plugins/tests/test_scheduler.py +100 -0
- cherrypy_foundation/plugins/tests/test_scheduler_db.py +107 -0
- cherrypy_foundation/plugins/tests/test_smtp.py +140 -0
- cherrypy_foundation/sessions.py +93 -0
- cherrypy_foundation/tests/__init__.py +72 -0
- cherrypy_foundation/tests/templates/test_flash.html +9 -0
- cherrypy_foundation/tests/templates/test_form.html +16 -0
- cherrypy_foundation/tests/templates/test_url.html +15 -0
- cherrypy_foundation/tests/test_error_page.py +78 -0
- cherrypy_foundation/tests/test_flash.py +61 -0
- cherrypy_foundation/tests/test_form.py +148 -0
- cherrypy_foundation/tests/test_logging.py +78 -0
- cherrypy_foundation/tests/test_passwd.py +51 -0
- cherrypy_foundation/tests/test_sessions.py +89 -0
- cherrypy_foundation/tests/test_url.py +161 -0
- cherrypy_foundation/tools/__init__.py +0 -0
- cherrypy_foundation/tools/auth.py +263 -0
- cherrypy_foundation/tools/auth_mfa.py +249 -0
- cherrypy_foundation/tools/i18n.py +529 -0
- cherrypy_foundation/tools/jinja2.py +158 -0
- cherrypy_foundation/tools/ratelimit.py +265 -0
- cherrypy_foundation/tools/secure_headers.py +119 -0
- cherrypy_foundation/tools/sessions_timeout.py +167 -0
- cherrypy_foundation/tools/tests/__init__.py +0 -0
- cherrypy_foundation/tools/tests/components/Button.jinja +2 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/de/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.mo +0 -0
- cherrypy_foundation/tools/tests/locales/fr/LC_MESSAGES/messages.po +15 -0
- cherrypy_foundation/tools/tests/locales/messages.pot +2 -0
- cherrypy_foundation/tools/tests/templates/test_jinja2.html +11 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax.html +9 -0
- cherrypy_foundation/tools/tests/templates/test_jinjax_i18n.html +22 -0
- cherrypy_foundation/tools/tests/test_auth.py +110 -0
- cherrypy_foundation/tools/tests/test_auth_mfa.py +369 -0
- cherrypy_foundation/tools/tests/test_i18n.py +247 -0
- cherrypy_foundation/tools/tests/test_jinja2.py +153 -0
- cherrypy_foundation/tools/tests/test_ratelimit.py +109 -0
- cherrypy_foundation/tools/tests/test_secure_headers.py +200 -0
- cherrypy_foundation/url.py +66 -0
- cherrypy_foundation/widgets.py +48 -0
- cherrypy_foundation-1.0.0.dist-info/METADATA +71 -0
- cherrypy_foundation-1.0.0.dist-info/RECORD +136 -0
- cherrypy_foundation-1.0.0.dist-info/WHEEL +5 -0
- cherrypy_foundation-1.0.0.dist-info/licenses/LICENSE.md +674 -0
- cherrypy_foundation-1.0.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
# Ratelimit tools for cherrypy
|
|
2
|
+
# Copyright (C) 2022-2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
import hashlib
|
|
17
|
+
import os
|
|
18
|
+
import tempfile
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
from collections import namedtuple
|
|
22
|
+
|
|
23
|
+
import cherrypy
|
|
24
|
+
|
|
25
|
+
from cherrypy_foundation.sessions import session_lock
|
|
26
|
+
|
|
27
|
+
Tracker = namedtuple('Tracker', ['token', 'hits', 'timeout'])
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class _DataStore:
|
|
31
|
+
"""
|
|
32
|
+
Base class for rate limit data store
|
|
33
|
+
"""
|
|
34
|
+
|
|
35
|
+
def __init__(self, **kwargs):
|
|
36
|
+
self._locks = {}
|
|
37
|
+
|
|
38
|
+
def get_and_increment(self, token, delay, hit=1):
|
|
39
|
+
lock = self._locks.setdefault(token, threading.RLock())
|
|
40
|
+
with lock:
|
|
41
|
+
tracker = self._load(token)
|
|
42
|
+
if tracker is None or tracker.timeout < time.time():
|
|
43
|
+
tracker = Tracker(token=token, hits=0, timeout=int(time.time() + delay))
|
|
44
|
+
tracker = tracker._replace(hits=tracker.hits + hit)
|
|
45
|
+
self._save(tracker)
|
|
46
|
+
return tracker.hits, tracker.timeout
|
|
47
|
+
|
|
48
|
+
def _save(self, tracker):
|
|
49
|
+
raise NotImplementedError
|
|
50
|
+
|
|
51
|
+
def _load(self, token):
|
|
52
|
+
raise NotImplementedError
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class RamRateLimit(_DataStore):
|
|
56
|
+
"""
|
|
57
|
+
Store rate limit information in memory.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def __init__(self, **kwargs):
|
|
61
|
+
super().__init__(**kwargs)
|
|
62
|
+
self._data = {}
|
|
63
|
+
|
|
64
|
+
def _load(self, token):
|
|
65
|
+
return self._data.get(token)
|
|
66
|
+
|
|
67
|
+
def _save(self, tracker):
|
|
68
|
+
self._data[tracker.token] = tracker
|
|
69
|
+
|
|
70
|
+
def reset(self):
|
|
71
|
+
self._data = {}
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
class FileRateLimit(_DataStore):
|
|
75
|
+
PREFIX = 'ratelimit-'
|
|
76
|
+
|
|
77
|
+
def __init__(self, storage_path, **kwargs):
|
|
78
|
+
super().__init__(**kwargs)
|
|
79
|
+
assert storage_path, 'FileRateLimit requires storage_path'
|
|
80
|
+
self.storage_path = os.path.abspath(storage_path)
|
|
81
|
+
os.makedirs(self.storage_path, exist_ok=True)
|
|
82
|
+
|
|
83
|
+
def _path(self, token):
|
|
84
|
+
# Hash the token to avoid max path limit and invalid chars.
|
|
85
|
+
digest = hashlib.sha256(token.encode('utf-8')).hexdigest()
|
|
86
|
+
return os.path.join(self.storage_path, self.PREFIX + digest + '.txt')
|
|
87
|
+
|
|
88
|
+
def _load(self, token):
|
|
89
|
+
path = self._path(token)
|
|
90
|
+
try:
|
|
91
|
+
with open(path, 'r', encoding='utf-8') as f:
|
|
92
|
+
line = f.readline().strip()
|
|
93
|
+
except FileNotFoundError:
|
|
94
|
+
return None
|
|
95
|
+
except OSError:
|
|
96
|
+
return None
|
|
97
|
+
|
|
98
|
+
if not line:
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
parts = line.split()
|
|
102
|
+
if len(parts) != 2:
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
hits = int(parts[0])
|
|
107
|
+
timeout = int(parts[1])
|
|
108
|
+
if hits < 0 or timeout < 0:
|
|
109
|
+
return None
|
|
110
|
+
except ValueError:
|
|
111
|
+
return None
|
|
112
|
+
|
|
113
|
+
return Tracker(token=token, hits=hits, timeout=timeout)
|
|
114
|
+
|
|
115
|
+
def _save(self, tracker):
|
|
116
|
+
path = self._path(tracker.token)
|
|
117
|
+
# Atomic write: temp file in same directory
|
|
118
|
+
fd, tmp = tempfile.mkstemp(dir=self.storage_path)
|
|
119
|
+
try:
|
|
120
|
+
with os.fdopen(fd, 'w', encoding='utf-8') as f:
|
|
121
|
+
f.write(f"{tracker.hits} {int(tracker.timeout)}\n")
|
|
122
|
+
f.flush()
|
|
123
|
+
os.fsync(f.fileno())
|
|
124
|
+
os.replace(tmp, path)
|
|
125
|
+
# On POSIX, chmod on first write
|
|
126
|
+
try:
|
|
127
|
+
os.chmod(path, 0o600)
|
|
128
|
+
except OSError:
|
|
129
|
+
pass
|
|
130
|
+
finally:
|
|
131
|
+
try:
|
|
132
|
+
if os.path.exists(tmp):
|
|
133
|
+
os.remove(tmp)
|
|
134
|
+
except Exception:
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
def reset(self):
|
|
138
|
+
for item in os.listdir(self.storage_path):
|
|
139
|
+
if not item.startswith(self.PREFIX):
|
|
140
|
+
continue
|
|
141
|
+
fn = os.path.join(self.storage_path, item)
|
|
142
|
+
if not os.path.isfile(fn):
|
|
143
|
+
continue
|
|
144
|
+
try:
|
|
145
|
+
os.remove(fn)
|
|
146
|
+
except OSError:
|
|
147
|
+
pass
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
class Ratelimit(cherrypy.Tool):
|
|
151
|
+
CONTEXT = 'TOOLS.RATELIMIT'
|
|
152
|
+
|
|
153
|
+
_datastores = {}
|
|
154
|
+
|
|
155
|
+
def __init__(self, priority=60):
|
|
156
|
+
super().__init__('before_handler', self.check_ratelimit, 'ratelimit', priority)
|
|
157
|
+
|
|
158
|
+
def _get_datastore(self, storage_class, **kwargs):
|
|
159
|
+
"""
|
|
160
|
+
Create new datastore or return existing datastore.
|
|
161
|
+
"""
|
|
162
|
+
# Default to RAM if storage class is not defined.
|
|
163
|
+
if storage_class is None:
|
|
164
|
+
kwargs = {}
|
|
165
|
+
storage_class = RamRateLimit
|
|
166
|
+
# Lookup for matching storage.
|
|
167
|
+
key = (storage_class, str(kwargs))
|
|
168
|
+
datastore = self._datastores.get(key)
|
|
169
|
+
if datastore is None:
|
|
170
|
+
# Create new storage if not found.
|
|
171
|
+
self._datastores[key] = datastore = storage_class(**kwargs)
|
|
172
|
+
return datastore
|
|
173
|
+
|
|
174
|
+
def check_ratelimit(
|
|
175
|
+
self,
|
|
176
|
+
delay=3600,
|
|
177
|
+
limit=25,
|
|
178
|
+
return_status=429,
|
|
179
|
+
logout=False,
|
|
180
|
+
scope=None,
|
|
181
|
+
methods=None,
|
|
182
|
+
debug=False,
|
|
183
|
+
hit=1,
|
|
184
|
+
storage_class=None,
|
|
185
|
+
**storage_kwargs,
|
|
186
|
+
):
|
|
187
|
+
"""
|
|
188
|
+
Verify the ratelimit. By default return a 429 HTTP error code (Too Many Request). After 25 request within the same hour.
|
|
189
|
+
|
|
190
|
+
Arguments:
|
|
191
|
+
delay: Time window for analysis in seconds
|
|
192
|
+
limit: Number of request allowed for an entry point
|
|
193
|
+
return_status: HTTP Error code to return.
|
|
194
|
+
logout: True to logout user when limit is reached
|
|
195
|
+
scope: if specify, define the scope of rate limit. Default to path_info.
|
|
196
|
+
methods: if specify, only the methods in the list will be rate limited.
|
|
197
|
+
"""
|
|
198
|
+
if delay <= 0:
|
|
199
|
+
raise ValueError('Invalid delay: %s' % delay)
|
|
200
|
+
if limit <= 0:
|
|
201
|
+
return
|
|
202
|
+
|
|
203
|
+
# Check if this 'method' should be rate limited
|
|
204
|
+
request = cherrypy.request
|
|
205
|
+
if methods is not None and request.method not in methods:
|
|
206
|
+
if debug:
|
|
207
|
+
cherrypy.log(f'skip rate limit for HTTP method {request.method}', context=self.CONTEXT)
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
# Create storage using storage class
|
|
211
|
+
datastore = self._get_datastore(storage_class, **storage_kwargs)
|
|
212
|
+
|
|
213
|
+
# Identifier: prefer authenticated user; else client IP
|
|
214
|
+
identifier = getattr(cherrypy.serving.request, 'login', None) or cherrypy.request.remote.ip
|
|
215
|
+
|
|
216
|
+
# Include method unless methods is explicitly a single method
|
|
217
|
+
method_part = request.method
|
|
218
|
+
if methods and len(methods) == 1:
|
|
219
|
+
method_part = next(iter(methods))
|
|
220
|
+
|
|
221
|
+
# Scope: allow explicit; else normalized path (e.g., strip trailing slash)
|
|
222
|
+
path = request.path_info.rstrip('/') or '/'
|
|
223
|
+
scope_value = scope or path
|
|
224
|
+
|
|
225
|
+
token = f"{identifier}|{method_part}|{scope_value}"
|
|
226
|
+
|
|
227
|
+
# Get hits count using datastore.
|
|
228
|
+
hits, timeout = datastore.get_and_increment(token, delay, hit)
|
|
229
|
+
if debug:
|
|
230
|
+
cherrypy.log(f'check and increase limit token={token} limit={limit} hits={hits}', context=self.CONTEXT)
|
|
231
|
+
|
|
232
|
+
# Verify user has not exceeded rate limit
|
|
233
|
+
remaining = max(0, limit - hits)
|
|
234
|
+
|
|
235
|
+
# Define headers
|
|
236
|
+
cherrypy.response.headers['X-RateLimit-Limit'] = str(limit)
|
|
237
|
+
cherrypy.response.headers['X-RateLimit-Remaining'] = str(remaining)
|
|
238
|
+
cherrypy.response.headers['X-RateLimit-Reset'] = str(timeout)
|
|
239
|
+
|
|
240
|
+
if limit < hits: # block only after 'limit' successful requests
|
|
241
|
+
cherrypy.log(f'block access to path_info={request.path_info}', context=self.CONTEXT)
|
|
242
|
+
if logout:
|
|
243
|
+
if hasattr(cherrypy.serving, 'session'):
|
|
244
|
+
with session_lock() as session:
|
|
245
|
+
session.clear()
|
|
246
|
+
raise cherrypy.HTTPRedirect("/")
|
|
247
|
+
raise cherrypy.HTTPError(return_status)
|
|
248
|
+
|
|
249
|
+
def increase_hit(self, hit=1):
|
|
250
|
+
"""
|
|
251
|
+
May be called directly by handlers to add a hit for the given request.
|
|
252
|
+
"""
|
|
253
|
+
conf = cherrypy.tools.ratelimit._merged_args()
|
|
254
|
+
conf['hit'] = hit
|
|
255
|
+
cherrypy.tools.ratelimit.callable(**conf)
|
|
256
|
+
|
|
257
|
+
def reset(self):
|
|
258
|
+
"""
|
|
259
|
+
Used to reset the ratelimit. (for unit test)
|
|
260
|
+
"""
|
|
261
|
+
for datastore in self._datastores.values():
|
|
262
|
+
datastore.reset()
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
cherrypy.tools.ratelimit = Ratelimit()
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# Secure Headers tool for cherrypy
|
|
2
|
+
# Copyright (C) 2012-2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import http.cookies
|
|
18
|
+
|
|
19
|
+
import cherrypy
|
|
20
|
+
|
|
21
|
+
DEFAULT_CSP = {
|
|
22
|
+
'default-src': 'self',
|
|
23
|
+
'style-src': ('self', 'unsafe-inline'),
|
|
24
|
+
'script-src': ('self', 'unsafe-inline'),
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
#
|
|
28
|
+
# Patch Morsel prior to 3.8
|
|
29
|
+
# Allow SameSite attribute to be define on the cookie.
|
|
30
|
+
#
|
|
31
|
+
if not http.cookies.Morsel().isReservedKey("samesite"):
|
|
32
|
+
http.cookies.Morsel._reserved['samesite'] = 'SameSite'
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _build_csp(csp):
|
|
36
|
+
if isinstance(csp, dict):
|
|
37
|
+
return "; ".join(
|
|
38
|
+
f"{directive} {' '.join(sources) if isinstance(sources, (list, tuple)) else str(sources)}"
|
|
39
|
+
for directive, sources in csp.items()
|
|
40
|
+
)
|
|
41
|
+
return str(csp)
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def set_headers(
|
|
45
|
+
xfo='DENY',
|
|
46
|
+
no_cache=True,
|
|
47
|
+
referrer='same-origin',
|
|
48
|
+
nosniff=True,
|
|
49
|
+
xxp='1; mode=block',
|
|
50
|
+
csp=DEFAULT_CSP,
|
|
51
|
+
):
|
|
52
|
+
"""
|
|
53
|
+
This tool provide CSRF mitigation.
|
|
54
|
+
|
|
55
|
+
* Define X-Frame-Options = DENY
|
|
56
|
+
* Define Cookies SameSite=Lax
|
|
57
|
+
* Define Cookies Secure when https is detected
|
|
58
|
+
* Validate `Origin` and `Referer` on POST, PUT, PATCH, DELETE
|
|
59
|
+
* Define Cache-Control by default
|
|
60
|
+
* Define Referrer-Policy to 'same-origin'
|
|
61
|
+
|
|
62
|
+
Ref.:
|
|
63
|
+
https://cheatsheetseries.owasp.org/cheatsheets/Cross-Site_Request_Forgery_Prevention_Cheat_Sheet.html
|
|
64
|
+
https://cheatsheetseries.owasp.org/cheatsheets/Clickjacking_Defense_Cheat_Sheet.html
|
|
65
|
+
"""
|
|
66
|
+
request = cherrypy.request
|
|
67
|
+
response = cherrypy.serving.response
|
|
68
|
+
|
|
69
|
+
# Check if Origin matches our target.
|
|
70
|
+
if request.method in ['POST', 'PUT', 'PATCH', 'DELETE']:
|
|
71
|
+
origin = request.headers.get('Origin', None)
|
|
72
|
+
if origin and origin != request.base:
|
|
73
|
+
raise cherrypy.HTTPError(403, 'Unexpected Origin header')
|
|
74
|
+
|
|
75
|
+
# Check if https is enabled
|
|
76
|
+
https = request.base.startswith('https')
|
|
77
|
+
|
|
78
|
+
# Define X-Frame-Options to avoid Clickjacking
|
|
79
|
+
if xfo:
|
|
80
|
+
response.headers['X-Frame-Options'] = xfo
|
|
81
|
+
|
|
82
|
+
# Enforce security on cookies
|
|
83
|
+
cookie = response.cookie.get('session_id', None)
|
|
84
|
+
if cookie:
|
|
85
|
+
# Awaiting bug fix in cherrypy
|
|
86
|
+
# https://github.com/cherrypy/cherrypy/issues/1767
|
|
87
|
+
# Force SameSite to Lax
|
|
88
|
+
cookie['samesite'] = 'Lax'
|
|
89
|
+
if https:
|
|
90
|
+
cookie['secure'] = 1
|
|
91
|
+
|
|
92
|
+
# Add Cache-Control to avoid storing sensible information in Browser cache.
|
|
93
|
+
if no_cache:
|
|
94
|
+
response.headers['Cache-control'] = 'no-cache, no-store, must-revalidate, max-age=0'
|
|
95
|
+
response.headers['Pragma'] = 'no-cache'
|
|
96
|
+
response.headers['Expires'] = '0'
|
|
97
|
+
|
|
98
|
+
# Add Referrer-Policy
|
|
99
|
+
if referrer:
|
|
100
|
+
response.headers['Referrer-Policy'] = referrer
|
|
101
|
+
|
|
102
|
+
# Add X-Content-Type-Options to avoid browser to "sniff" to content-type
|
|
103
|
+
if nosniff:
|
|
104
|
+
response.headers['X-Content-Type-Options'] = 'nosniff'
|
|
105
|
+
|
|
106
|
+
# Add X-XSS-Protection to enabled XSS protection
|
|
107
|
+
if xxp:
|
|
108
|
+
response.headers['X-XSS-Protection'] = xxp
|
|
109
|
+
|
|
110
|
+
# Add Content-Security-Policy
|
|
111
|
+
if csp:
|
|
112
|
+
response.headers['Content-Security-Policy'] = _build_csp(csp)
|
|
113
|
+
|
|
114
|
+
# Add Strict-Transport-Security to force https use.
|
|
115
|
+
if https:
|
|
116
|
+
response.headers['Strict-Transport-Security'] = "max-age=31536000; includeSubDomains"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
cherrypy.tools.secure_headers = cherrypy.Tool('before_request_body', set_headers, priority=71)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# Session timeout tool for cherrypy
|
|
2
|
+
# Copyright (C) 2012-2026 IKUS Software
|
|
3
|
+
#
|
|
4
|
+
# This program is free software: you can redistribute it and/or modify
|
|
5
|
+
# it under the terms of the GNU General Public License as published by
|
|
6
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
7
|
+
# (at your option) any later version.
|
|
8
|
+
#
|
|
9
|
+
# This program is distributed in the hope that it will be useful,
|
|
10
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
11
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
12
|
+
# GNU General Public License for more details.
|
|
13
|
+
#
|
|
14
|
+
# You should have received a copy of the GNU General Public License
|
|
15
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
16
|
+
|
|
17
|
+
import datetime
|
|
18
|
+
import math
|
|
19
|
+
import time
|
|
20
|
+
|
|
21
|
+
import cherrypy
|
|
22
|
+
from cherrypy.lib import httputil
|
|
23
|
+
|
|
24
|
+
from cherrypy_foundation.sessions import session_lock
|
|
25
|
+
|
|
26
|
+
SESSION_PERSISTENT = '_session_persistent'
|
|
27
|
+
SESSION_START_TIME = '_session_start_time'
|
|
28
|
+
|
|
29
|
+
SESSION_DEFAULT_ABSOLUTE_TIMEOUT = 43200 # 30 days
|
|
30
|
+
SESSION_DEFAULT_PERSISTENT_TIMEOUT = 10080 # 7 days
|
|
31
|
+
SESSION_DEFAULT_TIMEOUT = 60 # 60 minutes (from cherrypy default)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class SessionsTimeout(cherrypy.Tool):
|
|
35
|
+
"""
|
|
36
|
+
Fine-grained control over session timeouts.
|
|
37
|
+
|
|
38
|
+
Config keys:
|
|
39
|
+
- tools.sessions.timeout (int, minutes) -> base idle timeout (CherryPy built-in, used for non-persistent)
|
|
40
|
+
- tools.sessions_timeout.absolute_timeout (int, minutes) -> hard cap for all sessions (default=43200 = 30d)
|
|
41
|
+
- tools.sessions_timeout.persistent_timeout (int, minutes) -> sliding window for persistent sessions (default=10080 = 7d)
|
|
42
|
+
- tools.sessions.name (str) -> session cookie name (default: 'session_id')
|
|
43
|
+
"""
|
|
44
|
+
|
|
45
|
+
def __init__(self, priority: int = 75):
|
|
46
|
+
super().__init__(point='before_handler', callable=self.run, priority=priority)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def _cookie_name() -> str:
|
|
50
|
+
return cherrypy.request.config.get('tools.sessions.name', 'session_id')
|
|
51
|
+
|
|
52
|
+
@staticmethod
|
|
53
|
+
def _get_config_timeouts() -> tuple[int, int, int]:
|
|
54
|
+
cfg = cherrypy.request.config
|
|
55
|
+
idle_timeout = int(cfg.get('tools.sessions.timeout', SESSION_DEFAULT_TIMEOUT))
|
|
56
|
+
persistent_timeout = int(
|
|
57
|
+
cfg.get('tools.sessions_timeout.persistent_timeout', SESSION_DEFAULT_PERSISTENT_TIMEOUT)
|
|
58
|
+
)
|
|
59
|
+
absolute_timeout = int(cfg.get('tools.sessions_timeout.absolute_timeout', SESSION_DEFAULT_ABSOLUTE_TIMEOUT))
|
|
60
|
+
return idle_timeout, persistent_timeout, absolute_timeout
|
|
61
|
+
|
|
62
|
+
def _set_cookie_max_age(self, session, max_age: int) -> None:
|
|
63
|
+
"""Adjust only Max-Age and Expires for the session cookie, keep other flags intact."""
|
|
64
|
+
cookie_name = self._cookie_name()
|
|
65
|
+
cookie = cherrypy.serving.response.cookie
|
|
66
|
+
# Ensure the cookie key exists (CherryPy sets it when session id is issued/regenerated).
|
|
67
|
+
if cookie_name not in cookie:
|
|
68
|
+
cookie[cookie_name] = session.id
|
|
69
|
+
cookie[cookie_name]['max-age'] = str(max(0, max_age))
|
|
70
|
+
cookie[cookie_name]['expires'] = httputil.HTTPDate(time.time() + max(0, max_age))
|
|
71
|
+
|
|
72
|
+
@staticmethod
|
|
73
|
+
def _ceil_minutes(seconds: int) -> int:
|
|
74
|
+
if seconds <= 0:
|
|
75
|
+
return 0
|
|
76
|
+
return int(math.ceil(seconds / 60.0))
|
|
77
|
+
|
|
78
|
+
def _ensure_start_time(self, session) -> None:
|
|
79
|
+
"""Ensure the session has a stable start time anchor for absolute timeout."""
|
|
80
|
+
if SESSION_START_TIME not in session or not isinstance(session.get(SESSION_START_TIME), datetime.datetime):
|
|
81
|
+
session[SESSION_START_TIME] = session.now()
|
|
82
|
+
|
|
83
|
+
def _expire_and_restart(self, session, make_persistent: bool | None = None) -> None:
|
|
84
|
+
"""Expire current session and start a fresh one. Optionally set persistent flag."""
|
|
85
|
+
session.clear()
|
|
86
|
+
session.regenerate()
|
|
87
|
+
session[SESSION_START_TIME] = session.now()
|
|
88
|
+
if make_persistent is not None:
|
|
89
|
+
session[SESSION_PERSISTENT] = bool(make_persistent)
|
|
90
|
+
|
|
91
|
+
def _update_session_timeout(self, session) -> None:
|
|
92
|
+
self._ensure_start_time(session)
|
|
93
|
+
|
|
94
|
+
now: datetime.datetime = session.now()
|
|
95
|
+
start: datetime.datetime = session[SESSION_START_TIME]
|
|
96
|
+
|
|
97
|
+
idle_timeout_min, persistent_timeout_min, absolute_timeout_min = self._get_config_timeouts()
|
|
98
|
+
|
|
99
|
+
is_persistent = bool(session.get(SESSION_PERSISTENT, False))
|
|
100
|
+
|
|
101
|
+
# Compute absolute remaining (hard cap from start, never slides)
|
|
102
|
+
abs_expiration = start + datetime.timedelta(minutes=absolute_timeout_min)
|
|
103
|
+
abs_remaining_sec = int((abs_expiration - now).total_seconds())
|
|
104
|
+
|
|
105
|
+
if is_persistent:
|
|
106
|
+
# Sliding window for persistent sessions
|
|
107
|
+
sliding_exp = now + datetime.timedelta(minutes=persistent_timeout_min)
|
|
108
|
+
else:
|
|
109
|
+
# Sliding window for non-persistent sessions (idle timeout)
|
|
110
|
+
sliding_exp = now + datetime.timedelta(minutes=idle_timeout_min)
|
|
111
|
+
|
|
112
|
+
sliding_remaining_sec = int((sliding_exp - now).total_seconds())
|
|
113
|
+
|
|
114
|
+
# Effective remaining is the minimum of the sliding window and absolute cap
|
|
115
|
+
remaining_sec = min(abs_remaining_sec, sliding_remaining_sec)
|
|
116
|
+
remaining_min = self._ceil_minutes(remaining_sec)
|
|
117
|
+
|
|
118
|
+
if remaining_min <= 0:
|
|
119
|
+
# Expired due to either sliding window or absolute cap
|
|
120
|
+
# Start a fresh session; do NOT automatically re-mark persistent here.
|
|
121
|
+
# Authentication/Remember me logic elsewhere can call set_persistent(True) again after login.
|
|
122
|
+
self._expire_and_restart(session)
|
|
123
|
+
# After regeneration, set a sensible session.timeout baseline:
|
|
124
|
+
session.timeout = idle_timeout_min
|
|
125
|
+
return
|
|
126
|
+
|
|
127
|
+
# Apply per-request remaining minutes to CherryPy's session timeout
|
|
128
|
+
session.timeout = remaining_min
|
|
129
|
+
|
|
130
|
+
if is_persistent:
|
|
131
|
+
# Keep cookie lifetime aligned with remaining time so it can survive browser restarts.
|
|
132
|
+
self._set_cookie_max_age(session, remaining_sec)
|
|
133
|
+
else:
|
|
134
|
+
# For non-persistent, let CherryPy manage the session cookie (session or transient cookie).
|
|
135
|
+
# If you want strict enforcement client-side too, uncomment the next line:
|
|
136
|
+
# self._set_cookie_max_age(remaining_sec)
|
|
137
|
+
pass
|
|
138
|
+
|
|
139
|
+
def run(self, persistent_timeout: int = 10080, absolute_timeout: int = 43200) -> None:
|
|
140
|
+
# Skip if sessions are not enabled
|
|
141
|
+
if not hasattr(cherrypy.serving, 'session'):
|
|
142
|
+
return
|
|
143
|
+
|
|
144
|
+
# Ensure configured values are honored (method signature kept for CherryPy Tool interface)
|
|
145
|
+
with session_lock() as session:
|
|
146
|
+
self._update_session_timeout(session)
|
|
147
|
+
|
|
148
|
+
def set_persistent(self, value: bool = True) -> None:
|
|
149
|
+
"""Mark current session as persistent (or not) and reset the start window."""
|
|
150
|
+
with session_lock() as session:
|
|
151
|
+
session[SESSION_PERSISTENT] = bool(value)
|
|
152
|
+
# Reset absolute window anchor when the persistence policy changes
|
|
153
|
+
session[SESSION_START_TIME] = session.now()
|
|
154
|
+
self._update_session_timeout(session)
|
|
155
|
+
|
|
156
|
+
def is_persistent(self) -> bool:
|
|
157
|
+
"""Return True if the session is marked persistent."""
|
|
158
|
+
with session_lock() as session:
|
|
159
|
+
return bool(session.get(SESSION_PERSISTENT, False))
|
|
160
|
+
|
|
161
|
+
def get_session_start_time(self) -> datetime.datetime | None:
|
|
162
|
+
"""Return the session start time if any."""
|
|
163
|
+
with session_lock() as session:
|
|
164
|
+
return session.get(SESSION_START_TIME, None)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
cherrypy.tools.sessions_timeout = SessionsTimeout()
|
|
File without changes
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: \n"
|
|
4
|
+
"POT-Creation-Date: \n"
|
|
5
|
+
"PO-Revision-Date: \n"
|
|
6
|
+
"Last-Translator: \n"
|
|
7
|
+
"Language-Team: \n"
|
|
8
|
+
"Language: de\n"
|
|
9
|
+
"MIME-Version: 1.0\n"
|
|
10
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
11
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
12
|
+
"X-Generator: Poedit 3.6\n"
|
|
13
|
+
|
|
14
|
+
msgid "Some text to translate"
|
|
15
|
+
msgstr "Einige zu übersetzende Texte"
|
|
Binary file
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
msgid ""
|
|
2
|
+
msgstr ""
|
|
3
|
+
"Project-Id-Version: \n"
|
|
4
|
+
"POT-Creation-Date: \n"
|
|
5
|
+
"PO-Revision-Date: \n"
|
|
6
|
+
"Last-Translator: \n"
|
|
7
|
+
"Language-Team: \n"
|
|
8
|
+
"Language: fr\n"
|
|
9
|
+
"MIME-Version: 1.0\n"
|
|
10
|
+
"Content-Type: text/plain; charset=UTF-8\n"
|
|
11
|
+
"Content-Transfer-Encoding: 8bit\n"
|
|
12
|
+
"X-Generator: Poedit 3.6\n"
|
|
13
|
+
|
|
14
|
+
msgid "Some text to translate"
|
|
15
|
+
msgstr "Du texte à traduire"
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html lang="{{ get_translation().locale }}">
|
|
3
|
+
<head>
|
|
4
|
+
<title>test-jinja2</title>
|
|
5
|
+
{{ catalog.render_assets() }}
|
|
6
|
+
</head>
|
|
7
|
+
<body>
|
|
8
|
+
<!-- Selector -->
|
|
9
|
+
<div class="dropdown">
|
|
10
|
+
<button class="btn btn-secondary dropdown-toggle" type="button" data-bs-toggle="dropdown" aria-expanded="false">
|
|
11
|
+
Dropdown button
|
|
12
|
+
</button>
|
|
13
|
+
<ul class="dropdown-menu">
|
|
14
|
+
<LocaleSelection />
|
|
15
|
+
</ul>
|
|
16
|
+
</div>
|
|
17
|
+
{{ get_language_name(get_translation().locale) }}<br/>
|
|
18
|
+
{% trans %}Some text to translate{% endtrans %}<br/>
|
|
19
|
+
{{ my_datetime | format_datetime(format='full') }}<br/>
|
|
20
|
+
{{ my_date | format_date(format='full') }}
|
|
21
|
+
</body>
|
|
22
|
+
</html>
|