Browse Source

Fixed #19866 -- Added security logger and return 400 for SuspiciousOperation.

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.
Preston Holmes 12 years ago
parent
commit
d228c1192e
38 changed files with 363 additions and 77 deletions
  1. 2 1
      django/conf/urls/__init__.py
  2. 6 0
      django/contrib/admin/exceptions.py
  3. 2 1
      django/contrib/admin/views/main.py
  4. 21 15
      django/contrib/auth/tests/test_views.py
  5. 6 0
      django/contrib/formtools/exceptions.py
  6. 2 2
      django/contrib/formtools/wizard/storage/cookie.py
  7. 11 3
      django/contrib/sessions/backends/base.py
  8. 8 1
      django/contrib/sessions/backends/cached_db.py
  9. 8 2
      django/contrib/sessions/backends/db.py
  10. 10 2
      django/contrib/sessions/backends/file.py
  11. 11 0
      django/contrib/sessions/exceptions.py
  12. 16 4
      django/contrib/sessions/tests.py
  13. 27 7
      django/core/exceptions.py
  14. 2 2
      django/core/files/storage.py
  15. 18 2
      django/core/handlers/base.py
  16. 3 0
      django/core/urlresolvers.py
  17. 2 2
      django/http/multipartparser.py
  18. 2 2
      django/http/request.py
  19. 2 2
      django/http/response.py
  20. 20 0
      django/test/utils.py
  21. 5 0
      django/utils/log.py
  22. 15 0
      django/views/defaults.py
  23. 18 3
      docs/ref/exceptions.txt
  24. 7 0
      docs/releases/1.6.txt
  25. 22 0
      docs/topics/http/views.txt
  26. 30 1
      docs/topics/logging.txt
  27. 17 17
      tests/admin_views/tests.py
  28. 9 0
      tests/handlers/tests.py
  29. 1 0
      tests/handlers/urls.py
  30. 4 0
      tests/handlers/views.py
  31. 22 2
      tests/logging_tests/tests.py
  32. 10 0
      tests/logging_tests/urls.py
  33. 11 0
      tests/logging_tests/views.py
  34. 3 3
      tests/test_client_regress/tests.py
  35. 5 2
      tests/test_client_regress/views.py
  36. 3 1
      tests/urlpatterns_reverse/tests.py
  37. 1 0
      tests/urlpatterns_reverse/urls_error_handlers.py
  38. 1 0
      tests/urlpatterns_reverse/urls_error_handlers_callables.py

+ 2 - 1
django/conf/urls/__init__.py

@@ -5,8 +5,9 @@ from django.utils.importlib import import_module
 from django.utils import six
 
 
-__all__ = ['handler403', 'handler404', 'handler500', 'include', 'patterns', 'url']
+__all__ = ['handler400', 'handler403', 'handler404', 'handler500', 'include', 'patterns', 'url']
 
+handler400 = 'django.views.defaults.bad_request'
 handler403 = 'django.views.defaults.permission_denied'
 handler404 = 'django.views.defaults.page_not_found'
 handler500 = 'django.views.defaults.server_error'

+ 6 - 0
django/contrib/admin/exceptions.py

@@ -0,0 +1,6 @@
+from django.core.exceptions import SuspiciousOperation
+
+
+class DisallowedModelAdminLookup(SuspiciousOperation):
+    """Invalid filter was passed to admin view via URL querystring"""
+    pass

+ 2 - 1
django/contrib/admin/views/main.py

@@ -14,6 +14,7 @@ from django.utils.translation import ugettext, ugettext_lazy
 from django.utils.http import urlencode
 
 from django.contrib.admin import FieldListFilter
+from django.contrib.admin.exceptions import DisallowedModelAdminLookup
 from django.contrib.admin.options import IncorrectLookupParameters
 from django.contrib.admin.util import (quote, get_fields_from_path,
     lookup_needs_distinct, prepare_lookup_value)
@@ -128,7 +129,7 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
                 lookup_params[force_str(key)] = value
 
             if not self.model_admin.lookup_allowed(key, value):
-                raise SuspiciousOperation("Filtering by %s not allowed" % key)
+                raise DisallowedModelAdminLookup("Filtering by %s not allowed" % key)
 
         filter_specs = []
         if self.list_filter:

+ 21 - 15
django/contrib/auth/tests/test_views.py

@@ -10,7 +10,6 @@ from django.conf import global_settings, settings
 from django.contrib.sites.models import Site, RequestSite
 from django.contrib.auth.models import User
 from django.core import mail
-from django.core.exceptions import SuspiciousOperation
 from django.core.urlresolvers import reverse, NoReverseMatch
 from django.http import QueryDict, HttpRequest
 from django.utils.encoding import force_text
@@ -18,7 +17,7 @@ from django.utils.html import escape
 from django.utils.http import urlquote
 from django.utils._os import upath
 from django.test import TestCase
-from django.test.utils import override_settings
+from django.test.utils import override_settings, patch_logger
 from django.middleware.csrf import CsrfViewMiddleware
 from django.contrib.sessions.middleware import SessionMiddleware
 
@@ -155,23 +154,28 @@ class PasswordResetTest(AuthViewsTestCase):
         # produce a meaningful reset URL, we need to be certain that the
         # HTTP_HOST header isn't poisoned. This is done as a check when get_host()
         # is invoked, but we check here as a practical consequence.
-        with self.assertRaises(SuspiciousOperation):
-            self.client.post('/password_reset/',
-                {'email': 'staffmember@example.com'},
-                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
-            )
-        self.assertEqual(len(mail.outbox), 0)
+        with patch_logger('django.security.DisallowedHost', 'error') as logger_calls:
+            response = self.client.post('/password_reset/',
+                    {'email': 'staffmember@example.com'},
+                    HTTP_HOST='www.example:dr.frankenstein@evil.tld'
+                )
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(len(mail.outbox), 0)
+            self.assertEqual(len(logger_calls), 1)
 
     # Skip any 500 handler action (like sending more mail...)
     @override_settings(DEBUG_PROPAGATE_EXCEPTIONS=True)
     def test_poisoned_http_host_admin_site(self):
         "Poisoned HTTP_HOST headers can't be used for reset emails on admin views"
-        with self.assertRaises(SuspiciousOperation):
-            self.client.post('/admin_password_reset/',
-                {'email': 'staffmember@example.com'},
-                HTTP_HOST='www.example:dr.frankenstein@evil.tld'
-            )
-        self.assertEqual(len(mail.outbox), 0)
+        with patch_logger('django.security.DisallowedHost', 'error') as logger_calls:
+            response = self.client.post('/admin_password_reset/',
+                    {'email': 'staffmember@example.com'},
+                    HTTP_HOST='www.example:dr.frankenstein@evil.tld'
+                )
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(len(mail.outbox), 0)
+            self.assertEqual(len(logger_calls), 1)
+
 
     def _test_confirm_start(self):
         # Start by creating the email
@@ -678,5 +682,7 @@ class ChangelistTests(AuthViewsTestCase):
         self.login()
 
         # A lookup that tries to filter on password isn't OK
-        with self.assertRaises(SuspiciousOperation):
+        with patch_logger('django.security.DisallowedModelAdminLookup', 'error') as logger_calls:
             response = self.client.get('/admin/auth/user/?password__startswith=sha1$')
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(len(logger_calls), 1)

+ 6 - 0
django/contrib/formtools/exceptions.py

@@ -0,0 +1,6 @@
+from django.core.exceptions import SuspiciousOperation
+
+
+class WizardViewCookieModified(SuspiciousOperation):
+    """Signature of cookie modified"""
+    pass

+ 2 - 2
django/contrib/formtools/wizard/storage/cookie.py

@@ -1,8 +1,8 @@
 import json
 
-from django.core.exceptions import SuspiciousOperation
 from django.core.signing import BadSignature
 
+from django.contrib.formtools.exceptions import WizardViewCookieModified
 from django.contrib.formtools.wizard import storage
 
 
@@ -21,7 +21,7 @@ class CookieStorage(storage.BaseStorage):
         except KeyError:
             data = None
         except BadSignature:
-            raise SuspiciousOperation('WizardView cookie manipulated')
+            raise WizardViewCookieModified('WizardView cookie manipulated')
         if data is None:
             return None
         return json.loads(data, cls=json.JSONDecoder)

+ 11 - 3
django/contrib/sessions/backends/base.py

@@ -2,6 +2,8 @@ from __future__ import unicode_literals
 
 import base64
 from datetime import datetime, timedelta
+import logging
+
 try:
     from django.utils.six.moves import cPickle as pickle
 except ImportError:
@@ -14,7 +16,9 @@ from django.utils.crypto import constant_time_compare
 from django.utils.crypto import get_random_string
 from django.utils.crypto import salted_hmac
 from django.utils import timezone
-from django.utils.encoding import force_bytes
+from django.utils.encoding import force_bytes, force_text
+
+from django.contrib.sessions.exceptions import SuspiciousSession
 
 # session_key should not be case sensitive because some backends can store it
 # on case insensitive file systems.
@@ -94,12 +98,16 @@ class SessionBase(object):
             hash, pickled = encoded_data.split(b':', 1)
             expected_hash = self._hash(pickled)
             if not constant_time_compare(hash.decode(), expected_hash):
-                raise SuspiciousOperation("Session data corrupted")
+                raise SuspiciousSession("Session data corrupted")
             else:
                 return pickle.loads(pickled)
-        except Exception:
+        except Exception as e:
             # ValueError, SuspiciousOperation, unpickling exceptions. If any of
             # these happen, just return an empty dictionary (an empty session).
+            if isinstance(e, SuspiciousOperation):
+                logger = logging.getLogger('django.security.%s' %
+                        e.__class__.__name__)
+                logger.warning(force_text(e))
             return {}
 
     def update(self, dict_):

+ 8 - 1
django/contrib/sessions/backends/cached_db.py

@@ -2,10 +2,13 @@
 Cached, database-backed sessions.
 """
 
+import logging
+
 from django.contrib.sessions.backends.db import SessionStore as DBStore
 from django.core.cache import cache
 from django.core.exceptions import SuspiciousOperation
 from django.utils import timezone
+from django.utils.encoding import force_text
 
 KEY_PREFIX = "django.contrib.sessions.cached_db"
 
@@ -41,7 +44,11 @@ class SessionStore(DBStore):
                 data = self.decode(s.session_data)
                 cache.set(self.cache_key, data,
                     self.get_expiry_age(expiry=s.expire_date))
-            except (Session.DoesNotExist, SuspiciousOperation):
+            except (Session.DoesNotExist, SuspiciousOperation) as e:
+                if isinstance(e, SuspiciousOperation):
+                    logger = logging.getLogger('django.security.%s' %
+                            e.__class__.__name__)
+                    logger.warning(force_text(e))
                 self.create()
                 data = {}
         return data

+ 8 - 2
django/contrib/sessions/backends/db.py

@@ -1,8 +1,10 @@
+import logging
+
 from django.contrib.sessions.backends.base import SessionBase, CreateError
 from django.core.exceptions import SuspiciousOperation
 from django.db import IntegrityError, transaction, router
 from django.utils import timezone
-
+from django.utils.encoding import force_text
 
 class SessionStore(SessionBase):
     """
@@ -18,7 +20,11 @@ class SessionStore(SessionBase):
                 expire_date__gt=timezone.now()
             )
             return self.decode(s.session_data)
-        except (Session.DoesNotExist, SuspiciousOperation):
+        except (Session.DoesNotExist, SuspiciousOperation) as e:
+            if isinstance(e, SuspiciousOperation):
+                logger = logging.getLogger('django.security.%s' %
+                        e.__class__.__name__)
+                logger.warning(force_text(e))
             self.create()
             return {}
 

+ 10 - 2
django/contrib/sessions/backends/file.py

@@ -1,5 +1,6 @@
 import datetime
 import errno
+import logging
 import os
 import shutil
 import tempfile
@@ -8,6 +9,9 @@ from django.conf import settings
 from django.contrib.sessions.backends.base import SessionBase, CreateError, VALID_KEY_CHARS
 from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
 from django.utils import timezone
+from django.utils.encoding import force_text
+
+from django.contrib.sessions.exceptions import InvalidSessionKey
 
 class SessionStore(SessionBase):
     """
@@ -48,7 +52,7 @@ class SessionStore(SessionBase):
         # should always be md5s, so they should never contain directory
         # components.
         if not set(session_key).issubset(set(VALID_KEY_CHARS)):
-            raise SuspiciousOperation(
+            raise InvalidSessionKey(
                 "Invalid characters in session key")
 
         return os.path.join(self.storage_path, self.file_prefix + session_key)
@@ -75,7 +79,11 @@ class SessionStore(SessionBase):
             if file_data:
                 try:
                     session_data = self.decode(file_data)
-                except (EOFError, SuspiciousOperation):
+                except (EOFError, SuspiciousOperation) as e:
+                    if isinstance(e, SuspiciousOperation):
+                        logger = logging.getLogger('django.security.%s' %
+                                e.__class__.__name__)
+                        logger.warning(force_text(e))
                     self.create()
 
                 # Remove expired sessions.

+ 11 - 0
django/contrib/sessions/exceptions.py

@@ -0,0 +1,11 @@
+from django.core.exceptions import SuspiciousOperation
+
+
+class InvalidSessionKey(SuspiciousOperation):
+    """Invalid characters in session key"""
+    pass
+
+
+class SuspiciousSession(SuspiciousOperation):
+    """The session may be tampered with"""
+    pass

+ 16 - 4
django/contrib/sessions/tests.py

@@ -1,3 +1,4 @@
+import base64
 from datetime import timedelta
 import os
 import shutil
@@ -15,14 +16,16 @@ from django.contrib.sessions.models import Session
 from django.contrib.sessions.middleware import SessionMiddleware
 from django.core.cache import get_cache
 from django.core import management
-from django.core.exceptions import ImproperlyConfigured, SuspiciousOperation
+from django.core.exceptions import ImproperlyConfigured
 from django.http import HttpResponse
 from django.test import TestCase, RequestFactory
-from django.test.utils import override_settings
+from django.test.utils import override_settings, patch_logger
 from django.utils import six
 from django.utils import timezone
 from django.utils import unittest
 
+from django.contrib.sessions.exceptions import InvalidSessionKey
+
 
 class SessionTestsMixin(object):
     # This does not inherit from TestCase to avoid any tests being run with this
@@ -272,6 +275,15 @@ class SessionTestsMixin(object):
         encoded = self.session.encode(data)
         self.assertEqual(self.session.decode(encoded), data)
 
+    def test_decode_failure_logged_to_security(self):
+        bad_encode = base64.b64encode(b'flaskdj:alkdjf')
+        with patch_logger('django.security.SuspiciousSession', 'warning') as calls:
+            self.assertEqual({}, self.session.decode(bad_encode))
+            # check that the failed decode is logged
+            self.assertEqual(len(calls), 1)
+            self.assertTrue('corrupted' in calls[0])
+
+
     def test_actual_expiry(self):
         # Regression test for #19200
         old_session_key = None
@@ -411,12 +423,12 @@ class FileSessionTests(SessionTestsMixin, unittest.TestCase):
         # This is tested directly on _key_to_file, as load() will swallow
         # a SuspiciousOperation in the same way as an IOError - by creating
         # a new session, making it unclear whether the slashes were detected.
-        self.assertRaises(SuspiciousOperation,
+        self.assertRaises(InvalidSessionKey,
                           self.backend()._key_to_file, "a\\b\\c")
 
     def test_invalid_key_forwardslash(self):
         # Ensure we don't allow directory-traversal
-        self.assertRaises(SuspiciousOperation,
+        self.assertRaises(InvalidSessionKey,
                           self.backend()._key_to_file, "a/b/c")
 
     @override_settings(SESSION_ENGINE="django.contrib.sessions.backends.file")

+ 27 - 7
django/core/exceptions.py

@@ -1,6 +1,7 @@
 """
 Global Django exception and warning classes.
 """
+import logging
 from functools import reduce
 
 
@@ -9,37 +10,56 @@ class DjangoRuntimeWarning(RuntimeWarning):
 
 
 class ObjectDoesNotExist(Exception):
-    "The requested object does not exist"
+    """The requested object does not exist"""
     silent_variable_failure = True
 
 
 class MultipleObjectsReturned(Exception):
-    "The query returned multiple objects when only one was expected."
+    """The query returned multiple objects when only one was expected."""
     pass
 
 
 class SuspiciousOperation(Exception):
-    "The user did something suspicious"
+    """The user did something suspicious"""
+
+
+class SuspiciousMultipartForm(SuspiciousOperation):
+    """Suspect MIME request in multipart form data"""
+    pass
+
+
+class SuspiciousFileOperation(SuspiciousOperation):
+    """A Suspicious filesystem operation was attempted"""
+    pass
+
+
+class DisallowedHost(SuspiciousOperation):
+    """HTTP_HOST header contains invalid value"""
+    pass
+
+
+class DisallowedRedirect(SuspiciousOperation):
+    """Redirect to scheme not in allowed list"""
     pass
 
 
 class PermissionDenied(Exception):
-    "The user did not have permission to do that"
+    """The user did not have permission to do that"""
     pass
 
 
 class ViewDoesNotExist(Exception):
-    "The requested view does not exist"
+    """The requested view does not exist"""
     pass
 
 
 class MiddlewareNotUsed(Exception):
-    "This middleware is not used in this server configuration"
+    """This middleware is not used in this server configuration"""
     pass
 
 
 class ImproperlyConfigured(Exception):
-    "Django is somehow improperly configured"
+    """Django is somehow improperly configured"""
     pass
 
 

+ 2 - 2
django/core/files/storage.py

@@ -8,7 +8,7 @@ import itertools
 from datetime import datetime
 
 from django.conf import settings
-from django.core.exceptions import SuspiciousOperation
+from django.core.exceptions import SuspiciousFileOperation
 from django.core.files import locks, File
 from django.core.files.move import file_move_safe
 from django.utils.encoding import force_text, filepath_to_uri
@@ -260,7 +260,7 @@ class FileSystemStorage(Storage):
         try:
             path = safe_join(self.location, name)
         except ValueError:
-            raise SuspiciousOperation("Attempted access to '%s' denied." % name)
+            raise SuspiciousFileOperation("Attempted access to '%s' denied." % name)
         return os.path.normpath(path)
 
     def size(self, name):

+ 18 - 2
django/core/handlers/base.py

@@ -8,7 +8,7 @@ from django import http
 from django.conf import settings
 from django.core import urlresolvers
 from django.core import signals
-from django.core.exceptions import MiddlewareNotUsed, PermissionDenied
+from django.core.exceptions import MiddlewareNotUsed, PermissionDenied, SuspiciousOperation
 from django.db import connections, transaction
 from django.utils.encoding import force_text
 from django.utils.module_loading import import_by_path
@@ -170,11 +170,27 @@ class BaseHandler(object):
                 response = self.handle_uncaught_exception(request,
                         resolver, sys.exc_info())
 
+        except SuspiciousOperation as e:
+            # The request logger receives events for any problematic request
+            # The security logger receives events for all SuspiciousOperations
+            security_logger = logging.getLogger('django.security.%s' %
+                            e.__class__.__name__)
+            security_logger.error(force_text(e))
+
+            try:
+                callback, param_dict = resolver.resolve400()
+                response = callback(request, **param_dict)
+            except:
+                signals.got_request_exception.send(
+                        sender=self.__class__, request=request)
+                response = self.handle_uncaught_exception(request,
+                        resolver, sys.exc_info())
+
         except SystemExit:
             # Allow sys.exit() to actually exit. See tickets #1023 and #4701
             raise
 
-        except: # Handle everything else, including SuspiciousOperation, etc.
+        except: # Handle everything else.
             # Get the exception info now, in case another exception is thrown later.
             signals.got_request_exception.send(sender=self.__class__, request=request)
             response = self.handle_uncaught_exception(request, resolver, sys.exc_info())

+ 3 - 0
django/core/urlresolvers.py

@@ -360,6 +360,9 @@ class RegexURLResolver(LocaleRegexProvider):
             callback = getattr(urls, 'handler%s' % view_type)
         return get_callable(callback), {}
 
+    def resolve400(self):
+        return self._resolve_special('400')
+
     def resolve403(self):
         return self._resolve_special('403')
 

+ 2 - 2
django/http/multipartparser.py

@@ -11,7 +11,7 @@ import cgi
 import sys
 
 from django.conf import settings
-from django.core.exceptions import SuspiciousOperation
+from django.core.exceptions import SuspiciousMultipartForm
 from django.utils.datastructures import MultiValueDict
 from django.utils.encoding import force_text
 from django.utils import six
@@ -370,7 +370,7 @@ class LazyStream(six.Iterator):
                             if current_number == num_bytes])
 
         if number_equal > 40:
-            raise SuspiciousOperation(
+            raise SuspiciousMultipartForm(
                 "The multipart parser got stuck, which shouldn't happen with"
                 " normal uploaded files. Check for malicious upload activity;"
                 " if there is none, report this to the Django developers."

+ 2 - 2
django/http/request.py

@@ -14,7 +14,7 @@ except ImportError:
 
 from django.conf import settings
 from django.core import signing
-from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
+from django.core.exceptions import DisallowedHost, ImproperlyConfigured
 from django.core.files import uploadhandler
 from django.http.multipartparser import MultiPartParser
 from django.utils import six
@@ -72,7 +72,7 @@ class HttpRequest(object):
             msg = "Invalid HTTP_HOST header: %r." % host
             if domain:
                 msg += "You may need to add %r to ALLOWED_HOSTS." % domain
-            raise SuspiciousOperation(msg)
+            raise DisallowedHost(msg)
 
     def get_full_path(self):
         # RFC 3986 requires query string arguments to be in the ASCII range.

+ 2 - 2
django/http/response.py

@@ -12,7 +12,7 @@ except ImportError:
 from django.conf import settings
 from django.core import signals
 from django.core import signing
-from django.core.exceptions import SuspiciousOperation
+from django.core.exceptions import DisallowedRedirect
 from django.http.cookie import SimpleCookie
 from django.utils import six, timezone
 from django.utils.encoding import force_bytes, iri_to_uri
@@ -452,7 +452,7 @@ class HttpResponseRedirectBase(HttpResponse):
     def __init__(self, redirect_to, *args, **kwargs):
         parsed = urlparse(redirect_to)
         if parsed.scheme and parsed.scheme not in self.allowed_schemes:
-            raise SuspiciousOperation("Unsafe redirect to URL with protocol '%s'" % parsed.scheme)
+            raise DisallowedRedirect("Unsafe redirect to URL with protocol '%s'" % parsed.scheme)
         super(HttpResponseRedirectBase, self).__init__(*args, **kwargs)
         self['Location'] = iri_to_uri(redirect_to)
 

+ 20 - 0
django/test/utils.py

@@ -1,3 +1,5 @@
+from contextlib import contextmanager
+import logging
 import re
 import sys
 import warnings
@@ -401,3 +403,21 @@ class IgnoreDeprecationWarningsMixin(object):
 class IgnorePendingDeprecationWarningsMixin(IgnoreDeprecationWarningsMixin):
 
         warning_class = PendingDeprecationWarning
+
+
+@contextmanager
+def patch_logger(logger_name, log_level):
+    """
+    Context manager that takes a named logger and the logging level
+    and provides a simple mock-like list of messages received
+    """
+    calls = []
+    def replacement(msg):
+        calls.append(msg)
+    logger = logging.getLogger(logger_name)
+    orig = getattr(logger, log_level)
+    setattr(logger, log_level, replacement)
+    try:
+        yield calls
+    finally:
+        setattr(logger, log_level, orig)

+ 5 - 0
django/utils/log.py

@@ -63,6 +63,11 @@ DEFAULT_LOGGING = {
             'level': 'ERROR',
             'propagate': False,
         },
+        'django.security': {
+            'handlers': ['mail_admins'],
+            'level': 'ERROR',
+            'propagate': False,
+        },
         'py.warnings': {
             'handlers': ['console'],
         },

+ 15 - 0
django/views/defaults.py

@@ -43,6 +43,21 @@ def server_error(request, template_name='500.html'):
     return http.HttpResponseServerError(template.render(Context({})))
 
 
+@requires_csrf_token
+def bad_request(request, template_name='400.html'):
+    """
+    400 error handler.
+
+    Templates: :template:`400.html`
+    Context: None
+    """
+    try:
+        template = loader.get_template(template_name)
+    except TemplateDoesNotExist:
+        return http.HttpResponseBadRequest('<h1>Bad Request (400)</h1>')
+    return http.HttpResponseBadRequest(template.render(Context({})))
+
+
 # This can be called when CsrfViewMiddleware.process_view has not run,
 # therefore need @requires_csrf_token in case the template needs
 # {% csrf_token %}.

+ 18 - 3
docs/ref/exceptions.txt

@@ -44,9 +44,24 @@ SuspiciousOperation
 -------------------
 .. exception:: SuspiciousOperation
 
-    The :exc:`SuspiciousOperation` exception is raised when a user has performed
-    an operation that should be considered suspicious from a security perspective,
-    such as tampering with a session cookie.
+    The :exc:`SuspiciousOperation` exception is raised when a user has
+    performed an operation that should be considered suspicious from a security
+    perspective, such as tampering with a session cookie. Subclasses of
+    SuspiciousOperation include:
+
+    * DisallowedHost
+    * DisallowedModelAdminLookup
+    * DisallowedRedirect
+    * InvalidSessionKey
+    * SuspiciousFileOperation
+    * SuspiciousMultipartForm
+    * SuspiciousSession
+    * WizardViewCookieModified
+
+    If a ``SuspiciousOperation`` exception reaches the WSGI handler level it is
+    logged at the ``Error`` level and results in
+    a :class:`~django.http.HttpResponseBadRequest`. See the :doc:`logging
+    documentation </topics/logging/>` for more information.
 
 PermissionDenied
 ----------------

+ 7 - 0
docs/releases/1.6.txt

@@ -270,6 +270,13 @@ Minor features
   stores active language in session if it is not present there. This
   prevents loss of language settings after session flush, e.g. logout.
 
+* :exc:`~django.core.exceptions.SuspiciousOperation` has been differentiated
+  into a number of subclasses, and each will log to a matching named logger
+  under the ``django.security`` logging hierarchy. Along with this change,
+  a ``handler400`` mechanism and default view are used whenever
+  a ``SuspiciousOperation`` reaches the WSGI handler to return an
+  ``HttpResponseBadRequest``.
+
 Backwards incompatible changes in 1.6
 =====================================
 

+ 22 - 0
docs/topics/http/views.txt

@@ -231,3 +231,25 @@ same way you can for the 404 and 500 views by specifying a ``handler403`` in
 your URLconf::
 
     handler403 = 'mysite.views.my_custom_permission_denied_view'
+
+.. _http_bad_request_view:
+
+The 400 (bad request) view
+--------------------------
+
+When a :exc:`~django.core.exceptions.SuspiciousOperation` is raised in Django,
+the it may be handled by a component of Django (for example resetting the
+session data). If not specifically handled, Django will consider the current
+request a 'bad request' instead of a server error.
+
+The view ``django.views.defaults.bad_request``, is otherwise very similar to
+the ``server_error`` view, but returns with the status code 400 indicating that
+the error condition was the result of a client operation.
+
+Like the ``server_error`` view, the default ``bad_request`` should suffice for
+99% of Web applications, but if you want to override the view, you can specify
+``handler400`` in your URLconf, like so::
+
+    handler400 = 'mysite.views.my_custom_bad_request_view'
+
+``bad_request`` views are also only used when :setting:`DEBUG` is ``False``.

+ 30 - 1
docs/topics/logging.txt

@@ -394,7 +394,7 @@ requirements of logging in Web server environment.
 Loggers
 -------
 
-Django provides three built-in loggers.
+Django provides four built-in loggers.
 
 ``django``
 ~~~~~~~~~~
@@ -434,6 +434,35 @@ For performance reasons, SQL logging is only enabled when
 ``settings.DEBUG`` is set to ``True``, regardless of the logging
 level or handlers that are installed.
 
+``django.security.*``
+~~~~~~~~~~~~~~~~~~~~~~
+
+The security loggers will receive messages on any occurrence of
+:exc:`~django.core.exceptions.SuspiciousOperation`. There is a sub-logger for
+each sub-type of SuspiciousOperation. The level of the log event depends on
+where the exception is handled.  Most occurrences are logged as a warning, while
+any ``SuspiciousOperation`` that reaches the WSGI handler will be logged as an
+error. For example, when an HTTP ``Host`` header is included in a request from
+a client that does not match :setting:`ALLOWED_HOSTS`, Django will return a 400
+response, and an error message will be logged to the
+``django.security.DisallowedHost`` logger.
+
+Only the parent ``django.security`` logger is configured by default, and all
+child loggers will propagate to the parent logger. The ``django.security``
+logger is configured the same as the ``django.request`` logger, and any error
+events will be mailed to admins. Requests resulting in a 400 response due to
+a ``SuspiciousOperation`` will not be logged to the ``django.request`` logger,
+but only to the ``django.security`` logger.
+
+To silence a particular type of SuspiciousOperation, you can override that
+specific logger following this example::
+
+        'loggers': {
+            'django.security.DisallowedHost': {
+                'handlers': ['null'],
+                'propagate': False,
+            },
+
 Handlers
 --------
 

+ 17 - 17
tests/admin_views/tests.py

@@ -11,7 +11,6 @@ except ImportError:  # Python 2
 
 from django.conf import settings, global_settings
 from django.core import mail
-from django.core.exceptions import SuspiciousOperation
 from django.core.files import temp as tempfile
 from django.core.urlresolvers import reverse
 # Register auth models with the admin.
@@ -30,6 +29,7 @@ from django.db import connection
 from django.forms.util import ErrorList
 from django.template.response import TemplateResponse
 from django.test import TestCase
+from django.test.utils import patch_logger
 from django.utils import formats, translation, unittest
 from django.utils.cache import get_max_age
 from django.utils.encoding import iri_to_uri, force_bytes
@@ -543,20 +543,21 @@ class AdminViewBasicTest(TestCase):
                 self.assertContains(response, '%Y-%m-%d %H:%M:%S')
 
     def test_disallowed_filtering(self):
-        self.assertRaises(SuspiciousOperation,
-            self.client.get, "/test_admin/admin/admin_views/album/?owner__email__startswith=fuzzy"
-        )
+        with patch_logger('django.security.DisallowedModelAdminLookup', 'error') as calls:
+            response = self.client.get("/test_admin/admin/admin_views/album/?owner__email__startswith=fuzzy")
+            self.assertEqual(response.status_code, 400)
+            self.assertEqual(len(calls), 1)
 
-        try:
-            self.client.get("/test_admin/admin/admin_views/thing/?color__value__startswith=red")
-            self.client.get("/test_admin/admin/admin_views/thing/?color__value=red")
-        except SuspiciousOperation:
-            self.fail("Filters are allowed if explicitly included in list_filter")
+        # Filters are allowed if explicitly included in list_filter
+        response = self.client.get("/test_admin/admin/admin_views/thing/?color__value__startswith=red")
+        self.assertEqual(response.status_code, 200)
+        response = self.client.get("/test_admin/admin/admin_views/thing/?color__value=red")
+        self.assertEqual(response.status_code, 200)
 
-        try:
-            self.client.get("/test_admin/admin/admin_views/person/?age__gt=30")
-        except SuspiciousOperation:
-            self.fail("Filters should be allowed if they involve a local field without the need to whitelist them in list_filter or date_hierarchy.")
+        # Filters should be allowed if they involve a local field without the
+        # need to whitelist them in list_filter or date_hierarchy.
+        response = self.client.get("/test_admin/admin/admin_views/person/?age__gt=30")
+        self.assertEqual(response.status_code, 200)
 
         e1 = Employee.objects.create(name='Anonymous', gender=1, age=22, alive=True, code='123')
         e2 = Employee.objects.create(name='Visitor', gender=2, age=19, alive=True, code='124')
@@ -574,10 +575,9 @@ class AdminViewBasicTest(TestCase):
         ForeignKey 'limit_choices_to' should be allowed, otherwise raw_id_fields
         can break.
         """
-        try:
-            self.client.get("/test_admin/admin/admin_views/inquisition/?leader__name=Palin&leader__age=27")
-        except SuspiciousOperation:
-            self.fail("Filters should be allowed if they are defined on a ForeignKey pointing to this model")
+        # Filters should be allowed if they are defined on a ForeignKey pointing to this model
+        response = self.client.get("/test_admin/admin/admin_views/inquisition/?leader__name=Palin&leader__age=27")
+        self.assertEqual(response.status_code, 200)
 
     def test_hide_change_password(self):
         """

+ 9 - 0
tests/handlers/tests.py

@@ -61,6 +61,7 @@ class TransactionsPerRequestTests(TransactionTestCase):
             connection.settings_dict['ATOMIC_REQUESTS'] = old_atomic_requests
         self.assertContains(response, 'False')
 
+
 class SignalsTests(TestCase):
     urls = 'handlers.urls'
 
@@ -89,3 +90,11 @@ class SignalsTests(TestCase):
         self.assertEqual(self.signals, ['started'])
         self.assertEqual(b''.join(response.streaming_content), b"streaming content")
         self.assertEqual(self.signals, ['started', 'finished'])
+
+
+class HandlerSuspiciousOpsTest(TestCase):
+    urls = 'handlers.urls'
+
+    def test_suspiciousop_in_view_returns_400(self):
+        response = self.client.get('/suspicious/')
+        self.assertEqual(response.status_code, 400)

+ 1 - 0
tests/handlers/urls.py

@@ -9,4 +9,5 @@ urlpatterns = patterns('',
     url(r'^streaming/$', views.streaming),
     url(r'^in_transaction/$', views.in_transaction),
     url(r'^not_in_transaction/$', views.not_in_transaction),
+    url(r'^suspicious/$', views.suspicious),
 )

+ 4 - 0
tests/handlers/views.py

@@ -1,5 +1,6 @@
 from __future__ import unicode_literals
 
+from django.core.exceptions import SuspiciousOperation
 from django.db import connection, transaction
 from django.http import HttpResponse, StreamingHttpResponse
 
@@ -15,3 +16,6 @@ def in_transaction(request):
 @transaction.non_atomic_requests
 def not_in_transaction(request):
     return HttpResponse(str(connection.in_atomic_block))
+
+def suspicious(request):
+    raise SuspiciousOperation('dubious')

+ 22 - 2
tests/logging_tests/tests.py

@@ -8,9 +8,10 @@ import warnings
 from django.conf import LazySettings
 from django.core import mail
 from django.test import TestCase, RequestFactory
-from django.test.utils import override_settings
+from django.test.utils import override_settings, patch_logger
 from django.utils.encoding import force_text
-from django.utils.log import CallbackFilter, RequireDebugFalse, RequireDebugTrue
+from django.utils.log import (CallbackFilter, RequireDebugFalse,
+    RequireDebugTrue)
 from django.utils.six import StringIO
 from django.utils.unittest import skipUnless
 
@@ -354,3 +355,22 @@ class SettingsConfigureLogging(TestCase):
         settings.configure(
             LOGGING_CONFIG='logging_tests.tests.dictConfig')
         self.assertTrue(dictConfig.called)
+
+
+class SecurityLoggerTest(TestCase):
+
+    urls = 'logging_tests.urls'
+
+    def test_suspicious_operation_creates_log_message(self):
+        with self.settings(DEBUG=True):
+            with patch_logger('django.security.SuspiciousOperation', 'error') as calls:
+                response = self.client.get('/suspicious/')
+                self.assertEqual(len(calls), 1)
+                self.assertEqual(calls[0], 'dubious')
+
+    def test_suspicious_operation_uses_sublogger(self):
+        with self.settings(DEBUG=True):
+            with patch_logger('django.security.DisallowedHost', 'error') as calls:
+                response = self.client.get('/suspicious_spec/')
+                self.assertEqual(len(calls), 1)
+                self.assertEqual(calls[0], 'dubious')

+ 10 - 0
tests/logging_tests/urls.py

@@ -0,0 +1,10 @@
+from __future__ import unicode_literals
+
+from django.conf.urls import patterns, url
+
+from . import views
+
+urlpatterns = patterns('',
+    url(r'^suspicious/$', views.suspicious),
+    url(r'^suspicious_spec/$', views.suspicious_spec),
+)

+ 11 - 0
tests/logging_tests/views.py

@@ -0,0 +1,11 @@
+from __future__ import unicode_literals
+
+from django.core.exceptions import SuspiciousOperation, DisallowedHost
+
+
+def suspicious(request):
+    raise SuspiciousOperation('dubious')
+
+
+def suspicious_spec(request):
+    raise DisallowedHost('dubious')

+ 3 - 3
tests/test_client_regress/tests.py

@@ -7,7 +7,6 @@ from __future__ import unicode_literals
 import os
 
 from django.conf import settings
-from django.core.exceptions import SuspiciousOperation
 from django.core.urlresolvers import reverse
 from django.template import (TemplateDoesNotExist, TemplateSyntaxError,
     Context, Template, loader)
@@ -20,6 +19,7 @@ from django.utils._os import upath
 from django.utils.translation import ugettext_lazy
 from django.http import HttpResponse
 
+from .views import CustomTestException
 
 @override_settings(
     TEMPLATE_DIRS=(os.path.join(os.path.dirname(upath(__file__)), 'templates'),)
@@ -619,7 +619,7 @@ class ExceptionTests(TestCase):
         try:
             response = self.client.get("/test_client_regress/staff_only/")
             self.fail("General users should not be able to visit this page")
-        except SuspiciousOperation:
+        except CustomTestException:
             pass
 
         # At this point, an exception has been raised, and should be cleared.
@@ -629,7 +629,7 @@ class ExceptionTests(TestCase):
         self.assertTrue(login, 'Could not log in')
         try:
             self.client.get("/test_client_regress/staff_only/")
-        except SuspiciousOperation:
+        except CustomTestException:
             self.fail("Staff should be able to visit this page")
 
 

+ 5 - 2
tests/test_client_regress/views.py

@@ -3,12 +3,15 @@ import json
 from django.conf import settings
 from django.contrib.auth.decorators import login_required
 from django.http import HttpResponse, HttpResponseRedirect
-from django.core.exceptions import SuspiciousOperation
 from django.shortcuts import render_to_response
 from django.core.serializers.json import DjangoJSONEncoder
 from django.test.client import CONTENT_TYPE_RE
 from django.template import RequestContext
 
+
+class CustomTestException(Exception):
+    pass
+
 def no_template_view(request):
     "A simple view that expects a GET request, and returns a rendered template"
     return HttpResponse("No template used. Sample content: twice once twice. Content ends.")
@@ -18,7 +21,7 @@ def staff_only_view(request):
     if request.user.is_staff:
         return HttpResponse('')
     else:
-        raise SuspiciousOperation()
+        raise CustomTestException()
 
 def get_view(request):
     "A simple login protected view"

+ 3 - 1
tests/urlpatterns_reverse/tests.py

@@ -516,7 +516,7 @@ class RequestURLconfTests(TestCase):
             b''.join(self.client.get('/second_test/'))
 
 class ErrorHandlerResolutionTests(TestCase):
-    """Tests for handler404 and handler500"""
+    """Tests for handler400, handler404 and handler500"""
 
     def setUp(self):
         from django.core.urlresolvers import RegexURLResolver
@@ -528,12 +528,14 @@ class ErrorHandlerResolutionTests(TestCase):
     def test_named_handlers(self):
         from .views import empty_view
         handler = (empty_view, {})
+        self.assertEqual(self.resolver.resolve400(), handler)
         self.assertEqual(self.resolver.resolve404(), handler)
         self.assertEqual(self.resolver.resolve500(), handler)
 
     def test_callable_handers(self):
         from .views import empty_view
         handler = (empty_view, {})
+        self.assertEqual(self.callable_resolver.resolve400(), handler)
         self.assertEqual(self.callable_resolver.resolve404(), handler)
         self.assertEqual(self.callable_resolver.resolve500(), handler)
 

+ 1 - 0
tests/urlpatterns_reverse/urls_error_handlers.py

@@ -4,5 +4,6 @@ from django.conf.urls import patterns
 
 urlpatterns = patterns('')
 
+handler400 = 'urlpatterns_reverse.views.empty_view'
 handler404 = 'urlpatterns_reverse.views.empty_view'
 handler500 = 'urlpatterns_reverse.views.empty_view'

+ 1 - 0
tests/urlpatterns_reverse/urls_error_handlers_callables.py

@@ -9,5 +9,6 @@ from .views import empty_view
 
 urlpatterns = patterns('')
 
+handler400 = empty_view
 handler404 = empty_view
 handler500 = empty_view