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 ADDED
@@ -0,0 +1,7 @@
1
+ """Empty namespace, just exists to keep things tidy"""
2
+ try:
3
+ import pkg_resources
4
+ pkg_resources.declare_namespace(__name__)
5
+ except ImportError:
6
+ import pkgutil
7
+ __path__ = pkgutil.extend_path(__path__, __name__)
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']