From 52ef6a47269a455113d95992f868939131f9c10c Mon Sep 17 00:00:00 2001 From: Tim Graham Date: Fri, 12 Sep 2014 14:50:36 -0400 Subject: [PATCH] Fixed #17101 -- Integrated django-secure and added check --deploy option Thanks Carl Meyer for django-secure and for reviewing. Thanks also to Zach Borboa, Erik Romijn, Collin Anderson, and Jorge Carleitao for reviews. --- django/conf/global_settings.py | 11 + .../project_template/project_name/settings.py | 1 + django/core/checks/__init__.py | 3 + django/core/checks/registry.py | 32 +- django/core/checks/security/__init__.py | 0 django/core/checks/security/base.py | 185 +++++++ django/core/checks/security/csrf.py | 57 ++ django/core/checks/security/sessions.py | 97 ++++ django/core/management/base.py | 9 +- django/core/management/commands/check.py | 25 +- django/middleware/security.py | 43 ++ django/test/utils.py | 6 +- docs/howto/deployment/checklist.txt | 8 + docs/index.txt | 1 + docs/ref/checks.txt | 105 ++++ docs/ref/django-admin.txt | 19 + docs/ref/middleware.txt | 172 +++++++ docs/ref/settings.txt | 139 ++++- docs/releases/1.8.txt | 12 +- docs/spelling_wordlist | 3 + docs/topics/checks.txt | 16 +- tests/check_framework/test_security.py | 487 ++++++++++++++++++ tests/check_framework/tests.py | 27 + tests/middleware/test_security.py | 202 ++++++++ 24 files changed, 1638 insertions(+), 22 deletions(-) create mode 100644 django/core/checks/security/__init__.py create mode 100644 django/core/checks/security/base.py create mode 100644 django/core/checks/security/csrf.py create mode 100644 django/core/checks/security/sessions.py create mode 100644 django/middleware/security.py create mode 100644 tests/check_framework/test_security.py create mode 100644 tests/middleware/test_security.py diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 7f0a1471b1..186d77f314 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -631,3 +631,14 @@ MIGRATION_MODULES = {} # serious issues like errors and criticals does not result in hiding the # message, but Django will not stop you from e.g. running server. SILENCED_SYSTEM_CHECKS = [] + +####################### +# SECURITY MIDDLEWARE # +####################### +SECURE_BROWSER_XSS_FILTER = False +SECURE_CONTENT_TYPE_NOSNIFF = False +SECURE_HSTS_INCLUDE_SUBDOMAINS = False +SECURE_HSTS_SECONDS = 0 +SECURE_REDIRECT_EXEMPT = [] +SECURE_SSL_HOST = None +SECURE_SSL_REDIRECT = False diff --git a/django/conf/project_template/project_name/settings.py b/django/conf/project_template/project_name/settings.py index 49982995b8..62e7633fa6 100644 --- a/django/conf/project_template/project_name/settings.py +++ b/django/conf/project_template/project_name/settings.py @@ -46,6 +46,7 @@ MIDDLEWARE_CLASSES = ( 'django.contrib.auth.middleware.SessionAuthenticationMiddleware', 'django.contrib.messages.middleware.MessageMiddleware', 'django.middleware.clickjacking.XFrameOptionsMiddleware', + 'django.middleware.security.SecurityMiddleware', ) ROOT_URLCONF = '{{ project_name }}.urls' diff --git a/django/core/checks/__init__.py b/django/core/checks/__init__.py index d215b82513..f3e38448d6 100644 --- a/django/core/checks/__init__.py +++ b/django/core/checks/__init__.py @@ -10,6 +10,9 @@ from .registry import register, run_checks, tag_exists, Tags import django.core.checks.compatibility.django_1_6_0 # NOQA import django.core.checks.compatibility.django_1_7_0 # NOQA import django.core.checks.model_checks # NOQA +import django.core.checks.security.base # NOQA +import django.core.checks.security.csrf # NOQA +import django.core.checks.security.sessions # NOQA __all__ = [ 'CheckMessage', diff --git a/django/core/checks/registry.py b/django/core/checks/registry.py index 8217d534c6..e8626f03c7 100644 --- a/django/core/checks/registry.py +++ b/django/core/checks/registry.py @@ -13,6 +13,7 @@ class Tags(object): admin = 'admin' compatibility = 'compatibility' models = 'models' + security = 'security' signals = 'signals' @@ -20,8 +21,9 @@ class CheckRegistry(object): def __init__(self): self.registered_checks = [] + self.deployment_checks = [] - def register(self, *tags): + def register(self, *tags, **kwargs): """ Decorator. Register given function `f` labeled with given `tags`. The function should receive **kwargs and return list of Errors and @@ -36,24 +38,28 @@ class CheckRegistry(object): return errors """ + kwargs.setdefault('deploy', False) def inner(check): check.tags = tags - if check not in self.registered_checks: + if kwargs['deploy']: + if check not in self.deployment_checks: + self.deployment_checks.append(check) + elif check not in self.registered_checks: self.registered_checks.append(check) return check return inner - def run_checks(self, app_configs=None, tags=None): + def run_checks(self, app_configs=None, tags=None, include_deployment_checks=False): """ Run all registered checks and return list of Errors and Warnings. """ errors = [] + checks = self.get_checks(include_deployment_checks) + if tags is not None: - checks = [check for check in self.registered_checks + checks = [check for check in checks if hasattr(check, 'tags') and set(check.tags) & set(tags)] - else: - checks = self.registered_checks for check in checks: new_errors = check(app_configs=app_configs) @@ -63,11 +69,17 @@ class CheckRegistry(object): errors.extend(new_errors) return errors - def tag_exists(self, tag): - return tag in self.tags_available() + def tag_exists(self, tag, include_deployment_checks=False): + return tag in self.tags_available(include_deployment_checks) - def tags_available(self): - return set(chain(*[check.tags for check in self.registered_checks if hasattr(check, 'tags')])) + def tags_available(self, deployment_checks=False): + return set(chain(*[check.tags for check in self.get_checks(deployment_checks) if hasattr(check, 'tags')])) + + def get_checks(self, include_deployment_checks=False): + checks = list(self.registered_checks) + if include_deployment_checks: + checks.extend(self.deployment_checks) + return checks registry = CheckRegistry() diff --git a/django/core/checks/security/__init__.py b/django/core/checks/security/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py new file mode 100644 index 0000000000..20fbd189b3 --- /dev/null +++ b/django/core/checks/security/base.py @@ -0,0 +1,185 @@ +from django.conf import settings + +from .. import register, Tags, Warning + + +SECRET_KEY_MIN_LENGTH = 50 +SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5 + +W001 = Warning( + "You do not have 'django.middleware.security.SecurityMiddleware' " + "in your MIDDLEWARE_CLASSES so the SECURE_HSTS_SECONDS, " + "SECURE_CONTENT_TYPE_NOSNIFF, " + "SECURE_BROWSER_XSS_FILTER, and SECURE_SSL_REDIRECT settings " + "will have no effect.", + id='security.W001', +) + +W002 = Warning( + "You do not have " + "'django.middleware.clickjacking.XFrameOptionsMiddleware' in your " + "MIDDLEWARE_CLASSES, so your pages will not be served with an " + "'x-frame-options' header. Unless there is a good reason for your " + "site to be served in a frame, you should consider enabling this " + "header to help prevent clickjacking attacks.", + id='security.W002', +) + +W004 = Warning( + "You have not set a value for the SECURE_HSTS_SECONDS setting. " + "If your entire site is served only over SSL, you may want to consider " + "setting a value and enabling HTTP Strict Transport Security. " + "Be sure to read the documentation first; enabling HSTS carelessly " + "can cause serious, irreversible problems.", + id='security.W004', +) + +W005 = Warning( + "You have not set the SECURE_HSTS_INCLUDE_SUBDOMAINS setting to True. " + "Without this, your site is potentially vulnerable to attack " + "via an insecure connection to a subdomain. Only set this to True if " + "you are certain that all subdomains of your domain should be served " + "exclusively via SSL.", + id='security.W005', +) + +W006 = Warning( + "Your SECURE_CONTENT_TYPE_NOSNIFF setting is not set to True, " + "so your pages will not be served with an " + "'x-content-type-options: nosniff' header. " + "You should consider enabling this header to prevent the " + "browser from identifying content types incorrectly.", + id='security.W006', +) + +W007 = Warning( + "Your SECURE_BROWSER_XSS_FILTER setting is not set to True, " + "so your pages will not be served with an " + "'x-xss-protection: 1; mode=block' header. " + "You should consider enabling this header to activate the " + "browser's XSS filtering and help prevent XSS attacks.", + id='security.W007', +) + +W008 = Warning( + "Your SECURE_SSL_REDIRECT setting is not set to True. " + "Unless your site should be available over both SSL and non-SSL " + "connections, you may want to either set this setting True " + "or configure a load balancer or reverse-proxy server " + "to redirect all connections to HTTPS.", + id='security.W008', +) + +W009 = Warning( + "Your SECRET_KEY has less than %(min_length)s characters or less than " + "%(min_unique_chars)s unique characters. Please generate a long and random " + "SECRET_KEY, otherwise many of Django's security-critical features will be " + "vulnerable to attack." % { + 'min_length': SECRET_KEY_MIN_LENGTH, + 'min_unique_chars': SECRET_KEY_MIN_UNIQUE_CHARACTERS, + }, + id='security.W009', +) + +W018 = Warning( + "You should not have DEBUG set to True in deployment.", + id='security.W018', +) + +W019 = Warning( + "You have " + "'django.middleware.clickjacking.XFrameOptionsMiddleware' in your " + "MIDDLEWARE_CLASSES, but X_FRAME_OPTIONS is not set to 'DENY'. " + "The default is 'SAMEORIGIN', but unless there is a good reason for " + "your site to serve other parts of itself in a frame, you should " + "change it to 'DENY'.", + id='security.W019', +) + + +def _security_middleware(): + return "django.middleware.security.SecurityMiddleware" in settings.MIDDLEWARE_CLASSES + + +def _xframe_middleware(): + return "django.middleware.clickjacking.XFrameOptionsMiddleware" in settings.MIDDLEWARE_CLASSES + + +@register(Tags.security, deploy=True) +def check_security_middleware(app_configs, **kwargs): + passed_check = _security_middleware() + return [] if passed_check else [W001] + + +@register(Tags.security, deploy=True) +def check_xframe_options_middleware(app_configs, **kwargs): + passed_check = _xframe_middleware() + return [] if passed_check else [W002] + + +@register(Tags.security, deploy=True) +def check_sts(app_configs, **kwargs): + passed_check = not _security_middleware() or settings.SECURE_HSTS_SECONDS + return [] if passed_check else [W004] + + +@register(Tags.security, deploy=True) +def check_sts_include_subdomains(app_configs, **kwargs): + passed_check = ( + not _security_middleware() or + not settings.SECURE_HSTS_SECONDS or + settings.SECURE_HSTS_INCLUDE_SUBDOMAINS is True + ) + return [] if passed_check else [W005] + + +@register(Tags.security, deploy=True) +def check_content_type_nosniff(app_configs, **kwargs): + passed_check = ( + not _security_middleware() or + settings.SECURE_CONTENT_TYPE_NOSNIFF is True + ) + return [] if passed_check else [W006] + + +@register(Tags.security, deploy=True) +def check_xss_filter(app_configs, **kwargs): + passed_check = ( + not _security_middleware() or + settings.SECURE_BROWSER_XSS_FILTER is True + ) + return [] if passed_check else [W007] + + +@register(Tags.security, deploy=True) +def check_ssl_redirect(app_configs, **kwargs): + passed_check = ( + not _security_middleware() or + settings.SECURE_SSL_REDIRECT is True + ) + return [] if passed_check else [W008] + + +@register(Tags.security, deploy=True) +def check_secret_key(app_configs, **kwargs): + passed_check = ( + getattr(settings, 'SECRET_KEY', None) and + len(set(settings.SECRET_KEY)) >= SECRET_KEY_MIN_UNIQUE_CHARACTERS and + len(settings.SECRET_KEY) >= SECRET_KEY_MIN_LENGTH + ) + return [] if passed_check else [W009] + + +@register(Tags.security, deploy=True) +def check_debug(app_configs, **kwargs): + passed_check = not settings.DEBUG + return [] if passed_check else [W018] + + +@register(Tags.security, deploy=True) +def check_xframe_deny(app_configs, **kwargs): + passed_check = ( + not _xframe_middleware() or + settings.X_FRAME_OPTIONS == 'DENY' + ) + return [] if passed_check else [W019] diff --git a/django/core/checks/security/csrf.py b/django/core/checks/security/csrf.py new file mode 100644 index 0000000000..79cb6d4fa4 --- /dev/null +++ b/django/core/checks/security/csrf.py @@ -0,0 +1,57 @@ +from django.conf import settings + +from .. import register, Tags, Warning + + +W003 = Warning( + "You don't appear to be using Django's built-in " + "cross-site request forgery protection via the middleware " + "('django.middleware.csrf.CsrfViewMiddleware' is not in your " + "MIDDLEWARE_CLASSES). Enabling the middleware is the safest approach " + "to ensure you don't leave any holes.", + id='security.W003', +) + +W016 = Warning( + "You have 'django.middleware.csrf.CsrfViewMiddleware' in your " + "MIDDLEWARE_CLASSES, but you have not set CSRF_COOKIE_SECURE to True. " + "Using a secure-only CSRF cookie makes it more difficult for network " + "traffic sniffers to steal the CSRF token.", + id='security.W016', +) + +W017 = Warning( + "You have 'django.middleware.csrf.CsrfViewMiddleware' in your " + "MIDDLEWARE_CLASSES, but you have not set CSRF_COOKIE_HTTPONLY to True. " + "Using an HttpOnly CSRF cookie makes it more difficult for cross-site " + "scripting attacks to steal the CSRF token.", + id='security.W017', +) + + +def _csrf_middleware(): + return "django.middleware.csrf.CsrfViewMiddleware" in settings.MIDDLEWARE_CLASSES + + +@register(Tags.security, deploy=True) +def check_csrf_middleware(app_configs, **kwargs): + passed_check = _csrf_middleware() + return [] if passed_check else [W003] + + +@register(Tags.security, deploy=True) +def check_csrf_cookie_secure(app_configs, **kwargs): + passed_check = ( + not _csrf_middleware() or + settings.CSRF_COOKIE_SECURE + ) + return [] if passed_check else [W016] + + +@register(Tags.security, deploy=True) +def check_csrf_cookie_httponly(app_configs, **kwargs): + passed_check = ( + not _csrf_middleware() or + settings.CSRF_COOKIE_HTTPONLY + ) + return [] if passed_check else [W017] diff --git a/django/core/checks/security/sessions.py b/django/core/checks/security/sessions.py new file mode 100644 index 0000000000..b27aa1f9c2 --- /dev/null +++ b/django/core/checks/security/sessions.py @@ -0,0 +1,97 @@ +from django.conf import settings + +from .. import register, Tags, Warning + + +def add_session_cookie_message(message): + return message + ( + " Using a secure-only session cookie makes it more difficult for " + "network traffic sniffers to hijack user sessions." + ) + +W010 = Warning( + add_session_cookie_message( + "You have 'django.contrib.sessions' in your INSTALLED_APPS, " + "but you have not set SESSION_COOKIE_SECURE to True." + ), + id='security.W010', +) + +W011 = Warning( + add_session_cookie_message( + "You have 'django.contrib.sessions.middleware.SessionMiddleware' " + "in your MIDDLEWARE_CLASSES, but you have not set " + "SESSION_COOKIE_SECURE to True." + ), + id='security.W011', +) + +W012 = Warning( + add_session_cookie_message("SESSION_COOKIE_SECURE is not set to True."), + id='security.W012', +) + + +def add_httponly_message(message): + return message + ( + " Using an HttpOnly session cookie makes it more difficult for " + "cross-site scripting attacks to hijack user sessions." + ) + + +W013 = Warning( + add_httponly_message( + "You have 'django.contrib.sessions' in your INSTALLED_APPS, " + "but you have not set SESSION_COOKIE_HTTPONLY to True.", + ), + id='security.W013', +) + +W014 = Warning( + add_httponly_message( + "You have 'django.contrib.sessions.middleware.SessionMiddleware' " + "in your MIDDLEWARE_CLASSES, but you have not set " + "SESSION_COOKIE_HTTPONLY to True." + ), + id='security.W014', +) + +W015 = Warning( + add_httponly_message("SESSION_COOKIE_HTTPONLY is not set to True."), + id='security.W015', +) + + +@register(Tags.security, deploy=True) +def check_session_cookie_secure(app_configs, **kwargs): + errors = [] + if not settings.SESSION_COOKIE_SECURE: + if _session_app(): + errors.append(W010) + if _session_middleware(): + errors.append(W011) + if len(errors) > 1: + errors = [W012] + return errors + + +@register(Tags.security, deploy=True) +def check_session_cookie_httponly(app_configs, **kwargs): + errors = [] + if not settings.SESSION_COOKIE_HTTPONLY: + if _session_app(): + errors.append(W013) + if _session_middleware(): + errors.append(W014) + if len(errors) > 1: + errors = [W015] + return errors + + +def _session_middleware(): + return ("django.contrib.sessions.middleware.SessionMiddleware" in + settings.MIDDLEWARE_CLASSES) + + +def _session_app(): + return "django.contrib.sessions" in settings.INSTALLED_APPS diff --git a/django/core/management/base.py b/django/core/management/base.py index 167e4f746e..910b43ecd2 100644 --- a/django/core/management/base.py +++ b/django/core/management/base.py @@ -442,14 +442,19 @@ class BaseCommand(object): return self.check(app_configs=app_configs, display_num_errors=display_num_errors) - def check(self, app_configs=None, tags=None, display_num_errors=False): + def check(self, app_configs=None, tags=None, display_num_errors=False, + include_deployment_checks=False): """ Uses the system check framework to validate entire Django project. Raises CommandError for any serious message (error or critical errors). If there are only light messages (like warnings), they are printed to stderr and no exception is raised. """ - all_issues = checks.run_checks(app_configs=app_configs, tags=tags) + all_issues = checks.run_checks( + app_configs=app_configs, + tags=tags, + include_deployment_checks=include_deployment_checks, + ) msg = "" visible_issue_count = 0 # excludes silenced warnings diff --git a/django/core/management/commands/check.py b/django/core/management/commands/check.py index be9b49d54a..818700e4ed 100644 --- a/django/core/management/commands/check.py +++ b/django/core/management/commands/check.py @@ -18,10 +18,13 @@ class Command(BaseCommand): help='Run only checks labeled with given tag.') parser.add_argument('--list-tags', action='store_true', dest='list_tags', help='List available tags.') + parser.add_argument('--deploy', action='store_true', dest='deploy', + help='Check deployment settings.') def handle(self, *app_labels, **options): + include_deployment_checks = options['deploy'] if options.get('list_tags'): - self.stdout.write('\n'.join(sorted(registry.tags_available()))) + self.stdout.write('\n'.join(sorted(registry.tags_available(include_deployment_checks)))) return if app_labels: @@ -30,8 +33,20 @@ class Command(BaseCommand): app_configs = None tags = options.get('tags', None) - if tags and any(not checks.tag_exists(tag) for tag in tags): - invalid_tag = next(tag for tag in tags if not checks.tag_exists(tag)) - raise CommandError('There is no system check with the "%s" tag.' % invalid_tag) + if tags: + try: + invalid_tag = next( + tag for tag in tags if not checks.tag_exists(tag, include_deployment_checks) + ) + except StopIteration: + # no invalid tags + pass + else: + raise CommandError('There is no system check with the "%s" tag.' % invalid_tag) - self.check(app_configs=app_configs, tags=tags, display_num_errors=True) + self.check( + app_configs=app_configs, + tags=tags, + display_num_errors=True, + include_deployment_checks=include_deployment_checks, + ) diff --git a/django/middleware/security.py b/django/middleware/security.py new file mode 100644 index 0000000000..46afb68f57 --- /dev/null +++ b/django/middleware/security.py @@ -0,0 +1,43 @@ +import re + +from django.conf import settings +from django.http import HttpResponsePermanentRedirect + + +class SecurityMiddleware(object): + def __init__(self): + self.sts_seconds = settings.SECURE_HSTS_SECONDS + self.sts_include_subdomains = settings.SECURE_HSTS_INCLUDE_SUBDOMAINS + self.content_type_nosniff = settings.SECURE_CONTENT_TYPE_NOSNIFF + self.xss_filter = settings.SECURE_BROWSER_XSS_FILTER + self.redirect = settings.SECURE_SSL_REDIRECT + self.redirect_host = settings.SECURE_SSL_HOST + self.redirect_exempt = [re.compile(r) for r in settings.SECURE_REDIRECT_EXEMPT] + + def process_request(self, request): + path = request.path.lstrip("/") + if (self.redirect and not request.is_secure() and + not any(pattern.search(path) + for pattern in self.redirect_exempt)): + host = self.redirect_host or request.get_host() + return HttpResponsePermanentRedirect( + "https://%s%s" % (host, request.get_full_path()) + ) + + def process_response(self, request, response): + if (self.sts_seconds and request.is_secure() and + 'strict-transport-security' not in response): + sts_header = "max-age=%s" % self.sts_seconds + + if self.sts_include_subdomains: + sts_header = sts_header + "; includeSubDomains" + + response["strict-transport-security"] = sts_header + + if self.content_type_nosniff and 'x-content-type-options' not in response: + response["x-content-type-options"] = "nosniff" + + if self.xss_filter and 'x-xss-protection' not in response: + response["x-xss-protection"] = "1; mode=block" + + return response diff --git a/django/test/utils.py b/django/test/utils.py index b2d9f7b696..91a254ef5e 100644 --- a/django/test/utils.py +++ b/django/test/utils.py @@ -359,7 +359,7 @@ class modify_settings(override_settings): super(modify_settings, self).enable() -def override_system_checks(new_checks): +def override_system_checks(new_checks, deployment_checks=None): """ Acts as a decorator. Overrides list of registered system checks. Useful when you override `INSTALLED_APPS`, e.g. if you exclude `auth` app, you also need to exclude its system checks. """ @@ -371,10 +371,14 @@ def override_system_checks(new_checks): def inner(*args, **kwargs): old_checks = registry.registered_checks registry.registered_checks = new_checks + old_deployment_checks = registry.deployment_checks + if deployment_checks is not None: + registry.deployment_checks = deployment_checks try: return test_func(*args, **kwargs) finally: registry.registered_checks = old_checks + registry.deployment_checks = old_deployment_checks return inner return outer diff --git a/docs/howto/deployment/checklist.txt b/docs/howto/deployment/checklist.txt index 85f9255bfc..d7cffdd3b2 100644 --- a/docs/howto/deployment/checklist.txt +++ b/docs/howto/deployment/checklist.txt @@ -29,6 +29,14 @@ you're releasing the source code for your project, a common practice is to publish suitable settings for development, and to use a private settings module for production. +Run ``manage.py check --deploy`` +================================ + +Some of the checks described below can be automated using the +:djadminopt:`--deploy` option of the :djadmin:`check` command. Be sure to run it +against your production settings file as described in the option's +documentation. + Critical settings ================= diff --git a/docs/index.txt b/docs/index.txt index fb20bdf3a1..1c2a483eca 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -229,6 +229,7 @@ applications and Django provides multiple protection tools and mechanisms: * :doc:`Clickjacking protection ` * :doc:`Cross Site Request Forgery protection ` * :doc:`Cryptographic signing ` +* :ref:`Security Middleware ` Internationalization and localization ===================================== diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 4a0a687e2d..b86ad115b8 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -20,6 +20,7 @@ Django's system checks are organized using the following tags: * ``signals``: Checks on signal declarations and handler registrations. * ``admin``: Checks of any admin site declarations. * ``compatibility``: Flagging potential problems with version upgrades. +* ``security``: Checks security related configuration. Some checks may be registered with multiple tags. @@ -346,6 +347,110 @@ The following checks are performed when a model contains a * **contenttypes.E004**: ```` is not a ``ForeignKey`` to ``contenttypes.ContentType``. +Security +-------- + +The security checks do not make your site secure. They do not audit code, do +intrusion detection, or do anything particularly complex. Rather, they help +perform an automated, low-hanging-fruit checklist. They help you remember the +simple things that improve your site's security. + +Some of these checks may not be appropriate for your particular deployment +configuration. For instance, if you do your HTTP to HTTPS redirection in a load +balancer, it'd be irritating to be constantly warned about not having enabled +:setting:`SECURE_SSL_REDIRECT`. Use :setting:`SILENCED_SYSTEM_CHECKS` to +silence unneeded checks. + +The following checks will be run if you use the :djadminopt:`--deploy` option +of the :djadmin:`check` command: + +* **security.W001**: You do not have + :class:`django.middleware.security.SecurityMiddleware` in your + :setting:`MIDDLEWARE_CLASSES` so the :setting:`SECURE_HSTS_SECONDS`, + :setting:`SECURE_CONTENT_TYPE_NOSNIFF`, :setting:`SECURE_BROWSER_XSS_FILTER`, + and :setting:`SECURE_SSL_REDIRECT` settings will have no effect. +* **security.W002**: You do not have + :class:`django.middleware.clickjacking.XFrameOptionsMiddleware` in your + :setting:`MIDDLEWARE_CLASSES`, so your pages will not be served with an + ``'x-frame-options'`` header. Unless there is a good reason for your + site to be served in a frame, you should consider enabling this + header to help prevent clickjacking attacks. +* **security.W003**: You don't appear to be using Django's built-in cross-site + request forgery protection via the middleware + (:class:`django.middleware.csrf.CsrfViewMiddleware` is not in your + :setting:`MIDDLEWARE_CLASSES`). Enabling the middleware is the safest + approach to ensure you don't leave any holes. +* **security.W004**: You have not set a value for the + :setting:`SECURE_HSTS_SECONDS` setting. If your entire site is served only + over SSL, you may want to consider setting a value and enabling :ref:`HTTP + Strict Transport Security `. Be sure to read + the documentation first; enabling HSTS carelessly can cause serious, + irreversible problems. +* **security.W005**: You have not set the + :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` setting to ``True``. Without this, + your site is potentially vulnerable to attack via an insecure connection to a + subdomain. Only set this to ``True`` if you are certain that all subdomains of + your domain should be served exclusively via SSL. +* **security.W006**: Your :setting:`SECURE_CONTENT_TYPE_NOSNIFF` setting is not + set to ``True``, so your pages will not be served with an + ``'x-content-type-options: nosniff'`` header. You should consider enabling + this header to prevent the browser from identifying content types incorrectly. +* **security.W007**: Your :setting:`SECURE_BROWSER_XSS_FILTER` setting is not + set to ``True``, so your pages will not be served with an + ``'x-xss-protection: 1; mode=block'`` header. You should consider enabling + this header to activate the browser's XSS filtering and help prevent XSS + attacks. +* **security.W008**: Your :setting:`SECURE_SSL_REDIRECT` setting is not set to + ``True``. Unless your site should be available over both SSL and non-SSL + connections, you may want to either set this setting to ``True`` or configure + a load balancer or reverse-proxy server to redirect all connections to HTTPS. +* **security.W009**: Your :setting:`SECRET_KEY` has less than 50 characters or + less than 5 unique characters. Please generate a long and random + ``SECRET_KEY``, otherwise many of Django's security-critical features will be + vulnerable to attack. +* **security.W010**: You have :mod:`django.contrib.sessions` in your + :setting:`INSTALLED_APPS` but you have not set + :setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session + cookie makes it more difficult for network traffic sniffers to hijack user + sessions. +* **security.W011**: You have + :class:`django.contrib.sessions.middleware.SessionMiddleware` in your + :setting:`MIDDLEWARE_CLASSES`, but you have not set + :setting:`SESSION_COOKIE_SECURE` to ``True``. Using a secure-only session + cookie makes it more difficult for network traffic sniffers to hijack user + sessions. +* **security.W012**: :setting:`SESSION_COOKIE_SECURE` is not set to ``True``. + Using a secure-only session cookie makes it more difficult for network traffic + sniffers to hijack user sessions. +* **security.W013**: You have :mod:`django.contrib.sessions` in your + :setting:`INSTALLED_APPS`, but you have not set + :setting:`SESSION_COOKIE_HTTPONLY` to ``True``. Using an ``HttpOnly`` session + cookie makes it more difficult for cross-site scripting attacks to hijack user + sessions. +* **security.W014**: You have + :class:`django.contrib.sessions.middleware.SessionMiddleware` in your + :setting:`MIDDLEWARE_CLASSES`, but you have not set + :setting:`SESSION_COOKIE_HTTPONLY` to ``True``. Using an ``HttpOnly`` session + cookie makes it more difficult for cross-site scripting attacks to hijack user + sessions. +* **security.W015**: :setting:`SESSION_COOKIE_HTTPONLY` is not set to ``True``. + Using an ``HttpOnly`` session cookie makes it more difficult for cross-site + scripting attacks to hijack user sessions. +* **security.W016**: :setting:`CSRF_COOKIE_SECURE` is not set to ``True``. + Using a secure-only CSRF cookie makes it more difficult for network traffic + sniffers to steal the CSRF token. +* **security.W017**: :setting:`CSRF_COOKIE_HTTPONLY` is not set to ``True``. + Using an ``HttpOnly`` CSRF cookie makes it more difficult for cross-site + scripting attacks to steal the CSRF token. +* **security.W018**: You should not have :setting:`DEBUG` set to ``True`` in + deployment. +* **security.W019**: You have + :class:`django.middleware.clickjacking.XFrameOptionsMiddleware` in your + :setting:`MIDDLEWARE_CLASSES`, but :setting:`X_FRAME_OPTIONS` is not set to + ``'DENY'``. The default is ``'SAMEORIGIN'``, but unless there is a good reason + for your site to serve other parts of itself in a frame, you should change + it to ``'DENY'``. + Sites ----- diff --git a/docs/ref/django-admin.txt b/docs/ref/django-admin.txt index e91423ff6b..fd4abc62f4 100644 --- a/docs/ref/django-admin.txt +++ b/docs/ref/django-admin.txt @@ -135,6 +135,25 @@ to perform only security and compatibility checks, you would run:: List all available tags. +.. django-admin-option:: --deploy + +.. versionadded:: 1.8 + +The ``--deploy`` option activates some additional checks that are only relevant +in a deployment setting. + +You can use this option in your local development environment, but since your +local development settings module may not have many of your production settings, +you will probably want to point the ``check`` command at a different settings +module, either by setting the ``DJANGO_SETTINGS_MODULE`` environment variable, +or by passing the ``--settings`` option:: + + python manage.py check --deploy --settings=production_settings + +Or you could run it directly on a production or staging deployment to verify +that the correct settings are in use (omitting ``--settings``). You could even +make it part of your integration test suite. + compilemessages --------------- diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index b6ec137d2e..eea2d94a84 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -155,6 +155,178 @@ Message middleware Enables cookie- and session-based message support. See the :doc:`messages documentation `. +.. _security-middleware: + +Security middleware +------------------- + +.. module:: django.middleware.security + :synopsis: Security middleware. + +.. warning:: + If your deployment situation allows, it's usually a good idea to have your + front-end Web server perform the functionality provided by the + ``SecurityMiddleware``. That way, if there are requests that aren't served + by Django (such as static media or user-uploaded files), they will have + the same protections as requests to your Django application. + +.. class:: SecurityMiddleware + +.. versionadded:: 1.8 + +The ``django.middleware.security.SecurityMiddleware`` provides several security +enhancements to the request/response cycle. Each one can be independently +enabled or disabled with a setting. + +* :setting:`SECURE_BROWSER_XSS_FILTER` +* :setting:`SECURE_CONTENT_TYPE_NOSNIFF` +* :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` +* :setting:`SECURE_HSTS_SECONDS` +* :setting:`SECURE_REDIRECT_EXEMPT` +* :setting:`SECURE_SSL_HOST` +* :setting:`SECURE_SSL_REDIRECT` + +.. _http-strict-transport-security: + +HTTP Strict Transport Security +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +For sites that should only be accessed over HTTPS, you can instruct modern +browsers to refuse to connect to your domain name via an insecure connection +(for a given period of time) by setting the `"Strict-Transport-Security" +header`_. This reduces your exposure to some SSL-stripping man-in-the-middle +(MITM) attacks. + +``SecurityMiddleware`` will set this header for you on all HTTPS responses if +you set the :setting:`SECURE_HSTS_SECONDS` setting to a non-zero integer value. + +When enabling HSTS, it's a good idea to first use a small value for testing, +for example, :setting:`SECURE_HSTS_SECONDS = 3600` for one +hour. Each time a Web browser sees the HSTS header from your site, it will +refuse to communicate non-securely (using HTTP) with your domain for the given +period of time. Once you confirm that all assets are served securely on your +site (i.e. HSTS didn't break anything), it's a good idea to increase this value +so that infrequent visitors will be protected (31536000 seconds, i.e. 1 year, +is common). + +Additionally, if you set the :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` setting +to ``True``, ``SecurityMiddleware`` will add the ``includeSubDomains`` tag to +the ``Strict-Transport-Security`` header. This is recommended (assuming all +subdomains are served exclusively using HTTPS), otherwise your site may still +be vulnerable via an insecure connection to a subdomain. + +.. warning:: + The HSTS policy applies to your entire domain, not just the URL of the + response that you set the header on. Therefore, you should only use it if + your entire domain is served via HTTPS only. + + Browsers properly respecting the HSTS header will refuse to allow users to + bypass warnings and connect to a site with an expired, self-signed, or + otherwise invalid SSL certificate. If you use HSTS, make sure your + certificates are in good shape and stay that way! + +.. note:: + If you are deployed behind a load-balancer or reverse-proxy server, and the + ``Strict-Transport-Security`` header is not being added to your responses, + it may be because Django doesn't realize that it's on a secure connection; + you may need to set the :setting:`SECURE_PROXY_SSL_HEADER` setting. + +.. _"Strict-Transport-Security" header: http://en.wikipedia.org/wiki/Strict_Transport_Security + +.. _x-content-type-options: + +``X-Content-Type-Options: nosniff`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some browsers will try to guess the content types of the assets that they +fetch, overriding the ``Content-Type`` header. While this can help display +sites with improperly configured servers, it can also pose a security +risk. + +If your site serves user-uploaded files, a malicious user could upload a +specially-crafted file that would be interpreted as HTML or Javascript by +the browser when you expected it to be something harmless. + +To learn more about this header and how the browser treats it, you can +read about it on the `IE Security Blog`_. + +To prevent the browser from guessing the content type and force it to +always use the type provided in the ``Content-Type`` header, you can pass +the ``X-Content-Type-Options: nosniff`` header. ``SecurityMiddleware`` will +do this for all responses if the :setting:`SECURE_CONTENT_TYPE_NOSNIFF` setting +is ``True``. + +Note that in most deployment situations where Django isn't involved in serving +user-uploaded files, this setting won't help you. For example, if your +:setting:`MEDIA_URL` is served directly by your front-end Web server (nginx, +Apache, etc.) then you'd want to set this header there. On the other hand, if +you are using Django to do something like require authorization in order to +download files and you cannot set the header using your Web server, this +setting will be useful. + +.. _IE Security Blog: http://blogs.msdn.com/b/ie/archive/2008/09/02/ie8-security-part-vi-beta-2-update.aspx + +.. _x-xss-protection: + +``X-XSS-Protection: 1; mode=block`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +Some browsers have the ability to block content that appears to be an `XSS +attack`_. They work by looking for Javascript content in the GET or POST +parameters of a page. If the Javascript is replayed in the server's response, +the page is blocked from rendering and an error page is shown instead. + +The `X-XSS-Protection header`_ is used to control the operation of the +XSS filter. + +To enable the XSS filter in the browser, and force it to always block +suspected XSS attacks, you can pass the ``X-XSS-Protection: 1; mode=block`` +header. ``SecurityMiddleware`` will do this for all responses if the +:setting:`SECURE_BROWSER_XSS_FILTER` setting is ``True``. + +.. warning:: + The browser XSS filter is a useful defense measure, but must not be + relied upon exclusively. It cannot detect all XSS attacks and not all + browsers support the header. Ensure you are still :ref:`validating and + sanitizing ` all input to prevent XSS attacks. + +.. _XSS attack: http://en.wikipedia.org/wiki/Cross-site_scripting +.. _X-XSS-Protection header: http://blogs.msdn.com/b/ie/archive/2008/07/02/ie8-security-part-iv-the-xss-filter.aspx + +.. _ssl-redirect: + +SSL Redirect +~~~~~~~~~~~~ + +If your site offers both HTTP and HTTPS connections, most users will end up +with an unsecured connection by default. For best security, you should redirect +all HTTP connections to HTTPS. + +If you set the :setting:`SECURE_SSL_REDIRECT` setting to True, +``SecurityMiddleware`` will permanently (HTTP 301) redirect all HTTP +connections to HTTPS. + +.. note:: + + For performance reasons, it's preferable to do these redirects outside of + Django, in a front-end load balancer or reverse-proxy server such as + `nginx`_. :setting:`SECURE_SSL_REDIRECT` is intended for the deployment + situations where this isn't an option. + +If the :setting:`SECURE_SSL_HOST` setting has a value, all redirects will be +sent to that host instead of the originally-requested host. + +If there are a few pages on your site that should be available over HTTP, and +not redirected to HTTPS, you can list regular expressions to match those URLs +in the :setting:`SECURE_REDIRECT_EXEMPT` setting. + +.. note:: + If you are deployed behind a load-balancer or reverse-proxy server and + Django can't seem to tell when a request actually is already secure, you + may need to set the :setting:`SECURE_PROXY_SSL_HEADER` setting. + +.. _nginx: http://nginx.org + Session middleware ------------------ diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 00f9a1e629..507f3f11c9 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -357,6 +357,12 @@ Default: ``False`` Whether to use ``HttpOnly`` flag on the CSRF cookie. If this is set to ``True``, client-side JavaScript will not to be able to access the CSRF cookie. + +This can help prevent malicious JavaScript from bypassing CSRF protection. If +you enable this and need to send the value of the CSRF token with Ajax requests, +your JavaScript will need to pull the value from a hidden CSRF token form input +on the page instead of from the cookie. + See :setting:`SESSION_COOKIE_HTTPONLY` for details on ``HttpOnly``. .. setting:: CSRF_COOKIE_NAME @@ -1902,6 +1908,67 @@ Django will refuse to start if :setting:`SECRET_KEY` is not set. security protections, and can lead to privilege escalation and remote code execution vulnerabilities. +.. setting:: SECURE_BROWSER_XSS_FILTER + +SECURE_BROWSER_XSS_FILTER +------------------------- + +.. versionadded:: 1.8 + +Default: ``False`` + +If ``True``, the :class:`~django.middleware.security.SecurityMiddleware` sets +the :ref:`x-xss-protection` header on all responses that do not already have it. + +.. setting:: SECURE_CONTENT_TYPE_NOSNIFF + +SECURE_CONTENT_TYPE_NOSNIFF +--------------------------- + +.. versionadded:: 1.8 + +Default: ``False`` + +If ``True``, the :class:`~django.middleware.security.SecurityMiddleware` +sets the :ref:`x-content-type-options` header on all responses that do not +already have it. + +.. setting:: SECURE_HSTS_INCLUDE_SUBDOMAINS + +SECURE_HSTS_INCLUDE_SUBDOMAINS +------------------------------ + +.. versionadded:: 1.8 + +Default: ``False`` + +If ``True``, the :class:`~django.middleware.security.SecurityMiddleware` adds +the ``includeSubDomains`` tag to the :ref:`http-strict-transport-security` +header. It has no effect unless :setting:`SECURE_HSTS_SECONDS` is set to a +non-zero value. + +.. warning:: + Setting this incorrectly can irreversibly (for some time) break your site. + Read the :ref:`http-strict-transport-security` documentation first. + +.. setting:: SECURE_HSTS_SECONDS + +SECURE_HSTS_SECONDS +------------------- + +.. versionadded:: 1.8 + +Default: ``0`` + +If set to a non-zero integer value, the +:class:`~django.middleware.security.SecurityMiddleware` sets the +:ref:`http-strict-transport-security` header on all responses that do not +already have it. + +.. warning:: + Setting this incorrectly can irreversibly (for some time) break your site. + Read the :ref:`http-strict-transport-security` documentation first. + .. setting:: SECURE_PROXY_SSL_HEADER SECURE_PROXY_SSL_HEADER @@ -1963,6 +2030,55 @@ available in ``request.META``.) If any of those are not true, you should keep this setting set to ``None`` and find another way of determining HTTPS, perhaps via custom middleware. +.. setting:: SECURE_REDIRECT_EXEMPT + +SECURE_REDIRECT_EXEMPT +---------------------- + +.. versionadded:: 1.8 + +Default: ``[]`` + +If a URL path matches a regular expression in this list, the request will not be +redirected to HTTPS. If :setting:`SECURE_SSL_REDIRECT` is ``False``, this +setting has no effect. + +.. setting:: SECURE_SSL_HOST + +SECURE_SSL_HOST +--------------- + +.. versionadded:: 1.8 + +Default: ``None`` + +If a string (e.g. ``secure.example.com``), all SSL redirects will be directed +to this host rather than the originally-requested host +(e.g. ``www.example.com``). If :setting:`SECURE_SSL_REDIRECT` is ``False``, this +setting has no effect. + +.. setting:: SECURE_SSL_REDIRECT + +SECURE_SSL_REDIRECT +------------------- + +.. versionadded:: 1.8 + +Default: ``False``. + +If ``True``, the :class:`~django.middleware.security.SecurityMiddleware` +:ref:`redirects ` all non-HTTPS requests to HTTPS (except for +those URLs matching a regular expression listed in +:setting:`SECURE_REDIRECT_EXEMPT`). + +.. note:: + + If turning this to ``True`` causes infinite redirects, it probably means + your site is running behind a proxy and can't tell which requests are secure + and which are not. Your proxy likely sets a header to indicate secure + requests; you can correct the problem by finding out what that header is and + configuring the :setting:`SECURE_PROXY_SSL_HEADER` setting accordingly. + .. setting:: SERIALIZATION_MODULES SERIALIZATION_MODULES @@ -2642,6 +2758,11 @@ consistently by all browsers. However, when it is honored, it can be a useful way to mitigate the risk of client side script accessing the protected cookie data. +Turning it on makes it less trivial for an attacker to escalate a cross-site +scripting vulnerability into full hijacking of a user's session. There's not +much excuse for leaving this off, either: if your code depends on reading +session cookies from Javascript, you're probably doing it wrong. + .. versionadded:: 1.7 This setting also affects cookies set by :mod:`django.contrib.messages`. @@ -2683,6 +2804,13 @@ Whether to use a secure cookie for the session cookie. If this is set to ``True``, the cookie will be marked as "secure," which means browsers may ensure that the cookie is only sent under an HTTPS connection. +Since it's trivial for a packet sniffer (e.g. `Firesheep`_) to hijack a user's +session if the session cookie is sent unencrypted, there's really no good +excuse to leave this off. It will prevent you from using sessions on insecure +requests and that's a good thing. + +.. _Firesheep: http://codebutler.com/firesheep + .. versionadded:: 1.7 This setting also affects cookies set by :mod:`django.contrib.messages`. @@ -3023,7 +3151,16 @@ HTTP * :setting:`FORCE_SCRIPT_NAME` * :setting:`INTERNAL_IPS` * :setting:`MIDDLEWARE_CLASSES` -* :setting:`SECURE_PROXY_SSL_HEADER` +* Security + + * :setting:`SECURE_BROWSER_XSS_FILTER` + * :setting:`SECURE_CONTENT_TYPE_NOSNIFF` + * :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` + * :setting:`SECURE_HSTS_SECONDS` + * :setting:`SECURE_PROXY_SSL_HEADER` + * :setting:`SECURE_REDIRECT_EXEMPT` + * :setting:`SECURE_SSL_HOST` + * :setting:`SECURE_SSL_REDIRECT` * :setting:`SIGNING_BACKEND` * :setting:`USE_ETAGS` * :setting:`USE_X_FORWARDED_HOST` diff --git a/docs/releases/1.8.txt b/docs/releases/1.8.txt index 999b0ef295..41d029a9ca 100644 --- a/docs/releases/1.8.txt +++ b/docs/releases/1.8.txt @@ -23,7 +23,17 @@ Like Django 1.7, Django 1.8 requires Python 2.7 or above, though we What's new in Django 1.8 ======================== -... +Security enhancements +~~~~~~~~~~~~~~~~~~~~~ + +Several features of the django-secure_ third-party library have been +integrated into Django. :class:`django.middleware.security.SecurityMiddleware` +provides several security enhancements to the request/response cycle. The new +:djadminopt:`--deploy` option of the :djadmin:`check` command allows you to +check your production settings file for ways to increase the security of your +site. + +.. _django-secure: https://pypi.python.org/pypi/django-secure Minor features ~~~~~~~~~~~~~~ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 60b07e9734..75b82754f7 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -203,6 +203,7 @@ filesizeformat filesystem filesystems findstatic +Firesheep firstof fk flatpage @@ -572,6 +573,7 @@ sqlmigrate sqlsequencereset squashmigrations ssi +SSL stacktrace startswith stateful @@ -742,6 +744,7 @@ www xe xgettext xref +XSS xxxxx yesno Zope diff --git a/docs/topics/checks.txt b/docs/topics/checks.txt index 076b63483d..55181688b3 100644 --- a/docs/topics/checks.txt +++ b/docs/topics/checks.txt @@ -132,13 +132,25 @@ check. Tagging checks is useful since it allows you to run only a certain group of checks. For example, to register a compatibility check, you would make the following call:: - from django.core.checks import register + from django.core.checks import register, Tags - @register('compatibility') + @register(Tags.compatibility) def my_check(app_configs, **kwargs): # ... perform compatibility checks and collect errors return errors +.. versionadded:: 1.8 + +You can register "deployment checks" that are only relevant to a production +settings file like this:: + + @register(Tags.security, deploy=True) + def my_check(app_configs, **kwargs): + ... + +These checks will only be run if the :djadminopt:`--deploy` option is passed to +the :djadmin:`check` command. + .. _field-checking: Field, Model, and Manager checks diff --git a/tests/check_framework/test_security.py b/tests/check_framework/test_security.py new file mode 100644 index 0000000000..4a72e60029 --- /dev/null +++ b/tests/check_framework/test_security.py @@ -0,0 +1,487 @@ +from django.conf import settings +from django.test import TestCase +from django.test.utils import override_settings + +from django.core.checks.security import base +from django.core.checks.security import csrf +from django.core.checks.security import sessions + + +class CheckSessionCookieSecureTest(TestCase): + @property + def func(self): + from django.core.checks.security.sessions import check_session_cookie_secure + return check_session_cookie_secure + + @override_settings( + SESSION_COOKIE_SECURE=False, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=[]) + def test_session_cookie_secure_with_installed_app(self): + """ + Warn if SESSION_COOKIE_SECURE is off and "django.contrib.sessions" is + in INSTALLED_APPS. + """ + self.assertEqual(self.func(None), [sessions.W010]) + + @override_settings( + SESSION_COOKIE_SECURE=False, + INSTALLED_APPS=[], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_secure_with_middleware(self): + """ + Warn if SESSION_COOKIE_SECURE is off and + "django.contrib.sessions.middleware.SessionMiddleware" is in + MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), [sessions.W011]) + + @override_settings( + SESSION_COOKIE_SECURE=False, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_secure_both(self): + """ + If SESSION_COOKIE_SECURE is off and we find both the session app and + the middleware, provide one common warning. + """ + self.assertEqual(self.func(None), [sessions.W012]) + + @override_settings( + SESSION_COOKIE_SECURE=True, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_secure_true(self): + """ + If SESSION_COOKIE_SECURE is on, there's no warning about it. + """ + self.assertEqual(self.func(None), []) + + +class CheckSessionCookieHttpOnlyTest(TestCase): + @property + def func(self): + from django.core.checks.security.sessions import check_session_cookie_httponly + return check_session_cookie_httponly + + @override_settings( + SESSION_COOKIE_HTTPONLY=False, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=[]) + def test_session_cookie_httponly_with_installed_app(self): + """ + Warn if SESSION_COOKIE_HTTPONLY is off and "django.contrib.sessions" + is in INSTALLED_APPS. + """ + self.assertEqual(self.func(None), [sessions.W013]) + + @override_settings( + SESSION_COOKIE_HTTPONLY=False, + INSTALLED_APPS=[], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_httponly_with_middleware(self): + """ + Warn if SESSION_COOKIE_HTTPONLY is off and + "django.contrib.sessions.middleware.SessionMiddleware" is in + MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), [sessions.W014]) + + @override_settings( + SESSION_COOKIE_HTTPONLY=False, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_httponly_both(self): + """ + If SESSION_COOKIE_HTTPONLY is off and we find both the session app and + the middleware, provide one common warning. + """ + self.assertEqual(self.func(None), [sessions.W015]) + + @override_settings( + SESSION_COOKIE_HTTPONLY=True, + INSTALLED_APPS=["django.contrib.sessions"], + MIDDLEWARE_CLASSES=["django.contrib.sessions.middleware.SessionMiddleware"]) + def test_session_cookie_httponly_true(self): + """ + If SESSION_COOKIE_HTTPONLY is on, there's no warning about it. + """ + self.assertEqual(self.func(None), []) + + +class CheckCSRFMiddlewareTest(TestCase): + @property + def func(self): + from django.core.checks.security.csrf import check_csrf_middleware + return check_csrf_middleware + + @override_settings(MIDDLEWARE_CLASSES=[]) + def test_no_csrf_middleware(self): + """ + Warn if CsrfViewMiddleware isn't in MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), [csrf.W003]) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.csrf.CsrfViewMiddleware"]) + def test_with_csrf_middleware(self): + self.assertEqual(self.func(None), []) + + +class CheckCSRFCookieSecureTest(TestCase): + @property + def func(self): + from django.core.checks.security.csrf import check_csrf_cookie_secure + return check_csrf_cookie_secure + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.csrf.CsrfViewMiddleware"], + CSRF_COOKIE_SECURE=False) + def test_with_csrf_cookie_secure_false(self): + """ + Warn if CsrfViewMiddleware is in MIDDLEWARE_CLASSES but + CSRF_COOKIE_SECURE isn't True. + """ + self.assertEqual(self.func(None), [csrf.W016]) + + @override_settings(MIDDLEWARE_CLASSES=[], CSRF_COOKIE_SECURE=False) + def test_with_csrf_cookie_secure_false_no_middleware(self): + """ + No warning if CsrfViewMiddleware isn't in MIDDLEWARE_CLASSES, even if + CSRF_COOKIE_SECURE is False. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.csrf.CsrfViewMiddleware"], + CSRF_COOKIE_SECURE=True) + def test_with_csrf_cookie_secure_true(self): + self.assertEqual(self.func(None), []) + + +class CheckCSRFCookieHttpOnlyTest(TestCase): + @property + def func(self): + from django.core.checks.security.csrf import check_csrf_cookie_httponly + return check_csrf_cookie_httponly + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.csrf.CsrfViewMiddleware"], + CSRF_COOKIE_HTTPONLY=False) + def test_with_csrf_cookie_httponly_false(self): + """ + Warn if CsrfViewMiddleware is in MIDDLEWARE_CLASSES but + CSRF_COOKIE_HTTPONLY isn't True. + """ + self.assertEqual(self.func(None), [csrf.W017]) + + @override_settings(MIDDLEWARE_CLASSES=[], CSRF_COOKIE_HTTPONLY=False) + def test_with_csrf_cookie_httponly_false_no_middleware(self): + """ + No warning if CsrfViewMiddleware isn't in MIDDLEWARE_CLASSES, even if + CSRF_COOKIE_HTTPONLY is False. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.csrf.CsrfViewMiddleware"], + CSRF_COOKIE_HTTPONLY=True) + def test_with_csrf_cookie_httponly_true(self): + self.assertEqual(self.func(None), []) + + +class CheckSecurityMiddlewareTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_security_middleware + return check_security_middleware + + @override_settings(MIDDLEWARE_CLASSES=[]) + def test_no_security_middleware(self): + """ + Warn if SecurityMiddleware isn't in MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), [base.W001]) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"]) + def test_with_security_middleware(self): + self.assertEqual(self.func(None), []) + + +class CheckStrictTransportSecurityTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_sts + return check_sts + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_HSTS_SECONDS=0) + def test_no_sts(self): + """ + Warn if SECURE_HSTS_SECONDS isn't > 0. + """ + self.assertEqual(self.func(None), [base.W004]) + + @override_settings( + MIDDLEWARE_CLASSES=[], + SECURE_HSTS_SECONDS=0) + def test_no_sts_no_middlware(self): + """ + Don't warn if SECURE_HSTS_SECONDS isn't > 0 and SecurityMiddleware isn't + installed. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_HSTS_SECONDS=3600) + def test_with_sts(self): + self.assertEqual(self.func(None), []) + + +class CheckStrictTransportSecuritySubdomainsTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_sts_include_subdomains + return check_sts_include_subdomains + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_HSTS_INCLUDE_SUBDOMAINS=False, + SECURE_HSTS_SECONDS=3600) + def test_no_sts_subdomains(self): + """ + Warn if SECURE_HSTS_INCLUDE_SUBDOMAINS isn't True. + """ + self.assertEqual(self.func(None), [base.W005]) + + @override_settings( + MIDDLEWARE_CLASSES=[], + SECURE_HSTS_INCLUDE_SUBDOMAINS=False, + SECURE_HSTS_SECONDS=3600) + def test_no_sts_subdomains_no_middlware(self): + """ + Don't warn if SecurityMiddleware isn't installed. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_SSL_REDIRECT=False, + SECURE_HSTS_SECONDS=None) + def test_no_sts_subdomains_no_seconds(self): + """ + Don't warn if SECURE_HSTS_SECONDS isn't set. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_HSTS_INCLUDE_SUBDOMAINS=True, + SECURE_HSTS_SECONDS=3600) + def test_with_sts_subdomains(self): + self.assertEqual(self.func(None), []) + + +class CheckXFrameOptionsMiddlewareTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_xframe_options_middleware + return check_xframe_options_middleware + + @override_settings(MIDDLEWARE_CLASSES=[]) + def test_middleware_not_installed(self): + """ + Warn if XFrameOptionsMiddleware isn't in MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), [base.W002]) + + @override_settings(MIDDLEWARE_CLASSES=["django.middleware.clickjacking.XFrameOptionsMiddleware"]) + def test_middleware_installed(self): + self.assertEqual(self.func(None), []) + + +class CheckXFrameOptionsDenyTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_xframe_deny + return check_xframe_deny + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.clickjacking.XFrameOptionsMiddleware"], + X_FRAME_OPTIONS='SAMEORIGIN', + ) + def test_x_frame_options_not_deny(self): + """ + Warn if XFrameOptionsMiddleware is in MIDDLEWARE_CLASSES but + X_FRAME_OPTIONS isn't 'DENY'. + """ + self.assertEqual(self.func(None), [base.W019]) + + @override_settings(MIDDLEWARE_CLASSES=[], X_FRAME_OPTIONS='SAMEORIGIN') + def test_middleware_not_installed(self): + """ + No error if XFrameOptionsMiddleware isn't in MIDDLEWARE_CLASSES even if + X_FRAME_OPTIONS isn't 'DENY'. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.clickjacking.XFrameOptionsMiddleware"], + X_FRAME_OPTIONS='DENY', + ) + def test_xframe_deny(self): + self.assertEqual(self.func(None), []) + + +class CheckContentTypeNosniffTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_content_type_nosniff + return check_content_type_nosniff + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_CONTENT_TYPE_NOSNIFF=False) + def test_no_content_type_nosniff(self): + """ + Warn if SECURE_CONTENT_TYPE_NOSNIFF isn't True. + """ + self.assertEqual(self.func(None), [base.W006]) + + @override_settings( + MIDDLEWARE_CLASSES=[], + SECURE_CONTENT_TYPE_NOSNIFF=False) + def test_no_content_type_nosniff_no_middleware(self): + """ + Don't warn if SECURE_CONTENT_TYPE_NOSNIFF isn't True and + SecurityMiddleware isn't in MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_CONTENT_TYPE_NOSNIFF=True) + def test_with_content_type_nosniff(self): + self.assertEqual(self.func(None), []) + + +class CheckXssFilterTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_xss_filter + return check_xss_filter + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_BROWSER_XSS_FILTER=False) + def test_no_xss_filter(self): + """ + Warn if SECURE_BROWSER_XSS_FILTER isn't True. + """ + self.assertEqual(self.func(None), [base.W007]) + + @override_settings( + MIDDLEWARE_CLASSES=[], + SECURE_BROWSER_XSS_FILTER=False) + def test_no_xss_filter_no_middleware(self): + """ + Don't warn if SECURE_BROWSER_XSS_FILTER isn't True and + SecurityMiddleware isn't in MIDDLEWARE_CLASSES. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_BROWSER_XSS_FILTER=True) + def test_with_xss_filter(self): + self.assertEqual(self.func(None), []) + + +class CheckSSLRedirectTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_ssl_redirect + return check_ssl_redirect + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_SSL_REDIRECT=False) + def test_no_ssl_redirect(self): + """ + Warn if SECURE_SSL_REDIRECT isn't True. + """ + self.assertEqual(self.func(None), [base.W008]) + + @override_settings( + MIDDLEWARE_CLASSES=[], + SECURE_SSL_REDIRECT=False) + def test_no_ssl_redirect_no_middlware(self): + """ + Don't warn if SECURE_SSL_REDIRECT is False and SecurityMiddleware isn't + installed. + """ + self.assertEqual(self.func(None), []) + + @override_settings( + MIDDLEWARE_CLASSES=["django.middleware.security.SecurityMiddleware"], + SECURE_SSL_REDIRECT=True) + def test_with_ssl_redirect(self): + self.assertEqual(self.func(None), []) + + +class CheckSecretKeyTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_secret_key + return check_secret_key + + @override_settings(SECRET_KEY=('abcdefghijklmnopqrstuvwx' * 2) + 'ab') + def test_okay_secret_key(self): + self.assertEqual(len(settings.SECRET_KEY), base.SECRET_KEY_MIN_LENGTH) + self.assertGreater(len(set(settings.SECRET_KEY)), base.SECRET_KEY_MIN_UNIQUE_CHARACTERS) + self.assertEqual(self.func(None), []) + + @override_settings(SECRET_KEY='') + def test_empty_secret_key(self): + self.assertEqual(self.func(None), [base.W009]) + + @override_settings(SECRET_KEY=None) + def test_missing_secret_key(self): + del settings.SECRET_KEY + self.assertEqual(self.func(None), [base.W009]) + + @override_settings(SECRET_KEY=None) + def test_none_secret_key(self): + self.assertEqual(self.func(None), [base.W009]) + + @override_settings(SECRET_KEY=('abcdefghijklmnopqrstuvwx' * 2) + 'a') + def test_low_length_secret_key(self): + self.assertEqual(len(settings.SECRET_KEY), base.SECRET_KEY_MIN_LENGTH - 1) + self.assertEqual(self.func(None), [base.W009]) + + @override_settings(SECRET_KEY='abcd' * 20) + def test_low_entropy_secret_key(self): + self.assertGreater(len(settings.SECRET_KEY), base.SECRET_KEY_MIN_LENGTH) + self.assertLess(len(set(settings.SECRET_KEY)), base.SECRET_KEY_MIN_UNIQUE_CHARACTERS) + self.assertEqual(self.func(None), [base.W009]) + + +class CheckDebugTest(TestCase): + @property + def func(self): + from django.core.checks.security.base import check_debug + return check_debug + + @override_settings(DEBUG=True) + def test_debug_true(self): + """ + Warn if DEBUG is True. + """ + self.assertEqual(self.func(None), [base.W018]) + + @override_settings(DEBUG=False) + def test_debug_false(self): + self.assertEqual(self.func(None), []) diff --git a/tests/check_framework/tests.py b/tests/check_framework/tests.py index 47936c3a4e..2fd80fd1ac 100644 --- a/tests/check_framework/tests.py +++ b/tests/check_framework/tests.py @@ -194,6 +194,12 @@ def tagged_system_check(**kwargs): tagged_system_check.tags = ['simpletag'] +def deployment_system_check(**kwargs): + deployment_system_check.kwargs = kwargs + return [checks.Warning('Deployment Check')] +deployment_system_check.tags = ['deploymenttag'] + + class CheckCommandTests(TestCase): def setUp(self): @@ -239,6 +245,27 @@ class CheckCommandTests(TestCase): call_command('check', list_tags=True) self.assertEqual('simpletag\n', sys.stdout.getvalue()) + @override_system_checks([tagged_system_check], deployment_checks=[deployment_system_check]) + def test_list_deployment_check_omitted(self): + call_command('check', list_tags=True) + self.assertEqual('simpletag\n', sys.stdout.getvalue()) + + @override_system_checks([tagged_system_check], deployment_checks=[deployment_system_check]) + def test_list_deployment_check_included(self): + call_command('check', deploy=True, list_tags=True) + self.assertEqual('deploymenttag\nsimpletag\n', sys.stdout.getvalue()) + + @override_system_checks([tagged_system_check], deployment_checks=[deployment_system_check]) + def test_tags_deployment_check_omitted(self): + msg = 'There is no system check with the "deploymenttag" tag.' + with self.assertRaisesMessage(CommandError, msg): + call_command('check', tags=['deploymenttag']) + + @override_system_checks([tagged_system_check], deployment_checks=[deployment_system_check]) + def test_tags_deployment_check_included(self): + call_command('check', deploy=True, tags=['deploymenttag']) + self.assertIn('Deployment Check', sys.stderr.getvalue()) + def custom_error_system_check(app_configs, **kwargs): return [ diff --git a/tests/middleware/test_security.py b/tests/middleware/test_security.py new file mode 100644 index 0000000000..93cae6ced6 --- /dev/null +++ b/tests/middleware/test_security.py @@ -0,0 +1,202 @@ +from django.http import HttpResponse +from django.test import TestCase, RequestFactory +from django.test.utils import override_settings + + +class SecurityMiddlewareTest(TestCase): + @property + def middleware(self): + from django.middleware.security import SecurityMiddleware + return SecurityMiddleware() + + @property + def secure_request_kwargs(self): + return {"wsgi.url_scheme": "https"} + + def response(self, *args, **kwargs): + headers = kwargs.pop("headers", {}) + response = HttpResponse(*args, **kwargs) + for k, v in headers.items(): + response[k] = v + return response + + def process_response(self, *args, **kwargs): + request_kwargs = {} + if kwargs.pop("secure", False): + request_kwargs.update(self.secure_request_kwargs) + request = (kwargs.pop("request", None) or + self.request.get("/some/url", **request_kwargs)) + ret = self.middleware.process_request(request) + if ret: + return ret + return self.middleware.process_response( + request, self.response(*args, **kwargs)) + + request = RequestFactory() + + def process_request(self, method, *args, **kwargs): + if kwargs.pop("secure", False): + kwargs.update(self.secure_request_kwargs) + req = getattr(self.request, method.lower())(*args, **kwargs) + return self.middleware.process_request(req) + + @override_settings(SECURE_HSTS_SECONDS=3600) + def test_sts_on(self): + """ + With HSTS_SECONDS=3600, the middleware adds + "strict-transport-security: max-age=3600" to the response. + """ + self.assertEqual( + self.process_response(secure=True)["strict-transport-security"], + "max-age=3600") + + @override_settings(SECURE_HSTS_SECONDS=3600) + def test_sts_already_present(self): + """ + The middleware will not override a "strict-transport-security" header + already present in the response. + """ + response = self.process_response( + secure=True, + headers={"strict-transport-security": "max-age=7200"}) + self.assertEqual(response["strict-transport-security"], "max-age=7200") + + @override_settings(HSTS_SECONDS=3600) + def test_sts_only_if_secure(self): + """ + The "strict-transport-security" header is not added to responses going + over an insecure connection. + """ + self.assertNotIn("strict-transport-security", self.process_response(secure=False)) + + @override_settings(HSTS_SECONDS=0) + def test_sts_off(self): + """ + With HSTS_SECONDS of 0, the middleware does not add a + "strict-transport-security" header to the response. + """ + self.assertNotIn("strict-transport-security", self.process_response(secure=True)) + + @override_settings( + SECURE_HSTS_SECONDS=600, SECURE_HSTS_INCLUDE_SUBDOMAINS=True) + def test_sts_include_subdomains(self): + """ + With HSTS_SECONDS non-zero and HSTS_INCLUDE_SUBDOMAINS + True, the middleware adds a "strict-transport-security" header with the + "includeSubDomains" tag to the response. + """ + response = self.process_response(secure=True) + self.assertEqual( + response["strict-transport-security"], + "max-age=600; includeSubDomains", + ) + + @override_settings( + SECURE_HSTS_SECONDS=600, SECURE_HSTS_INCLUDE_SUBDOMAINS=False) + def test_sts_no_include_subdomains(self): + """ + With HSTS_SECONDS non-zero and HSTS_INCLUDE_SUBDOMAINS + False, the middleware adds a "strict-transport-security" header without + the "includeSubDomains" tag to the response. + """ + response = self.process_response(secure=True) + self.assertEqual(response["strict-transport-security"], "max-age=600") + + @override_settings(SECURE_CONTENT_TYPE_NOSNIFF=True) + def test_content_type_on(self): + """ + With CONTENT_TYPE_NOSNIFF set to True, the middleware adds + "x-content-type-options: nosniff" header to the response. + """ + self.assertEqual(self.process_response()["x-content-type-options"], "nosniff") + + @override_settings(SECURE_CONTENT_TYPE_NO_SNIFF=True) + def test_content_type_already_present(self): + """ + The middleware will not override an "x-content-type-options" header + already present in the response. + """ + response = self.process_response(secure=True, headers={"x-content-type-options": "foo"}) + self.assertEqual(response["x-content-type-options"], "foo") + + @override_settings(SECURE_CONTENT_TYPE_NOSNIFF=False) + def test_content_type_off(self): + """ + With CONTENT_TYPE_NOSNIFF False, the middleware does not add an + "x-content-type-options" header to the response. + """ + self.assertNotIn("x-content-type-options", self.process_response()) + + @override_settings(SECURE_BROWSER_XSS_FILTER=True) + def test_xss_filter_on(self): + """ + With BROWSER_XSS_FILTER set to True, the middleware adds + "s-xss-protection: 1; mode=block" header to the response. + """ + self.assertEqual( + self.process_response()["x-xss-protection"], + "1; mode=block") + + @override_settings(SECURE_BROWSER_XSS_FILTER=True) + def test_xss_filter_already_present(self): + """ + The middleware will not override an "x-xss-protection" header + already present in the response. + """ + response = self.process_response(secure=True, headers={"x-xss-protection": "foo"}) + self.assertEqual(response["x-xss-protection"], "foo") + + @override_settings(BROWSER_XSS_FILTER=False) + def test_xss_filter_off(self): + """ + With BROWSER_XSS_FILTER set to False, the middleware does not add an + "x-xss-protection" header to the response. + """ + self.assertFalse("x-xss-protection" in self.process_response()) + + @override_settings(SECURE_SSL_REDIRECT=True) + def test_ssl_redirect_on(self): + """ + With SSL_REDIRECT True, the middleware redirects any non-secure + requests to the https:// version of the same URL. + """ + ret = self.process_request("get", "/some/url?query=string") + self.assertEqual(ret.status_code, 301) + self.assertEqual( + ret["Location"], "https://testserver/some/url?query=string") + + @override_settings(SECURE_SSL_REDIRECT=True) + def test_no_redirect_ssl(self): + """ + The middleware does not redirect secure requests. + """ + ret = self.process_request("get", "/some/url", secure=True) + self.assertEqual(ret, None) + + @override_settings( + SECURE_SSL_REDIRECT=True, SECURE_REDIRECT_EXEMPT=["^insecure/"]) + def test_redirect_exempt(self): + """ + The middleware does not redirect requests with URL path matching an + exempt pattern. + """ + ret = self.process_request("get", "/insecure/page") + self.assertEqual(ret, None) + + @override_settings( + SECURE_SSL_REDIRECT=True, SECURE_SSL_HOST="secure.example.com") + def test_redirect_ssl_host(self): + """ + The middleware redirects to SSL_HOST if given. + """ + ret = self.process_request("get", "/some/url") + self.assertEqual(ret.status_code, 301) + self.assertEqual(ret["Location"], "https://secure.example.com/some/url") + + @override_settings(SECURE_SSL_REDIRECT=False) + def test_ssl_redirect_off(self): + """ + With SSL_REDIRECT False, the middleware does no redirect. + """ + ret = self.process_request("get", "/some/url") + self.assertEqual(ret, None)