Skip to content
This repository

HTTPS clone URL

Subversion checkout URL

You can clone with HTTPS or Subversion.

Download ZIP
Browse code

Fixed #19866 -- Added security logger and return 400 for SuspiciousOp…

…eration.

SuspiciousOperations have been differentiated into subclasses, and
are now logged to a 'django.security.*' logger. SuspiciousOperations
that reach django.core.handlers.base.BaseHandler will now return a 400
instead of a 500.

Thanks to tiwoc for the report, and Carl Meyer and Donald Stufft
for review.
commit d228c1192ed59ab0114d9eba82ac99df611652d2 1 parent 36d47f7
Preston Holmes authored May 15, 2013

Showing 38 changed files with 363 additions and 77 deletions. Show diff stats Hide diff stats

  1. 3  django/conf/urls/__init__.py
  2. 6  django/contrib/admin/exceptions.py
  3. 3  django/contrib/admin/views/main.py
  4. 36  django/contrib/auth/tests/test_views.py
  5. 6  django/contrib/formtools/exceptions.py
  6. 4  django/contrib/formtools/wizard/storage/cookie.py
  7. 14  django/contrib/sessions/backends/base.py
  8. 9  django/contrib/sessions/backends/cached_db.py
  9. 10  django/contrib/sessions/backends/db.py
  10. 12  django/contrib/sessions/backends/file.py
  11. 11  django/contrib/sessions/exceptions.py
  12. 20  django/contrib/sessions/tests.py
  13. 34  django/core/exceptions.py
  14. 4  django/core/files/storage.py
  15. 20  django/core/handlers/base.py
  16. 3  django/core/urlresolvers.py
  17. 4  django/http/multipartparser.py
  18. 4  django/http/request.py
  19. 4  django/http/response.py
  20. 20  django/test/utils.py
  21. 5  django/utils/log.py
  22. 15  django/views/defaults.py
  23. 21  docs/ref/exceptions.txt
  24. 7  docs/releases/1.6.txt
  25. 22  docs/topics/http/views.txt
  26. 31  docs/topics/logging.txt
  27. 34  tests/admin_views/tests.py
  28. 9  tests/handlers/tests.py
  29. 1  tests/handlers/urls.py
  30. 4  tests/handlers/views.py
  31. 24  tests/logging_tests/tests.py
  32. 10  tests/logging_tests/urls.py
  33. 11  tests/logging_tests/views.py
  34. 6  tests/test_client_regress/tests.py
  35. 7  tests/test_client_regress/views.py
  36. 4  tests/urlpatterns_reverse/tests.py
  37. 1  tests/urlpatterns_reverse/urls_error_handlers.py
  38. 1  tests/urlpatterns_reverse/urls_error_handlers_callables.py
3  django/conf/urls/__init__.py
@@ -5,8 +5,9 @@
5 5
 from django.utils import six
6 6
 
7 7
 
8  
-__all__ = ['handler403', 'handler404', 'handler500', 'include', 'patterns', 'url']
  8
+__all__ = ['handler400', 'handler403', 'handler404', 'handler500', 'include', 'patterns', 'url']
9 9
 
  10
+handler400 = 'django.views.defaults.bad_request'
10 11
 handler403 = 'django.views.defaults.permission_denied'
11 12
 handler404 = 'django.views.defaults.page_not_found'
12 13
 handler500 = 'django.views.defaults.server_error'
6  django/contrib/admin/exceptions.py
... ...
@@ -0,0 +1,6 @@
  1
+from django.core.exceptions import SuspiciousOperation
  2
+
  3
+
  4
+class DisallowedModelAdminLookup(SuspiciousOperation):
  5
+    """Invalid filter was passed to admin view via URL querystring"""
  6
+    pass
3  django/contrib/admin/views/main.py
@@ -14,6 +14,7 @@
14 14
 from django.utils.http import urlencode
15 15
 
16 16
 from django.contrib.admin import FieldListFilter
  17
+from django.contrib.admin.exceptions import DisallowedModelAdminLookup
17 18
 from django.contrib.admin.options import IncorrectLookupParameters
18 19
 from django.contrib.admin.util import (quote, get_fields_from_path,
19 20
     lookup_needs_distinct, prepare_lookup_value)
@@ -128,7 +129,7 @@ def get_filters(self, request):
128 129
                 lookup_params[force_str(key)] = value
129 130
 
130 131
             if not self.model_admin.lookup_allowed(key, value):
131  
-                raise SuspiciousOperation("Filtering by %s not allowed" % key)
  132
+                raise DisallowedModelAdminLookup("Filtering by %s not allowed" % key)
132 133
 
133 134
         filter_specs = []
134 135
         if self.list_filter:
36  django/contrib/auth/tests/test_views.py
@@ -10,7 +10,6 @@
10 10
 from django.contrib.sites.models import Site, RequestSite
11 11
 from django.contrib.auth.models import User
12 12
 from django.core import mail
13  
-from django.core.exceptions import SuspiciousOperation
14 13
 from django.core.urlresolvers import reverse, NoReverseMatch
15 14
 from django.http import QueryDict, HttpRequest
16 15
 from django.utils.encoding import force_text
@@ -18,7 +17,7 @@
18 17
 from django.utils.http import urlquote
19 18
 from django.utils._os import upath
20 19
 from django.test import TestCase
21  
-from django.test.utils import override_settings
  20
+from django.test.utils import override_settings, patch_logger
22 21
 from django.middleware.csrf import CsrfViewMiddleware
23 22
 from django.contrib.sessions.middleware import SessionMiddleware
24 23
 
@@ -155,23 +154,28 @@ def test_poisoned_http_host(self):
155 154
         # produce a meaningful reset URL, we need to be certain that the
156 155
         # HTTP_HOST header isn't poisoned. This is done as a check when get_host()
157 156
         # is invoked, but we check here as a practical consequence.
158  
-        with self.assertRaises(SuspiciousOperation):
159  
-            self.client.post('/password_reset/',
160  
-                {'email': 'staffmember@example.com'},
161  
-                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
162  
-            )
163  
-        self.assertEqual(len(mail.outbox), 0)
  157
+        with patch_logger('django.security.DisallowedHost', 'error') as logger_calls:
  158
+            response = self.client.post('/password_reset/',
  159
+                    {'email': 'staffmember@example.com'},
  160
+                    HTTP_HOST='www.example:dr.frankenstein@evil.tld'
  161
+                )
  162
+            self.assertEqual(response.status_code, 400)
  163
+            self.assertEqual(len(mail.outbox), 0)
  164
+            self.assertEqual(len(logger_calls), 1)
164 165
 
165 166
     # Skip any 500 handler action (like sending more mail...)
166 167
     @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True)
167 168
     def test_poisoned_http_host_admin_site(self):
168 169
         "Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
169  
-        with self.assertRaises(SuspiciousOperation):
170  
-            self.client.post('/admin_password_reset/',
171  
-                {'email': 'staffmember@example.com'},
172  
-                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
173  
-            )
174  
-        self.assertEqual(len(mail.outbox), 0)
  170
+        with patch_logger('django.security.DisallowedHost', 'error') as logger_calls:
  171
+            response = self.client.post('/admin_password_reset/',
  172
+                    {'email': 'staffmember@example.com'},
  173
+                    HTTP_HOST='www.example:dr.frankenstein@evil.tld'
  174
+                )
  175
+            self.assertEqual(response.status_code, 400)
  176
+            self.assertEqual(len(mail.outbox), 0)
  177
+            self.assertEqual(len(logger_calls), 1)
  178
+
175 179
 
176 180
     def _test_confirm_start(self):
177 181
         # Start by creating the email
@@ -678,5 +682,7 @@ def test_changelist_disallows_password_lookups(self):
678 682
         self.login()
679 683
 
680 684
         # A lookup that tries to filter on password isn't OK
681  
-        with self.assertRaises(SuspiciousOperation):
  685
+        with patch_logger('django.security.DisallowedModelAdminLookup', 'error') as logger_calls:
682 686
             response = self.client.get('/admin/auth/user/?password__startswith=sha1$')
  687
+            self.assertEqual(response.status_code, 400)
  688
+            self.assertEqual(len(logger_calls), 1)
6  django/contrib/formtools/exceptions.py
... ...
@@ -0,0 +1,6 @@
  1
+from django.core.exceptions import SuspiciousOperation
  2
+
  3
+
  4
+class WizardViewCookieModified(SuspiciousOperation):
  5
+    """Signature of cookie modified"""
  6
+    pass
4  django/contrib/formtools/wizard/storage/cookie.py
... ...
@@ -1,8 +1,8 @@
@@ -21,7 +21,7 @@ def load_data(self):
14  django/contrib/sessions/backends/base.py
@@ -2,6 +2,8 @@
2 2
 
3 3
 import base64
4 4
 from datetime import datetime, timedelta
  5
+import logging
  6
+
5 7
 try:
6 8
     from django.utils.six.moves import cPickle as pickle
7 9
 except ImportError:
@@ -14,7 +16,9 @@
14 16
 from django.utils.crypto import get_random_string
15 17
 from django.utils.crypto import salted_hmac
16 18
 from django.utils import timezone
17  
-from django.utils.encoding import force_bytes
  19
+from django.utils.encoding import force_bytes, force_text
  20
+
  21
+from django.contrib.sessions.exceptions import SuspiciousSession
18 22
 
19 23
 # session_key should not be case sensitive because some backends can store it
20 24
 # on case insensitive file systems.
@@ -94,12 +98,16 @@ def decode(self, session_data):
94 98
             hash, pickled = encoded_data.split(b':', 1)
95 99
             expected_hash = self._hash(pickled)
96 100
             if not constant_time_compare(hash.decode(), expected_hash):
97  
-                raise SuspiciousOperation("Session data corrupted")
  101
+                raise SuspiciousSession("Session data corrupted")
98 102
             else:
99 103
                 return pickle.loads(pickled)
100  
-        except Exception:
  104
+        except Exception as e:
101 105
             # ValueError, SuspiciousOperation, unpickling exceptions. If any of
102 106
             # these happen, just return an empty dictionary (an empty session).
  107
+            if isinstance(e, SuspiciousOperation):
  108
+                logger = logging.getLogger('django.security.%s' %
  109
+                        e.__class__.__name__)
  110
+                logger.warning(force_text(e))
103 111
             return {}
104 112
 
105 113
     def update(self, dict_):
9  django/contrib/sessions/backends/cached_db.py
@@ -2,10 +2,13 @@
2 2
 Cached, database-backed sessions.
3 3
 """
4 4
 
  5
+import logging
  6
+
5 7
 from django.contrib.sessions.backends.db import SessionStore as DBStore
6 8
 from django.core.cache import cache
7 9
 from django.core.exceptions import SuspiciousOperation
8 10
 from django.utils import timezone
  11
+from django.utils.encoding import force_text
9 12
 
10 13
 KEY_PREFIX = "django.contrib.sessions.cached_db"
11 14
 
@@ -41,7 +44,11 @@ def load(self):
41 44
                 data = self.decode(s.session_data)
42 45
                 cache.set(self.cache_key, data,
43 46
                     self.get_expiry_age(expiry=s.expire_date))
44  
-            except (Session.DoesNotExist, SuspiciousOperation):
  47
+            except (Session.DoesNotExist, SuspiciousOperation) as e:
  48
+                if isinstance(e, SuspiciousOperation):
  49
+                    logger = logging.getLogger('django.security.%s' %
  50
+                            e.__class__.__name__)
  51
+                    logger.warning(force_text(e))
45 52
                 self.create()
46 53
                 data = {}
47 54
         return data
10  django/contrib/sessions/backends/db.py
... ...
@@ -1,8 +1,10 @@
  1
+import logging
  2
+
1 3
 from django.contrib.sessions.backends.base import SessionBase, CreateError
2 4
 from django.core.exceptions import SuspiciousOperation
3 5
 from django.db import IntegrityError, transaction, router
4 6
 from django.utils import timezone
5  
-
  7
+from django.utils.encoding import force_text
6 8
 
7 9
 class SessionStore(SessionBase):
8 10
     """
@@ -18,7 +20,11 @@ def load(self):
18 20
                 expire_date__gt=timezone.now()
19 21
             )
20 22
             return self.decode(s.session_data)
21  
-        except (Session.DoesNotExist, SuspiciousOperation):
  23
+        except (Session.DoesNotExist, SuspiciousOperation) as e:
  24
+            if isinstance(e, SuspiciousOperation):
  25
+                logger = logging.getLogger('django.security.%s' %
  26
+                        e.__class__.__name__)
  27
+                logger.warning(force_text(e))
22 28
             self.create()
23 29
             return {}
24 30
 
12  django/contrib/sessions/backends/file.py
... ...
@@ -1,5 +1,6 @@
1 1
 import datetime
2 2
 import errno
  3
+import logging
3 4
 import os
4 5
 import shutil
5 6
 import tempfile
@@ -8,6 +9,9 @@
8 9
 from django.contrib.sessions.backends.base import SessionBase, CreateError, VALID_KEY_CHARS
9 10
 from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
10 11
 from django.utils import timezone
  12
+from django.utils.encoding import force_text
  13
+
  14
+from django.contrib.sessions.exceptions import InvalidSessionKey
11 15
 
12 16
 class SessionStore(SessionBase):
13 17
     """
@@ -48,7 +52,7 @@ def _key_to_file(self, session_key=None):
48 52
         # should always be md5s, so they should never contain directory
49 53
         # components.
50 54
         if not set(session_key).issubset(set(VALID_KEY_CHARS)):
51  
-            raise SuspiciousOperation(
  55
+            raise InvalidSessionKey(
52 56
                 "Invalid characters in session key")
53 57
 
54 58
         return os.path.join(self.storage_path, self.file_prefix + session_key)
@@ -75,7 +79,11 @@ def load(self):
75 79
             if file_data:
76 80
                 try:
77 81
                     session_data = self.decode(file_data)
78  
-                except (EOFError, SuspiciousOperation):
  82
+                except (EOFError, SuspiciousOperation) as e:
  83
+                    if isinstance(e, SuspiciousOperation):
  84
+                        logger = logging.getLogger('django.security.%s' %
  85
+                                e.__class__.__name__)
  86
+                        logger.warning(force_text(e))
79 87
                     self.create()
80 88
 
81 89
                 # Remove expired sessions.
11  django/contrib/sessions/exceptions.py
... ...
@@ -0,0 +1,11 @@
  1
+from django.core.exceptions import SuspiciousOperation
  2
+
  3
+
  4
+class InvalidSessionKey(SuspiciousOperation):
  5
+    """Invalid characters in session key"""
  6
+    pass
  7
+
  8
+
  9
+class SuspiciousSession(SuspiciousOperation):
  10
+    """The session may be tampered with"""
  11
+    pass
20  django/contrib/sessions/tests.py
... ...
@@ -1,3 +1,4 @@
  1
+import base64
1 2
 from datetime import timedelta
2 3
 import os
3 4
 import shutil
@@ -15,14 +16,16 @@
15 16
 from django.contrib.sessions.middleware import SessionMiddleware
16 17
 from django.core.cache import get_cache
17 18
 from django.core import management
18  
-from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
  19
+from django.core.exceptions import ImproperlyConfigured
19 20
 from django.http import HttpResponse
20 21
 from django.test import TestCase, RequestFactory
21  
-from django.test.utils import override_settings
  22
+from django.test.utils import override_settings, patch_logger
22 23
 from django.utils import six
23 24
 from django.utils import timezone
24 25
 from django.utils import unittest
25 26
 
  27
+from django.contrib.sessions.exceptions import InvalidSessionKey
  28
+
26 29
 
27 30
 class SessionTestsMixin(object):
28 31
     # This does not inherit from TestCase to avoid any tests being run with this
@@ -272,6 +275,15 @@ def test_decode(self):
272 275
         encoded = self.session.encode(data)
273 276
         self.assertEqual(self.session.decode(encoded), data)
274 277
 
  278
+    def test_decode_failure_logged_to_security(self):
  279
+        bad_encode = base64.b64encode(b'flaskdj:alkdjf')
  280
+        with patch_logger('django.security.SuspiciousSession', 'warning') as calls:
  281
+            self.assertEqual({}, self.session.decode(bad_encode))
  282
+            # check that the failed decode is logged
  283
+            self.assertEqual(len(calls), 1)
  284
+            self.assertTrue('corrupted' in calls[0])
  285
+
  286
+
275 287
     def test_actual_expiry(self):
276 288
         # Regression test for #19200
277 289
         old_session_key = None
@@ -411,12 +423,12 @@ def test_invalid_key_backslash(self):
411 423
         # This is tested directly on _key_to_file, as load() will swallow
412 424
         # a SuspiciousOperation in the same way as an IOError - by creating
413 425
         # a new session, making it unclear whether the slashes were detected.
414  
-        self.assertRaises(SuspiciousOperation,
  426
+        self.assertRaises(InvalidSessionKey,
415 427
                           self.backend()._key_to_file, "a\\b\\c")
416 428
 
417 429
     def test_invalid_key_forwardslash(self):
418 430
         # Ensure we don't allow directory-traversal
419  
-        self.assertRaises(SuspiciousOperation,
  431
+        self.assertRaises(InvalidSessionKey,
420 432
                           self.backend()._key_to_file, "a/b/c")
421 433
 
422 434
     @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.file")
34  django/core/exceptions.py
... ...
@@ -1,6 +1,7 @@
1 1
 """
2 2
 Global Django exception and warning classes.
3 3
 """
  4
+import logging
4 5
 from functools import reduce
5 6
 
6 7
 
@@ -9,37 +10,56 @@ class DjangoRuntimeWarning(RuntimeWarning):
9 10
 
10 11
 
11 12
 class ObjectDoesNotExist(Exception):
12  
-    "The requested object does not exist"
  13
+    """The requested object does not exist"""
13 14
     silent_variable_failure = True
14 15
 
15 16
 
16 17
 class MultipleObjectsReturned(Exception):
17  
-    "The query returned multiple objects when only one was expected."
  18
+    """The query returned multiple objects when only one was expected."""
18 19
     pass
19 20
 
20 21
 
21 22
 class SuspiciousOperation(Exception):
22  
-    "The user did something suspicious"
  23
+    """The user did something suspicious"""
  24
+
  25
+
  26
+class SuspiciousMultipartForm(SuspiciousOperation):
  27
+    """Suspect MIME request in multipart form data"""
  28
+    pass
  29
+
  30
+
  31
+class SuspiciousFileOperation(SuspiciousOperation):
  32
+    """A Suspicious filesystem operation was attempted"""
  33
+    pass
  34
+
  35
+
  36
+class DisallowedHost(SuspiciousOperation):
  37
+    """HTTP_HOST header contains invalid value"""
  38
+    pass
  39
+
  40
+
  41
+class DisallowedRedirect(SuspiciousOperation):
  42
+    """Redirect to scheme not in allowed list"""
23 43
     pass
24 44
 
25 45
 
26 46
 class PermissionDenied(Exception):
27  
-    "The user did not have permission to do that"
  47
+    """The user did not have permission to do that"""
28 48
     pass
29 49
 
30 50
 
31 51
 class ViewDoesNotExist(Exception):
32  
-    "The requested view does not exist"
  52
+    """The requested view does not exist"""
33 53
     pass
34 54
 
35 55
 
36 56
 class MiddlewareNotUsed(Exception):
37  
-    "This middleware is not used in this server configuration"
  57
+    """This middleware is not used in this server configuration"""
38 58
     pass
39 59
 
40 60
 
41 61
 class ImproperlyConfigured(Exception):
42  
-    "Django is somehow improperly configured"
  62
+    """Django is somehow improperly configured"""
43 63
     pass
44 64
 
45 65
 
4  django/core/files/storage.py
@@ -8,7 +8,7 @@
8 8
 from datetime import datetime
9 9
 
10 10
 from django.conf import settings
11  
-from django.core.exceptions import SuspiciousOperation
  11
+from django.core.exceptions import SuspiciousFileOperation
12 12
 from django.core.files import locks, File
13 13
 from django.core.files.move import file_move_safe
14 14
 from django.utils.encoding import force_text, filepath_to_uri
@@ -260,7 +260,7 @@ def path(self, name):
260 260
         try:
261 261
             path = safe_join(self.location, name)
262 262
         except ValueError:
263  
-            raise SuspiciousOperation("Attempted access to '%s' denied." % name)
  263
+            raise SuspiciousFileOperation("Attempted access to '%s' denied." % name)
264 264
         return os.path.normpath(path)
265 265
 
266 266
     def size(self, name):
20  django/core/handlers/base.py
@@ -8,7 +8,7 @@
8 8
 from django.conf import settings
9 9
 from django.core import urlresolvers
10 10
 from django.core import signals
11  
-from django.core.exceptions import MiddlewareNotUsed, PermissionDenied
  11
+from django.core.exceptions import MiddlewareNotUsed, PermissionDenied, SuspiciousOperation
12 12
 from django.db import connections, transaction
13 13
 from django.utils.encoding import force_text
14 14
 from django.utils.module_loading import import_by_path
@@ -170,11 +170,27 @@ def get_response(self, request):
170 170
                 response = self.handle_uncaught_exception(request,
171 171
                         resolver, sys.exc_info())
172 172
 
  173
+        except SuspiciousOperation as e:
  174
+            # The request logger receives events for any problematic request
  175
+            # The security logger receives events for all SuspiciousOperations
  176
+            security_logger = logging.getLogger('django.security.%s' %
  177
+                            e.__class__.__name__)
  178
+            security_logger.error(force_text(e))
  179
+
  180
+            try:
  181
+                callback, param_dict = resolver.resolve400()
  182
+                response = callback(request, **param_dict)
  183
+            except:
  184
+                signals.got_request_exception.send(
  185
+                        sender=self.__class__, request=request)
  186
+                response = self.handle_uncaught_exception(request,
  187
+                        resolver, sys.exc_info())
  188
+
173 189
         except SystemExit:
174 190
             # Allow sys.exit() to actually exit. See tickets #1023 and #4701
175 191
             raise
176 192
 
177  
-        except: # Handle everything else, including SuspiciousOperation, etc.
  193
+        except: # Handle everything else.
178 194
             # Get the exception info now, in case another exception is thrown later.
179 195
             signals.got_request_exception.send(sender=self.__class__, request=request)
180 196
             response = self.handle_uncaught_exception(request, resolver, sys.exc_info())
3  django/core/urlresolvers.py
@@ -360,6 +360,9 @@ def _resolve_special(self, view_type):
360 360
             callback = getattr(urls, 'handler%s' % view_type)
361 361
         return get_callable(callback), {}
362 362
 
  363
+    def resolve400(self):
  364
+        return self._resolve_special('400')
  365
+
363 366
     def resolve403(self):
364 367
         return self._resolve_special('403')
365 368
 
4  django/http/multipartparser.py
@@ -11,7 +11,7 @@
11 11
 import sys
12 12
 
13 13
 from django.conf import settings
14  
-from django.core.exceptions import SuspiciousOperation
  14
+from django.core.exceptions import SuspiciousMultipartForm
15 15
 from django.utils.datastructures import MultiValueDict
16 16
 from django.utils.encoding import force_text
17 17
 from django.utils import six
@@ -370,7 +370,7 @@ def _update_unget_history(self, num_bytes):
370 370
                             if current_number == num_bytes])
371 371
 
372 372
         if number_equal > 40:
373  
-            raise SuspiciousOperation(
  373
+            raise SuspiciousMultipartForm(
374 374
                 "The multipart parser got stuck, which shouldn't happen with"
375 375
                 " normal uploaded files. Check for malicious upload activity;"
376 376
                 " if there is none, report this to the Django developers."
4  django/http/request.py
@@ -14,7 +14,7 @@
14 14
 
15 15
 from django.conf import settings
16 16
 from django.core import signing
17  
-from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
  17
+from django.core.exceptions import DisallowedHost, ImproperlyConfigured
18 18
 from django.core.files import uploadhandler
19 19
 from django.http.multipartparser import MultiPartParser
20 20
 from django.utils import six
@@ -72,7 +72,7 @@ def get_host(self):
72 72
             msg = "Invalid HTTP_HOST header: %r." % host
73 73
             if domain:
74 74
                 msg += "You may need to add %r to ALLOWED_HOSTS." % domain
75  
-            raise SuspiciousOperation(msg)
  75
+            raise DisallowedHost(msg)
76 76
 
77 77
     def get_full_path(self):
78 78
         # RFC 3986 requires query string arguments to be in the ASCII range.
4  django/http/response.py
@@ -12,7 +12,7 @@
12 12
 from django.conf import settings
13 13
 from django.core import signals
14 14
 from django.core import signing
15  
-from django.core.exceptions import SuspiciousOperation
  15
+from django.core.exceptions import DisallowedRedirect
16 16
 from django.http.cookie import SimpleCookie
17 17
 from django.utils import six, timezone
18 18
 from django.utils.encoding import force_bytes, iri_to_uri
@@ -452,7 +452,7 @@ class HttpResponseRedirectBase(HttpResponse):
452 452
     def __init__(self, redirect_to, *args, **kwargs):
453 453
         parsed = urlparse(redirect_to)
454 454
         if parsed.scheme and parsed.scheme not in self.allowed_schemes:
455  
-            raise SuspiciousOperation("Unsafe redirect to URL with protocol '%s'" % parsed.scheme)
  455
+            raise DisallowedRedirect("Unsafe redirect to URL with protocol '%s'" % parsed.scheme)
456 456
         super(HttpResponseRedirectBase, self).__init__(*args, **kwargs)
457 457
         self['Location'] = iri_to_uri(redirect_to)
458 458
 
20  django/test/utils.py
... ...
@@ -1,3 +1,5 @@
  1
+from contextlib import contextmanager
  2
+import logging
1 3
 import re
2 4
 import sys
3 5
 import warnings
@@ -401,3 +403,21 @@ def tearDown(self):
401 403
 class IgnorePendingDeprecationWarningsMixin(IgnoreDeprecationWarningsMixin):
402 404
 
403 405
         warning_class = PendingDeprecationWarning
  406
+
  407
+
  408
+@contextmanager
  409
+def patch_logger(logger_name, log_level):
  410
+    """
  411
+    Context manager that takes a named logger and the logging level
  412
+    and provides a simple mock-like list of messages received
  413
+    """
  414
+    calls = []
  415
+    def replacement(msg):
  416
+        calls.append(msg)
  417
+    logger = logging.getLogger(logger_name)
  418
+    orig = getattr(logger, log_level)
  419
+    setattr(logger, log_level, replacement)
  420
+    try:
  421
+        yield calls
  422
+    finally:
  423
+        setattr(logger, log_level, orig)
5  django/utils/log.py
@@ -63,6 +63,11 @@ def emit(self, record):
63 63
             'level': 'ERROR',
64 64
             'propagate': False,
65 65
         },
  66
+        'django.security': {
  67
+            'handlers': ['mail_admins'],
  68
+            'level': 'ERROR',
  69
+            'propagate': False,
  70
+        },
66 71
         'py.warnings': {
67 72
             'handlers': ['console'],
68 73
         },
15  django/views/defaults.py
@@ -43,6 +43,21 @@ def server_error(request, template_name='500.html'):
43 43
     return http.HttpResponseServerError(template.render(Context({})))
44 44
 
45 45
 
  46
+@requires_csrf_token
  47
+def bad_request(request, template_name='400.html'):
  48
+    """
  49
+    400 error handler.
  50
+
  51
+    Templates: :template:`400.html`
  52
+    Context: None
  53
+    """
  54
+    try:
  55
+        template = loader.get_template(template_name)
  56
+    except TemplateDoesNotExist:
  57
+        return http.HttpResponseBadRequest('<h1>Bad Request (400)</h1>')
  58
+    return http.HttpResponseBadRequest(template.render(Context({})))
  59
+
  60
+
46 61
 # This can be called when CsrfViewMiddleware.process_view has not run,
47 62
 # therefore need @requires_csrf_token in case the template needs
48 63
 # {% csrf_token %}.
21  docs/ref/exceptions.txt
@@ -44,9 +44,24 @@ SuspiciousOperation
44 44
 -------------------
45 45
 .. exception:: SuspiciousOperation
46 46
 
47  
-    The :exc:`SuspiciousOperation` exception is raised when a user has performed
48  
-    an operation that should be considered suspicious from a security perspective,
49  
-    such as tampering with a session cookie.
  47
+    The :exc:`SuspiciousOperation` exception is raised when a user has
  48
+    performed an operation that should be considered suspicious from a security
  49
+    perspective, such as tampering with a session cookie. Subclasses of
  50
+    SuspiciousOperation include:
  51
+
  52
+    * DisallowedHost
  53
+    * DisallowedModelAdminLookup
  54
+    * DisallowedRedirect
  55
+    * InvalidSessionKey
  56
+    * SuspiciousFileOperation
  57
+    * SuspiciousMultipartForm
  58
+    * SuspiciousSession
  59
+    * WizardViewCookieModified
  60
+
  61
+    If a ``SuspiciousOperation`` exception reaches the WSGI handler level it is
  62
+    logged at the ``Error`` level and results in
  63
+    a :class:`~django.http.HttpResponseBadRequest`. See the :doc:`logging
  64
+    documentation </topics/logging/>` for more information.
50 65
 
51 66
 PermissionDenied
52 67
 ----------------
7  docs/releases/1.6.txt
@@ -270,6 +270,13 @@ Minor features
270 270
   stores active language in session if it is not present there. This
271 271
   prevents loss of language settings after session flush, e.g. logout.
272 272
 
  273
+* :exc:`~django.core.exceptions.SuspiciousOperation` has been differentiated
  274
+  into a number of subclasses, and each will log to a matching named logger
  275
+  under the ``django.security`` logging hierarchy. Along with this change,
  276
+  a ``handler400`` mechanism and default view are used whenever
  277
+  a ``SuspiciousOperation`` reaches the WSGI handler to return an
  278
+  ``HttpResponseBadRequest``.
  279
+
273 280
 Backwards incompatible changes in 1.6
274 281
 =====================================
275 282
 
22  docs/topics/http/views.txt
@@ -231,3 +231,25 @@ same way you can for the 404 and 500 views by specifying a ``handler403`` in
231 231
 your URLconf::
232 232
 
233 233
     handler403 = 'mysite.views.my_custom_permission_denied_view'
  234
+
  235
+.. _http_bad_request_view:
  236
+
  237
+The 400 (bad request) view
  238
+--------------------------
  239
+
  240
+When a :exc:`~django.core.exceptions.SuspiciousOperation` is raised in Django,
  241
+the it may be handled by a component of Django (for example resetting the
  242
+session data). If not specifically handled, Django will consider the current
  243
+request a 'bad request' instead of a server error.
  244
+
  245
+The view ``django.views.defaults.bad_request``, is otherwise very similar to
  246
+the ``server_error`` view, but returns with the status code 400 indicating that
  247
+the error condition was the result of a client operation.
  248
+
  249
+Like the ``server_error`` view, the default ``bad_request`` should suffice for
  250
+99% of Web applications, but if you want to override the view, you can specify
  251
+``handler400`` in your URLconf, like so::
  252
+
  253
+    handler400 = 'mysite.views.my_custom_bad_request_view'
  254
+
  255
+``bad_request`` views are also only used when :setting:`DEBUG` is ``False``.
31  docs/topics/logging.txt
@@ -394,7 +394,7 @@ requirements of logging in Web server environment.
394 394
 Loggers
395 395
 -------
396 396
 
397  
-Django provides three built-in loggers.
  397
+Django provides four built-in loggers.
398 398
 
399 399
 ``django``
400 400
 ~~~~~~~~~~
@@ -434,6 +434,35 @@ For performance reasons, SQL logging is only enabled when
434 434
 ``settings.DEBUG`` is set to ``True``, regardless of the logging
435 435
 level or handlers that are installed.
436 436
 
  437
+``django.security.*``
  438
+~~~~~~~~~~~~~~~~~~~~~~
  439
+
  440
+The security loggers will receive messages on any occurrence of
  441
+:exc:`~django.core.exceptions.SuspiciousOperation`. There is a sub-logger for
  442
+each sub-type of SuspiciousOperation. The level of the log event depends on
  443
+where the exception is handled.  Most occurrences are logged as a warning, while
  444
+any ``SuspiciousOperation`` that reaches the WSGI handler will be logged as an
  445
+error. For example, when an HTTP ``Host`` header is included in a request from
  446
+a client that does not match :setting:`ALLOWED_HOSTS`, Django will return a 400
  447
+response, and an error message will be logged to the
  448
+``django.security.DisallowedHost`` logger.
  449
+
  450
+Only the parent ``django.security`` logger is configured by default, and all
  451
+child loggers will propagate to the parent logger. The ``django.security``
  452
+logger is configured the same as the ``django.request`` logger, and any error
  453
+events will be mailed to admins. Requests resulting in a 400 response due to
  454
+a ``SuspiciousOperation`` will not be logged to the ``django.request`` logger,
  455
+but only to the ``django.security`` logger.
  456
+
  457
+To silence a particular type of SuspiciousOperation, you can override that
  458
+specific logger following this example::
  459
+
  460
+        'loggers': {
  461
+            'django.security.DisallowedHost': {
  462
+                'handlers': ['null'],
  463
+                'propagate': False,
  464
+            },
  465
+
437 466
 Handlers
438 467
 --------
439 468
 
34  tests/admin_views/tests.py
@@ -11,7 +11,6 @@
11 11
 
12 12
 from django.conf import settings, global_settings
13 13
 from django.core import mail
14  
-from django.core.exceptions import SuspiciousOperation
15 14
 from django.core.files import temp as tempfile
16 15
 from django.core.urlresolvers import reverse
17 16
 # Register auth models with the admin.
@@ -30,6 +29,7 @@
30 29
 from django.forms.util import ErrorList
31 30
 from django.template.response import TemplateResponse
32 31
 from django.test import TestCase
  32
+from django.test.utils import patch_logger
33 33
 from django.utils import formats, translation, unittest
34 34
 from django.utils.cache import get_max_age
35 35
 from django.utils.encoding import iri_to_uri, force_bytes
@@ -543,20 +543,21 @@ def testL10NDeactivated(self):
543 543
                 self.assertContains(response, '%Y-%m-%d %H:%M:%S')
544 544
 
545 545
     def test_disallowed_filtering(self):
546  
-        self.assertRaises(SuspiciousOperation,
547  
-            self.client.get, "/test_admin/admin/admin_views/album/?owner__email__startswith=fuzzy"
548  
-        )
  546
+        with patch_logger('django.security.DisallowedModelAdminLookup', 'error') as calls:
  547
+            response = self.client.get("/test_admin/admin/admin_views/album/?owner__email__startswith=fuzzy")
  548
+            self.assertEqual(response.status_code, 400)
  549
+            self.assertEqual(len(calls), 1)
549 550
 
550  
-        try:
551  
-            self.client.get("/test_admin/admin/admin_views/thing/?color__value__startswith=red")
552  
-            self.client.get("/test_admin/admin/admin_views/thing/?color__value=red")
553  
-        except SuspiciousOperation:
554  
-            self.fail("Filters are allowed if explicitly included in list_filter")
  551
+        # Filters are allowed if explicitly included in list_filter
  552
+        response = self.client.get("/test_admin/admin/admin_views/thing/?color__value__startswith=red")
  553
+        self.assertEqual(response.status_code, 200)
  554
+        response = self.client.get("/test_admin/admin/admin_views/thing/?color__value=red")
  555
+        self.assertEqual(response.status_code, 200)
555 556
 
556  
-        try:
557  
-            self.client.get("/test_admin/admin/admin_views/person/?age__gt=30")
558  
-        except SuspiciousOperation:
559  
-            self.fail("Filters should be allowed if they involve a local field without the need to whitelist them in list_filter or date_hierarchy.")
  557
+        # Filters should be allowed if they involve a local field without the
  558
+        # need to whitelist them in list_filter or date_hierarchy.
  559
+        response = self.client.get("/test_admin/admin/admin_views/person/?age__gt=30")
  560
+        self.assertEqual(response.status_code, 200)
560 561
 
561 562
         e1 = Employee.objects.create(name='Anonymous', gender=1, age=22, alive=True, code='123')
562 563
         e2 = Employee.objects.create(name='Visitor', gender=2, age=19, alive=True, code='124')
@@ -574,10 +575,9 @@ def test_allowed_filtering_15103(self):
574 575
         ForeignKey 'limit_choices_to' should be allowed, otherwise raw_id_fields
575 576
         can break.
576 577
         """
577  
-        try:
578  
-            self.client.get("/test_admin/admin/admin_views/inquisition/?leader__name=Palin&leader__age=27")
579  
-        except SuspiciousOperation:
580  
-            self.fail("Filters should be allowed if they are defined on a ForeignKey pointing to this model")
  578
+        # Filters should be allowed if they are defined on a ForeignKey pointing to this model
  579
+        response = self.client.get("/test_admin/admin/admin_views/inquisition/?leader__name=Palin&leader__age=27")
  580
+        self.assertEqual(response.status_code, 200)
581 581
 
582 582
     def test_hide_change_password(self):
583 583
         """
9  tests/handlers/tests.py
@@ -61,6 +61,7 @@ def test_no_auto_transaction(self):
61 61
             connection.settings_dict['ATOMIC_REQUESTS'] = old_atomic_requests
62 62
         self.assertContains(response, 'False')
63 63
 
  64
+
64 65
 class SignalsTests(TestCase):
65 66
     urls = 'handlers.urls'
66 67
 
@@ -89,3 +90,11 @@ def test_request_signals_streaming_response(self):
89 90
         self.assertEqual(self.signals, ['started'])
90 91
         self.assertEqual(b''.join(response.streaming_content), b"streaming content")
91 92
         self.assertEqual(self.signals, ['started', 'finished'])
  93
+
  94
+
  95
+class HandlerSuspiciousOpsTest(TestCase):
  96
+    urls = 'handlers.urls'
  97
+
  98
+    def test_suspiciousop_in_view_returns_400(self):
  99
+        response = self.client.get('/suspicious/')
  100
+        self.assertEqual(response.status_code, 400)
1  tests/handlers/urls.py
@@ -9,4 +9,5 @@
9 9
     url(r'^streaming/$', views.streaming),
10 10
     url(r'^in_transaction/$', views.in_transaction),
11 11
     url(r'^not_in_transaction/$', views.not_in_transaction),
  12
+    url(r'^suspicious/$', views.suspicious),
12 13
 )
4  tests/handlers/views.py
... ...
@@ -1,5 +1,6 @@
1 1
 from __future__ import unicode_literals
2 2
 
  3
+from django.core.exceptions import SuspiciousOperation
3 4
 from django.db import connection, transaction
4 5
 from django.http import HttpResponse, StreamingHttpResponse
5 6
 
@@ -15,3 +16,6 @@ def in_transaction(request):
15 16
 @transaction.non_atomic_requests
16 17
 def not_in_transaction(request):
17 18
     return HttpResponse(str(connection.in_atomic_block))
  19
+
  20
+def suspicious(request):
  21
+    raise SuspiciousOperation('dubious')
24  tests/logging_tests/tests.py
@@ -8,9 +8,10 @@
8 8
 from django.conf import LazySettings
9 9
 from django.core import mail
10 10
 from django.test import TestCase, RequestFactory
11  
-from django.test.utils import override_settings
  11
+from django.test.utils import override_settings, patch_logger
12 12
 from django.utils.encoding import force_text
13  
-from django.utils.log import CallbackFilter, RequireDebugFalse, RequireDebugTrue
  13
+from django.utils.log import (CallbackFilter, RequireDebugFalse,
  14
+    RequireDebugTrue)
14 15
 from django.utils.six import StringIO
15 16
 from django.utils.unittest import skipUnless
16 17
 
@@ -354,3 +355,22 @@ def test_configure_initializes_logging(self):
354 355
         settings.configure(
355 356
             LOGGING_CONFIG='logging_tests.tests.dictConfig')
356 357
         self.assertTrue(dictConfig.called)
  358
+
  359
+
  360
+class SecurityLoggerTest(TestCase):
  361
+
  362
+    urls = 'logging_tests.urls'
  363
+
  364
+    def test_suspicious_operation_creates_log_message(self):
  365
+        with self.settings(DEBUG=True):
  366
+            with patch_logger('django.security.SuspiciousOperation', 'error') as calls:
  367
+                response = self.client.get('/suspicious/')
  368
+                self.assertEqual(len(calls), 1)
  369
+                self.assertEqual(calls[0], 'dubious')
  370
+
  371
+    def test_suspicious_operation_uses_sublogger(self):
  372
+        with self.settings(DEBUG=True):
  373
+            with patch_logger('django.security.DisallowedHost', 'error') as calls:
  374
+                response = self.client.get('/suspicious_spec/')
  375
+                self.assertEqual(len(calls), 1)
  376
+                self.assertEqual(calls[0], 'dubious')
10  tests/logging_tests/urls.py
... ...
@@ -0,0 +1,10 @@
  1
+from __future__ import unicode_literals
  2
+
  3
+from django.conf.urls import patterns, url
  4
+
  5
+from . import views
  6
+
  7
+urlpatterns = patterns('',
  8
+    url(r'^suspicious/$', views.suspicious),
  9
+    url(r'^suspicious_spec/$', views.suspicious_spec),
  10
+)
11  tests/logging_tests/views.py
... ...
@@ -0,0 +1,11 @@
  1
+from __future__ import unicode_literals
  2
+
  3
+from django.core.exceptions import SuspiciousOperation, DisallowedHost
  4
+
  5
+
  6
+def suspicious(request):
  7
+    raise SuspiciousOperation('dubious')
  8
+
  9
+
  10
+def suspicious_spec(request):
  11
+    raise DisallowedHost('dubious')
6  tests/test_client_regress/tests.py
@@ -7,7 +7,6 @@
7 7
 import os
8 8
 
9 9
 from django.conf import settings
10  
-from django.core.exceptions import SuspiciousOperation
11 10
 from django.core.urlresolvers import reverse
12 11
 from django.template import (TemplateDoesNotExist, TemplateSyntaxError,
13 12
     Context, Template, loader)
@@ -20,6 +19,7 @@
20 19
 from django.utils.translation import ugettext_lazy
21 20
 from django.http import HttpResponse
22 21
 
  22
+from .views import CustomTestException
23 23
 
24 24
 @override_settings(
25 25
     TEMPLATE_DIRS=(os.path.join(os.path.dirname(upath(__file__)), 'templates'),)
@@ -619,7 +619,7 @@ def test_exception_cleared(self):
619 619
         try:
620 620
             response = self.client.get("/test_client_regress/staff_only/")
621 621
             self.fail("General users should not be able to visit this page")
622  
-        except SuspiciousOperation:
  622
+        except CustomTestException:
623 623
             pass
624 624
 
625 625
         # At this point, an exception has been raised, and should be cleared.
@@ -629,7 +629,7 @@ def test_exception_cleared(self):
629 629
         self.assertTrue(login, 'Could not log in')
630 630
         try:
631 631
             self.client.get("/test_client_regress/staff_only/")
632  
-        except SuspiciousOperation:
  632
+        except CustomTestException:
633 633
             self.fail("Staff should be able to visit this page")
634 634
 
635 635
 
7  tests/test_client_regress/views.py
@@ -3,12 +3,15 @@
3 3
 from django.conf import settings
4 4
 from django.contrib.auth.decorators import login_required
5 5
 from django.http import HttpResponse, HttpResponseRedirect
6  
-from django.core.exceptions import SuspiciousOperation
7 6
 from django.shortcuts import render_to_response
8 7
 from django.core.serializers.json import DjangoJSONEncoder
9 8
 from django.test.client import CONTENT_TYPE_RE
10 9
 from django.template import RequestContext
11 10
 
  11
+
  12
+class CustomTestException(Exception):
  13
+    pass
  14
+
12 15
 def no_template_view(request):
13 16
     "A simple view that expects a GET request, and returns a rendered template"
14 17
     return HttpResponse("No template used. Sample content: twice once twice. Content ends.")
@@ -18,7 +21,7 @@ def staff_only_view(request):
18 21
     if request.user.is_staff:
19 22
         return HttpResponse('')
20 23
     else:
21  
-        raise SuspiciousOperation()
  24
+        raise CustomTestException()
22 25
 
23 26
 def get_view(request):
24 27
     "A simple login protected view"
4  tests/urlpatterns_reverse/tests.py
@@ -516,7 +516,7 @@ def test_reverse_outer_in_streaming(self):
516 516
             b''.join(self.client.get('/second_test/'))
517 517
 
518 518
 class ErrorHandlerResolutionTests(TestCase):
519  
-    """Tests for handler404 and handler500"""
  519
+    """Tests for handler400, handler404 and handler500"""
520 520
 
521 521
     def setUp(self):
522 522
         from django.core.urlresolvers import RegexURLResolver
@@ -528,12 +528,14 @@ def setUp(self):
528 528
     def test_named_handlers(self):
529 529
         from .views import empty_view
530 530
         handler = (empty_view, {})
  531
+        self.assertEqual(self.resolver.resolve400(), handler)
531 532
         self.assertEqual(self.resolver.resolve404(), handler)
532 533
         self.assertEqual(self.resolver.resolve500(), handler)
533 534
 
534 535
     def test_callable_handers(self):
535 536
         from .views import empty_view
536 537
         handler = (empty_view, {})
  538
+        self.assertEqual(self.callable_resolver.resolve400(), handler)
537 539
         self.assertEqual(self.callable_resolver.resolve404(), handler)
538 540
         self.assertEqual(self.callable_resolver.resolve500(), handler)
539 541
 
1  tests/urlpatterns_reverse/urls_error_handlers.py
@@ -4,5 +4,6 @@
4 4
 
5 5
 urlpatterns = patterns('')
6 6
 
  7
+handler400 = 'urlpatterns_reverse.views.empty_view'
7 8
 handler404 = 'urlpatterns_reverse.views.empty_view'
8 9
 handler500 = 'urlpatterns_reverse.views.empty_view'
1  tests/urlpatterns_reverse/urls_error_handlers_callables.py
@@ -9,5 +9,6 @@
9 9
 
10 10
 urlpatterns = patterns('')
11 11
 
  12
+handler400 = empty_view
12 13
 handler404 = empty_view
13 14
 handler500 = empty_view

0 notes on commit d228c11

Please sign in to comment.
Something went wrong with that request. Please try again.