ckanext-csrf-filter 2.0.3__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.
- ckanext/__init__.py +7 -0
- ckanext/csrf_filter/__init__.py +0 -0
- ckanext/csrf_filter/anti_csrf.py +426 -0
- ckanext/csrf_filter/helpers.py +15 -0
- ckanext/csrf_filter/plugin.py +125 -0
- ckanext/csrf_filter/request_helpers.py +66 -0
- ckanext/csrf_filter/test_anti_csrf.py +320 -0
- ckanext/csrf_filter/test_token_protected_friendlyform.py +80 -0
- ckanext/csrf_filter/token_protected_friendlyform.py +39 -0
- ckanext_csrf_filter-2.0.3.dist-info/METADATA +162 -0
- ckanext_csrf_filter-2.0.3.dist-info/RECORD +15 -0
- ckanext_csrf_filter-2.0.3.dist-info/WHEEL +5 -0
- ckanext_csrf_filter-2.0.3.dist-info/entry_points.txt +2 -0
- ckanext_csrf_filter-2.0.3.dist-info/licenses/LICENSE +661 -0
- ckanext_csrf_filter-2.0.3.dist-info/top_level.txt +1 -0
ckanext/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,426 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
""" Provides functions to help prevent Cross-Site Request Forgery,
|
|
3
|
+
based on the Double Submit Cookie pattern,
|
|
4
|
+
www.owasp.org/index.php/Cross-Site_Request_Forgery_(CSRF)_Prevention_Cheat_Sheet#Double_Submit_Cookie
|
|
5
|
+
|
|
6
|
+
To apply the filter, use 'apply_token' to inject tokens into Flask responses,
|
|
7
|
+
and call 'check_csrf' on all requests to determine whether they are valid.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import hashlib
|
|
11
|
+
import hmac
|
|
12
|
+
import json
|
|
13
|
+
from logging import getLogger
|
|
14
|
+
import random
|
|
15
|
+
import re
|
|
16
|
+
import time
|
|
17
|
+
import six
|
|
18
|
+
from six.moves.urllib.parse import quote, urlparse
|
|
19
|
+
|
|
20
|
+
from ckanext.csrf_filter.request_helpers import RequestHelper
|
|
21
|
+
|
|
22
|
+
LOG = getLogger(__name__)
|
|
23
|
+
|
|
24
|
+
""" Used as the cookie name and input field name.
|
|
25
|
+
"""
|
|
26
|
+
TOKEN_FIELD_NAME = 'token'
|
|
27
|
+
|
|
28
|
+
"""
|
|
29
|
+
This will match a POST form that has whitespace after the opening tag (which all existing forms do).
|
|
30
|
+
Once we have injected a token immediately after the opening tag,
|
|
31
|
+
it won't match any more, which avoids redundant injection.
|
|
32
|
+
"""
|
|
33
|
+
POST_FORM = re.compile(
|
|
34
|
+
r'(<form [^>]*method=["\']post["\'][^>]*>)(\s[^<]*<)',
|
|
35
|
+
re.IGNORECASE | re.MULTILINE)
|
|
36
|
+
|
|
37
|
+
"""The format of the token HTML field.
|
|
38
|
+
"""
|
|
39
|
+
TOKEN_VALIDATION_PATTERN = re.compile(
|
|
40
|
+
r'^[0-9a-z]+![0-9]+/[0-9]+/[-_a-z0-9%+=/]+$',
|
|
41
|
+
re.IGNORECASE)
|
|
42
|
+
ENCODED_TOKEN_VALIDATION_PATTERN = re.compile(
|
|
43
|
+
r'^[0-9a-z]+![0-9]+/[0-9]+/[-_a-z0-9%+=]+$',
|
|
44
|
+
re.IGNORECASE)
|
|
45
|
+
LOGIN_URL = re.compile(r'^(/user)?/log(ged_)?in(_generic)?')
|
|
46
|
+
CONFIRM_MODULE_PATTERN = r'data-module=["\']confirm-action["\']'
|
|
47
|
+
CONFIRM_MODULE = re.compile(CONFIRM_MODULE_PATTERN)
|
|
48
|
+
HREF_URL_PATTERN = r'href=["\']([^"\']+)'
|
|
49
|
+
ANONYMOUS_USERNAME = '__anonymous__'
|
|
50
|
+
|
|
51
|
+
# We need to edit confirm-action links, which get intercepted by JavaScript,
|
|
52
|
+
# regardless of which order their 'data-module' and 'href' attributes appear.
|
|
53
|
+
CONFIRM_LINK = re.compile(r'(<a [^>]*{}[^>]*{})(["\'])'.format(
|
|
54
|
+
CONFIRM_MODULE_PATTERN, HREF_URL_PATTERN),
|
|
55
|
+
re.IGNORECASE | re.MULTILINE)
|
|
56
|
+
CONFIRM_LINK_REVERSED = re.compile(r'(<a [^>]*{})(["\'][^>]*{})'.format(
|
|
57
|
+
HREF_URL_PATTERN, CONFIRM_MODULE_PATTERN),
|
|
58
|
+
re.IGNORECASE | re.MULTILINE)
|
|
59
|
+
|
|
60
|
+
# We also need to edit HTMX links that will be converted to POST requests.
|
|
61
|
+
HTMX_POST_LINK = re.compile(
|
|
62
|
+
r'(<a [^>]*hx-post=["\']([^"\']+))(["\'])',
|
|
63
|
+
re.IGNORECASE | re.MULTILINE)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def configure(config):
|
|
67
|
+
""" Configure global values for the filter.
|
|
68
|
+
"""
|
|
69
|
+
global secure_cookies
|
|
70
|
+
global same_site
|
|
71
|
+
global secret_key
|
|
72
|
+
global token_expiry_age
|
|
73
|
+
global token_renewal_age
|
|
74
|
+
global exempt_rules
|
|
75
|
+
|
|
76
|
+
site_url = urlparse(config.get('ckan.site_url', ''))
|
|
77
|
+
if site_url.scheme == 'https':
|
|
78
|
+
secure_cookies = True
|
|
79
|
+
else:
|
|
80
|
+
LOG.warning("Site %s is not secure! CSRF tokens may be exposed!", site_url)
|
|
81
|
+
secure_cookies = False
|
|
82
|
+
|
|
83
|
+
same_site = config.get('ckanext.csrf_filter.same_site', 'None')
|
|
84
|
+
# https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie#samesitesamesite-value
|
|
85
|
+
assert same_site in ['Strict', 'Lax', 'None']
|
|
86
|
+
# workaround for older Werkzeug versions
|
|
87
|
+
if same_site == 'None':
|
|
88
|
+
same_site = None
|
|
89
|
+
|
|
90
|
+
key_fields = ['ckanext.csrf_filter.secret_key',
|
|
91
|
+
'beaker.session.secret',
|
|
92
|
+
'flask.secret_key']
|
|
93
|
+
secret_key = None
|
|
94
|
+
for field in key_fields:
|
|
95
|
+
secret_key = config.get(field)
|
|
96
|
+
if secret_key:
|
|
97
|
+
LOG.info("Obtained secret key from %s", field)
|
|
98
|
+
break
|
|
99
|
+
else:
|
|
100
|
+
raise ValueError("No secret key provided for CSRF tokens; populate one of %s",
|
|
101
|
+
key_fields)
|
|
102
|
+
token_expiry_age = 60 * config.get('ckanext.csrf_filter.token_expiry_minutes', 30)
|
|
103
|
+
token_renewal_age = 60 * config.get('ckanext.csrf_filter.token_renewal_minutes', 10)
|
|
104
|
+
|
|
105
|
+
exempt_rules = [re.compile(r'^/+api/.*'), re.compile(r'^/user/_logout.*')]
|
|
106
|
+
custom_exempt_rules = config.get('ckanext.csrf_filter.exempt_rules', None)
|
|
107
|
+
if custom_exempt_rules:
|
|
108
|
+
for rule in json.loads(custom_exempt_rules):
|
|
109
|
+
LOG.debug("Adding CSRF exclusion: %s", rule)
|
|
110
|
+
exempt_rules.append(re.compile(rule))
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
# -------------
|
|
114
|
+
# Token parsing
|
|
115
|
+
# -------------
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _read_token_values(token):
|
|
119
|
+
""" Parse the provided token string. Invalid tokens are parsed as empty dicts.
|
|
120
|
+
"""
|
|
121
|
+
if not token or not TOKEN_VALIDATION_PATTERN.match(token):
|
|
122
|
+
return {}
|
|
123
|
+
|
|
124
|
+
parts = token.split('!', 1)
|
|
125
|
+
message = parts[1]
|
|
126
|
+
# limiting to 2 means that even if a username contains a slash, it won't cause an extra split
|
|
127
|
+
message_parts = message.split('/', 2)
|
|
128
|
+
username = message_parts[2]
|
|
129
|
+
encoded_username = quote(username, safe='%')
|
|
130
|
+
if username != encoded_username:
|
|
131
|
+
username = encoded_username
|
|
132
|
+
message_parts[2] = username
|
|
133
|
+
message = '/'.join(message_parts)
|
|
134
|
+
|
|
135
|
+
return {
|
|
136
|
+
"message": message,
|
|
137
|
+
"hash": parts[0],
|
|
138
|
+
"timestamp": int(message_parts[0]),
|
|
139
|
+
"nonce": int(message_parts[1]),
|
|
140
|
+
"username": username
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _ensure_token_encoded(token):
|
|
145
|
+
if ENCODED_TOKEN_VALIDATION_PATTERN.match(token):
|
|
146
|
+
return token
|
|
147
|
+
token_parts = _read_token_values(token)
|
|
148
|
+
return token_parts['hash'] + '!' + token_parts['message']
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def is_valid_token(token):
|
|
152
|
+
""" Verify the integrity of the provided token.
|
|
153
|
+
It must have the expected format (hash!timestamp/nonce/username),
|
|
154
|
+
the hash must match the other values,
|
|
155
|
+
the username must match the current account,
|
|
156
|
+
and it must not be older than our limit (default 30 minutes).
|
|
157
|
+
"""
|
|
158
|
+
token_values = _read_token_values(token)
|
|
159
|
+
if 'hash' not in token_values:
|
|
160
|
+
return False
|
|
161
|
+
|
|
162
|
+
expected_hmac = six.ensure_text(_get_digest(token_values['message']))
|
|
163
|
+
if not hmac.compare_digest(expected_hmac, six.ensure_text(token_values['hash'])):
|
|
164
|
+
return False
|
|
165
|
+
|
|
166
|
+
timestamp = token_values['timestamp']
|
|
167
|
+
token_age = int(time.time()) - timestamp
|
|
168
|
+
# allow tokens up to 'max_age' minutes old
|
|
169
|
+
if token_age < 0 or token_age > token_expiry_age:
|
|
170
|
+
return False
|
|
171
|
+
|
|
172
|
+
return token_values['username'] == _get_safe_username()
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def is_soft_expired(token):
|
|
176
|
+
""" Check whether the token is old enough to need rotation.
|
|
177
|
+
It may still be valid, but it's time to generate a new one.
|
|
178
|
+
|
|
179
|
+
The default rotation age is 10 minutes.
|
|
180
|
+
"""
|
|
181
|
+
if not is_valid_token(token):
|
|
182
|
+
return False
|
|
183
|
+
|
|
184
|
+
token_values = _read_token_values(token)
|
|
185
|
+
timestamp = token_values['timestamp']
|
|
186
|
+
token_age = int(time.time()) - timestamp
|
|
187
|
+
|
|
188
|
+
return token_age > token_renewal_age
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# --------------------
|
|
192
|
+
# Check request tokens
|
|
193
|
+
# --------------------
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _is_login_url(request):
|
|
197
|
+
""" Determine whether the user is visiting a login-related URL
|
|
198
|
+
that should be protected with a token.
|
|
199
|
+
"""
|
|
200
|
+
request_helper = RequestHelper(request)
|
|
201
|
+
path = request_helper.get_path()
|
|
202
|
+
if LOGIN_URL.match(path):
|
|
203
|
+
return True
|
|
204
|
+
try:
|
|
205
|
+
return path.startswith(getattr(
|
|
206
|
+
request_helper.get_environ()[u'repoze.who.plugins'][u'friendlyform'],
|
|
207
|
+
u'login_handler_path', None))
|
|
208
|
+
except (KeyError, AttributeError):
|
|
209
|
+
# not necessarily an error, we might just not use FriendlyForm
|
|
210
|
+
return False
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def is_logged_in(request=None):
|
|
214
|
+
""" Determine whether the user is logged in, or about to log in,
|
|
215
|
+
and thus needs a token.
|
|
216
|
+
"""
|
|
217
|
+
return _get_user() or _is_login_url(request)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def _is_request_exempt(request):
|
|
221
|
+
""" Determine whether a request needs to provide a token.
|
|
222
|
+
HTTP methods without side effects (GET, HEAD, OPTIONS) are exempt,
|
|
223
|
+
as are API calls (which should instead provide an API key).
|
|
224
|
+
"""
|
|
225
|
+
request_helper = RequestHelper(request)
|
|
226
|
+
for rule in exempt_rules:
|
|
227
|
+
if rule.match(request_helper.get_path()):
|
|
228
|
+
return True
|
|
229
|
+
return not is_logged_in(request) \
|
|
230
|
+
or request_helper.get_method() in {'GET', 'HEAD', 'OPTIONS'}
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def _get_cookie_token(request):
|
|
234
|
+
""" Retrieve the token included in the request cookies, if it exists
|
|
235
|
+
and is valid.
|
|
236
|
+
"""
|
|
237
|
+
token = RequestHelper(request).get_cookie(TOKEN_FIELD_NAME)
|
|
238
|
+
if is_valid_token(token):
|
|
239
|
+
return token
|
|
240
|
+
|
|
241
|
+
return None
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _get_submitted_form_token(request):
|
|
245
|
+
"""Retrieve the form token included in the request.
|
|
246
|
+
|
|
247
|
+
This is normally a single 'token' parameter in the POST body.
|
|
248
|
+
However, for compatibility with 'confirm-action' links,
|
|
249
|
+
it is also acceptable to provide the token as a query string parameter.
|
|
250
|
+
"""
|
|
251
|
+
request_helper = RequestHelper(request)
|
|
252
|
+
if TOKEN_FIELD_NAME in request_helper.scoped_attrs():
|
|
253
|
+
return request_helper.scoped_attrs()[TOKEN_FIELD_NAME]
|
|
254
|
+
|
|
255
|
+
post_tokens = request_helper.get_post_params(TOKEN_FIELD_NAME)
|
|
256
|
+
|
|
257
|
+
if post_tokens:
|
|
258
|
+
if len(post_tokens) > 1:
|
|
259
|
+
LOG.error("More than one CSRF token in form submission")
|
|
260
|
+
return None
|
|
261
|
+
else:
|
|
262
|
+
token = post_tokens[0]
|
|
263
|
+
else:
|
|
264
|
+
get_tokens = request_helper.get_query_params(TOKEN_FIELD_NAME)
|
|
265
|
+
if len(get_tokens) == 1:
|
|
266
|
+
# handle query string token if there are no POST parameters
|
|
267
|
+
# this is needed for the 'confirm-action' JavaScript module
|
|
268
|
+
token = get_tokens[0]
|
|
269
|
+
else:
|
|
270
|
+
LOG.error("Missing CSRF token in form submission")
|
|
271
|
+
return None
|
|
272
|
+
|
|
273
|
+
if not is_valid_token(token):
|
|
274
|
+
LOG.error("Invalid CSRF token format")
|
|
275
|
+
return None
|
|
276
|
+
|
|
277
|
+
token = _ensure_token_encoded(token)
|
|
278
|
+
request_helper.scoped_attrs()[TOKEN_FIELD_NAME] = token
|
|
279
|
+
request_helper.delete_param(TOKEN_FIELD_NAME)
|
|
280
|
+
return token
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def check_csrf(request=None):
|
|
284
|
+
""" Check whether the request passes (or is exempt from) CSRF checks.
|
|
285
|
+
Returns True if valid or exempt, False if invalid.
|
|
286
|
+
"""
|
|
287
|
+
if _is_request_exempt(request):
|
|
288
|
+
return True
|
|
289
|
+
cookie_token = _get_cookie_token(request)
|
|
290
|
+
if cookie_token and cookie_token.strip() != ""\
|
|
291
|
+
and cookie_token == _get_submitted_form_token(request):
|
|
292
|
+
return True
|
|
293
|
+
else:
|
|
294
|
+
LOG.error("Could not match cookie token with form token")
|
|
295
|
+
return False
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
# ------------------------
|
|
299
|
+
# Populate response tokens
|
|
300
|
+
# ------------------------
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _get_user():
|
|
304
|
+
""" Retrieve the current user object.
|
|
305
|
+
"""
|
|
306
|
+
from ckan.common import g
|
|
307
|
+
if 'userobj' in dir(g):
|
|
308
|
+
return g.userobj
|
|
309
|
+
else:
|
|
310
|
+
return None
|
|
311
|
+
|
|
312
|
+
|
|
313
|
+
def _get_safe_username():
|
|
314
|
+
""" Retrieve a with unsafe characters URL-encoded.
|
|
315
|
+
"""
|
|
316
|
+
userobj = _get_user()
|
|
317
|
+
if userobj and userobj.name:
|
|
318
|
+
if userobj.name == ANONYMOUS_USERNAME:
|
|
319
|
+
raise ValueError("User account is named %s!",
|
|
320
|
+
ANONYMOUS_USERNAME)
|
|
321
|
+
return quote(userobj.name, safe='')
|
|
322
|
+
else:
|
|
323
|
+
return ANONYMOUS_USERNAME
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def _get_digest(message):
|
|
327
|
+
""" Generate a secure (unforgeable) hash of the provided data.
|
|
328
|
+
"""
|
|
329
|
+
return hmac.HMAC(six.ensure_binary(secret_key), six.ensure_binary(message), hashlib.sha512).hexdigest()
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _set_response_token_cookie(token, response):
|
|
333
|
+
""" Add a generated token cookie to the HTTP response.
|
|
334
|
+
"""
|
|
335
|
+
response.set_cookie(TOKEN_FIELD_NAME, token, secure=secure_cookies, httponly=True, samesite=same_site)
|
|
336
|
+
|
|
337
|
+
|
|
338
|
+
def create_response_token():
|
|
339
|
+
""" Generate an unforgeable CSRF token. The format of this token is:
|
|
340
|
+
hash!timestamp/nonce/username
|
|
341
|
+
where the hash is a secure HMAC of the other values plus a secret key.
|
|
342
|
+
"""
|
|
343
|
+
username = _get_safe_username()
|
|
344
|
+
timestamp = int(time.time())
|
|
345
|
+
nonce = random.randint(1, 999999)
|
|
346
|
+
message = "{}/{}/{}".format(timestamp, nonce, username)
|
|
347
|
+
token = "{}!{}".format(_get_digest(message), message)
|
|
348
|
+
|
|
349
|
+
return token
|
|
350
|
+
|
|
351
|
+
|
|
352
|
+
def get_response_token(response, request=None):
|
|
353
|
+
"""Retrieve the token to be injected into pages.
|
|
354
|
+
|
|
355
|
+
This will be retrieved from the token cookie, if is valid and current.
|
|
356
|
+
If not, a new token will be generated and a new cookie set.
|
|
357
|
+
"""
|
|
358
|
+
# ensure that the same token is used when a page is assembled from pieces
|
|
359
|
+
request_helper = RequestHelper(request)
|
|
360
|
+
if 'response_token' in request_helper.scoped_attrs():
|
|
361
|
+
token = request_helper.scoped_attrs()['response_token']
|
|
362
|
+
else:
|
|
363
|
+
token = _get_cookie_token(request)
|
|
364
|
+
if not token or is_soft_expired(token):
|
|
365
|
+
LOG.debug("No valid token found; making new token")
|
|
366
|
+
token = create_response_token()
|
|
367
|
+
_set_response_token_cookie(token, response)
|
|
368
|
+
request_helper.scoped_attrs()['response_token'] = token
|
|
369
|
+
|
|
370
|
+
return token
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def insert_token(html, token, request=None):
|
|
374
|
+
""" Rewrite HTML to insert tokens if applicable.
|
|
375
|
+
"""
|
|
376
|
+
if not html or not is_logged_in(request) or not (
|
|
377
|
+
POST_FORM.search(html) or CONFIRM_MODULE.search(html) or HTMX_POST_LINK.search(html)):
|
|
378
|
+
return html
|
|
379
|
+
|
|
380
|
+
def insert_form_token(form_match):
|
|
381
|
+
""" Inject a token into a POST form. """
|
|
382
|
+
return form_match.group(1)\
|
|
383
|
+
+ '<input type="hidden" name="{}" value="{}"/>'.format(TOKEN_FIELD_NAME, token)\
|
|
384
|
+
+ form_match.group(2)
|
|
385
|
+
|
|
386
|
+
def insert_link_token(link_match):
|
|
387
|
+
""" Inject a token into a link that uses data-module="confirm-action".
|
|
388
|
+
These links are picked up by JavaScript and converted into empty POST requests.
|
|
389
|
+
"""
|
|
390
|
+
if TOKEN_FIELD_NAME + '=' in link_match.group(1):
|
|
391
|
+
return link_match.group(0)
|
|
392
|
+
if '?' in link_match.group(2):
|
|
393
|
+
separator = '&'
|
|
394
|
+
else:
|
|
395
|
+
separator = '?'
|
|
396
|
+
return link_match.group(1) + separator + TOKEN_FIELD_NAME + '=' + token + link_match.group(3)
|
|
397
|
+
|
|
398
|
+
html = POST_FORM.sub(insert_form_token, six.ensure_text(html))
|
|
399
|
+
html = CONFIRM_LINK.sub(insert_link_token, html)
|
|
400
|
+
html = CONFIRM_LINK_REVERSED.sub(insert_link_token, html)
|
|
401
|
+
html = HTMX_POST_LINK.sub(insert_link_token, html)
|
|
402
|
+
return html
|
|
403
|
+
|
|
404
|
+
|
|
405
|
+
def apply_token(response, request=None):
|
|
406
|
+
""" Rewrite HTML to insert tokens if applicable.
|
|
407
|
+
If a new token is generated, it will be added to 'response' as a cookie.
|
|
408
|
+
"""
|
|
409
|
+
try:
|
|
410
|
+
if not getattr(response, 'charset', None):
|
|
411
|
+
# Provide default charset
|
|
412
|
+
response.charset = 'utf-8'
|
|
413
|
+
# If the response data can't be decoded from bytes to str
|
|
414
|
+
# we can't insert a token
|
|
415
|
+
html = response.get_data(as_text=True)
|
|
416
|
+
except UnicodeDecodeError:
|
|
417
|
+
html = None
|
|
418
|
+
|
|
419
|
+
if not html or not is_logged_in(request):
|
|
420
|
+
return response
|
|
421
|
+
|
|
422
|
+
token = get_response_token(response)
|
|
423
|
+
html = insert_token(html, token)
|
|
424
|
+
if hasattr(response, 'data'):
|
|
425
|
+
response.data = html
|
|
426
|
+
return response
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
from markupsafe import Markup
|
|
2
|
+
from ckanext.csrf_filter.anti_csrf import get_response_token, TOKEN_FIELD_NAME
|
|
3
|
+
from flask import Response
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from ckan.common import is_flask_request
|
|
7
|
+
except ImportError:
|
|
8
|
+
def is_flask_request():
|
|
9
|
+
return True
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def csrf_token_field():
|
|
13
|
+
response = Response()
|
|
14
|
+
token = get_response_token(response)
|
|
15
|
+
return Markup('<input type="hidden" name="{}" value="{}"/>'.format(TOKEN_FIELD_NAME, token))
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
""" Injects and validates form tokens to prevent Cross-Site Request Forgery attacks.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
from logging import getLogger
|
|
6
|
+
from types import GeneratorType
|
|
7
|
+
|
|
8
|
+
from ckan import plugins
|
|
9
|
+
from ckan.plugins import implements, toolkit
|
|
10
|
+
|
|
11
|
+
from ckanext.csrf_filter import anti_csrf
|
|
12
|
+
import ckanext.csrf_filter.helpers as h
|
|
13
|
+
from ckanext.csrf_filter.request_helpers import RequestHelper
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
from flask import Blueprint, Request
|
|
17
|
+
from werkzeug.datastructures import MultiDict, ImmutableMultiDict
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class MostlyImmutableMultiDict(ImmutableMultiDict):
|
|
21
|
+
""" Allows a single mutating operation, to delete the CSRF token.
|
|
22
|
+
"""
|
|
23
|
+
mutable_fields = [anti_csrf.TOKEN_FIELD_NAME]
|
|
24
|
+
|
|
25
|
+
def __delitem__(self, key):
|
|
26
|
+
if key in self.mutable_fields:
|
|
27
|
+
return MultiDict.__delitem__(self, key)
|
|
28
|
+
else:
|
|
29
|
+
return super.__delitem__(key)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class CSRFAwareRequest(Request):
|
|
33
|
+
""" Adjust parameter storage so we can delete CSRF tokens.
|
|
34
|
+
"""
|
|
35
|
+
parameter_storage_class = MostlyImmutableMultiDict
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
LOG = getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class CSRFFilterPlugin(plugins.SingletonPlugin):
|
|
42
|
+
""" Inject CSRF tokens into HTML responses,
|
|
43
|
+
and validate them on applicable requests.
|
|
44
|
+
"""
|
|
45
|
+
implements(plugins.IConfigurer)
|
|
46
|
+
implements(plugins.IConfigurable, inherit=True)
|
|
47
|
+
implements(plugins.IAuthenticator, inherit=True)
|
|
48
|
+
implements(plugins.ITemplateHelpers)
|
|
49
|
+
implements(plugins.IBlueprint, inherit=True)
|
|
50
|
+
implements(plugins.IMiddleware, inherit=True)
|
|
51
|
+
|
|
52
|
+
# IConfigurer
|
|
53
|
+
|
|
54
|
+
def update_config(self, config_):
|
|
55
|
+
toolkit.add_template_directory(config_, 'templates')
|
|
56
|
+
|
|
57
|
+
# IConfigurable
|
|
58
|
+
|
|
59
|
+
def configure(self, config):
|
|
60
|
+
""" Provide configuration (eg max token age) to the CSRF filter.
|
|
61
|
+
"""
|
|
62
|
+
self.config = config
|
|
63
|
+
anti_csrf.configure(config)
|
|
64
|
+
|
|
65
|
+
# IAuthenticator
|
|
66
|
+
|
|
67
|
+
def login(self):
|
|
68
|
+
request = RequestHelper()
|
|
69
|
+
request.get_environ()['__no_cache__'] = True
|
|
70
|
+
return None
|
|
71
|
+
|
|
72
|
+
# ITemplateHelpers
|
|
73
|
+
|
|
74
|
+
def get_helpers(self):
|
|
75
|
+
return {'csrf_token_field': h.csrf_token_field}
|
|
76
|
+
|
|
77
|
+
# IBlueprint
|
|
78
|
+
|
|
79
|
+
def get_blueprint(self):
|
|
80
|
+
""" Create a blueprint that uses a Flask rule to intercept all
|
|
81
|
+
requests and set/check CSRF tokens.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
blueprint = Blueprint(self.name, self.__module__)
|
|
85
|
+
|
|
86
|
+
@blueprint.before_app_request
|
|
87
|
+
def check_csrf():
|
|
88
|
+
""" Abort invalid Flask requests based on CSRF token.
|
|
89
|
+
"""
|
|
90
|
+
if not anti_csrf.check_csrf():
|
|
91
|
+
return toolkit.abort(403, "Your form submission could not be validated")
|
|
92
|
+
|
|
93
|
+
@blueprint.after_app_request
|
|
94
|
+
def set_csrf_token(response):
|
|
95
|
+
""" Apply a CSRF token to all response bodies.
|
|
96
|
+
|
|
97
|
+
Exclude GeneratorType responses as they are data streams.
|
|
98
|
+
Modifying the data of the data stream breaks the streaming process.
|
|
99
|
+
|
|
100
|
+
If a user needs to stream templates, they should use the csrf_token_field
|
|
101
|
+
helper in their forms inside of their streamed templates.
|
|
102
|
+
"""
|
|
103
|
+
if isinstance(getattr(response, 'response', None), GeneratorType):
|
|
104
|
+
return response
|
|
105
|
+
|
|
106
|
+
response.direct_passthrough = False
|
|
107
|
+
anti_csrf.apply_token(response)
|
|
108
|
+
return response
|
|
109
|
+
|
|
110
|
+
return blueprint
|
|
111
|
+
|
|
112
|
+
# IMiddleware
|
|
113
|
+
|
|
114
|
+
def make_middleware(self, app, config):
|
|
115
|
+
""" Configure the Flask app to permit deletion of the CSRF token
|
|
116
|
+
from the request parameters. Otherwise the token ends up getting
|
|
117
|
+
populated in dataset and resource 'extras'.
|
|
118
|
+
"""
|
|
119
|
+
if hasattr(app, 'request_class'):
|
|
120
|
+
app.request_class = CSRFAwareRequest
|
|
121
|
+
if app.secret_key:
|
|
122
|
+
self.config['flask.secret_key'] = app.secret_key
|
|
123
|
+
anti_csrf.configure(self.config)
|
|
124
|
+
|
|
125
|
+
return app
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# encoding: utf-8
|
|
2
|
+
""" Some useful functions for interacting with the current request.
|
|
3
|
+
"""
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class RequestHelper():
|
|
7
|
+
|
|
8
|
+
def __init__(self, request=None):
|
|
9
|
+
if request:
|
|
10
|
+
self.request = request
|
|
11
|
+
else:
|
|
12
|
+
from ckan.plugins import toolkit
|
|
13
|
+
self.request = toolkit.request
|
|
14
|
+
|
|
15
|
+
def get_path(self):
|
|
16
|
+
""" Get the request path, without query string.
|
|
17
|
+
"""
|
|
18
|
+
return self.request.path
|
|
19
|
+
|
|
20
|
+
def get_method(self):
|
|
21
|
+
""" Get the request method, eg HEAD, GET, POST.
|
|
22
|
+
"""
|
|
23
|
+
return self.request.method
|
|
24
|
+
|
|
25
|
+
def get_environ(self):
|
|
26
|
+
""" Get the WebOb environment dict.
|
|
27
|
+
"""
|
|
28
|
+
return self.request.environ
|
|
29
|
+
|
|
30
|
+
def get_cookie(self, field_name, default=None):
|
|
31
|
+
""" Get the value of a cookie, or the default value if not present.
|
|
32
|
+
"""
|
|
33
|
+
return self.request.cookies.get(field_name, default)
|
|
34
|
+
|
|
35
|
+
def get_post_params(self, field_name):
|
|
36
|
+
""" Retrieve a list of all POST parameters with the specified name
|
|
37
|
+
for the current request.
|
|
38
|
+
|
|
39
|
+
This uses 'request.form' for Flask.
|
|
40
|
+
"""
|
|
41
|
+
return self.request.form.getlist(field_name)
|
|
42
|
+
|
|
43
|
+
def get_query_params(self, field_name):
|
|
44
|
+
""" Retrieve a list of all GET parameters with the specified name
|
|
45
|
+
for the current request.
|
|
46
|
+
|
|
47
|
+
This uses 'request.args' for Flask.
|
|
48
|
+
"""
|
|
49
|
+
return self.request.args.getlist(field_name)
|
|
50
|
+
|
|
51
|
+
def delete_param(self, field_name):
|
|
52
|
+
""" Remove the parameter with the specified name from the current
|
|
53
|
+
request. This requires the request parameters to be mutable.
|
|
54
|
+
"""
|
|
55
|
+
for collection_name in ['args', 'form', 'GET', 'POST']:
|
|
56
|
+
collection = getattr(self.request, collection_name, {})
|
|
57
|
+
if field_name in collection:
|
|
58
|
+
del collection[field_name]
|
|
59
|
+
|
|
60
|
+
def scoped_attrs(self):
|
|
61
|
+
""" Returns a mutable dictionary of attributes that exist in the
|
|
62
|
+
scope of the current request, and will vanish afterward.
|
|
63
|
+
"""
|
|
64
|
+
if 'webob.adhoc_attrs' not in self.request.environ:
|
|
65
|
+
self.request.environ['webob.adhoc_attrs'] = {}
|
|
66
|
+
return self.request.environ['webob.adhoc_attrs']
|