diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 0aee63dbc38..6932340daf7 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -419,6 +419,15 @@ USE_X_FORWARDED_HOST = False # actual WSGI application object. WSGI_APPLICATION = None +# If your Django app is behind a proxy that sets a header to specify secure +# connections, AND that proxy ensures that user-submitted headers with the +# same name are ignored (so that people can't spoof it), set this value to +# a tuple of (header_name, header_value). For any requests that come in with +# that header/value, request.is_secure() will return True. +# WARNING! Only set this if you fully understand what you're doing. Otherwise, +# you may be opening yourself up to a security risk. +SECURE_PROXY_SSL_HEADER = None + ############## # MIDDLEWARE # ############## diff --git a/django/core/handlers/modpython.py b/django/core/handlers/modpython.py index b0b8a0618b7..fb23fb497a7 100644 --- a/django/core/handlers/modpython.py +++ b/django/core/handlers/modpython.py @@ -44,7 +44,7 @@ class ModPythonRequest(http.HttpRequest): # doesn't always happen, so rather than crash, we defensively encode it. return '%s%s' % (self.path, self._req.args and ('?' + iri_to_uri(self._req.args)) or '') - def is_secure(self): + def _is_secure(self): try: return self._req.is_https() except AttributeError: diff --git a/django/core/handlers/wsgi.py b/django/core/handlers/wsgi.py index a961aaf40ff..2898703a959 100644 --- a/django/core/handlers/wsgi.py +++ b/django/core/handlers/wsgi.py @@ -158,9 +158,8 @@ class WSGIRequest(http.HttpRequest): # Rather than crash if this doesn't happen, we encode defensively. return '%s%s' % (self.path, self.environ.get('QUERY_STRING', '') and ('?' + iri_to_uri(self.environ.get('QUERY_STRING', ''))) or '') - def is_secure(self): - return 'wsgi.url_scheme' in self.environ \ - and self.environ['wsgi.url_scheme'] == 'https' + def _is_secure(self): + return 'wsgi.url_scheme' in self.environ and self.environ['wsgi.url_scheme'] == 'https' def _get_request(self): if not hasattr(self, '_request'): diff --git a/django/http/__init__.py b/django/http/__init__.py index 476a62501c1..414440b9d9f 100644 --- a/django/http/__init__.py +++ b/django/http/__init__.py @@ -113,6 +113,7 @@ class CompatCookie(SimpleCookie): from django.conf import settings from django.core import signing +from django.core.exceptions import ImproperlyConfigured from django.core.files import uploadhandler from django.http.multipartparser import MultiPartParser from django.http.utils import * @@ -251,9 +252,23 @@ class HttpRequest(object): location = urljoin(current_uri, location) return iri_to_uri(location) - def is_secure(self): + def _is_secure(self): return os.environ.get("HTTPS") == "on" + def is_secure(self): + # First, check the SECURE_PROXY_SSL_HEADER setting. + if settings.SECURE_PROXY_SSL_HEADER: + try: + header, value = settings.SECURE_PROXY_SSL_HEADER + except ValueError: + raise ImproperlyConfigured('The SECURE_PROXY_SSL_HEADER setting must be a tuple containing two values.') + if self.META.get(header, None) == value: + return True + + # Failing that, fall back to _is_secure(), which is a hook for + # subclasses to implement. + return self._is_secure() + def is_ajax(self): return self.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest' diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index a35d99a535a..cb659a21fda 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -1530,6 +1530,64 @@ better. ``django-admin.py startproject`` creates one automatically. .. setting:: SEND_BROKEN_LINK_EMAILS +SECURE_PROXY_SSL_HEADER +----------------------- + +.. versionadded:: 1.4 + +Default: ``None`` + +A tuple representing a HTTP header/value combination that signifies a request +is secure. This controls the behavior of the request object's ``is_secure()`` +method. + +This takes some explanation. By default, ``is_secure()`` is able to determine +whether a request is secure by looking at whether the requested URL uses +"https://". + +If your Django app is behind a proxy, though, the proxy may be "swallowing" the +fact that a request is HTTPS, using a non-HTTPS connection between the proxy +and Django. In this case, ``is_secure()`` would always return ``False`` -- even +for requests that were made via HTTPS by the end user. + +In this situation, you'll want to configure your proxy to set a custom HTTP +header that tells Django whether the request came in via HTTPS, and you'll want +to set ``SECURE_PROXY_SSL_HEADER`` so that Django knows what header to look +for. + +You'll need to set a tuple with two elements -- the name of the header to look +for and the required value. For example:: + + SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') + +Here, we're telling Django that we trust the ``X-Forwarded-Protocol`` header +that comes from our proxy, and any time its value is ``'https'``, then the +request is guaranteed to be secure (i.e., it originally came in via HTTPS). +Obviously, you should *only* set this setting if you control your proxy or +have some other guarantee that it sets/strips this header appropriately. + +Note that the header needs to be in the format as used by ``request.META`` -- +all caps and likely starting with ``HTTP_``. (Remember, Django automatically +adds ``'HTTP_'`` to the start of x-header names before making the header +available in ``request.META``.) + +.. warning:: + + **You will probably open security holes in your site if you set this without knowing what you're doing. Seriously.** + + Make sure ALL of the following are true before setting this (assuming the + values from the example above): + + * Your Django app is behind a proxy. + * Your proxy strips the 'X-Forwarded-Protocol' header from all incoming + requests. In other words, if end users include that header in their + requests, the proxy will discard it. + * Your proxy sets the 'X-Forwarded-Protocol' header and sends it to Django, + but only for requests that originally come in via HTTPS. + + 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. + SEND_BROKEN_LINK_EMAILS ----------------------- diff --git a/tests/regressiontests/settings_tests/tests.py b/tests/regressiontests/settings_tests/tests.py index 7aa0e2a7963..c250aea1ace 100644 --- a/tests/regressiontests/settings_tests/tests.py +++ b/tests/regressiontests/settings_tests/tests.py @@ -3,6 +3,7 @@ from __future__ import with_statement import os from django.conf import settings, global_settings +from django.http import HttpRequest from django.test import TransactionTestCase, TestCase, signals from django.test.utils import override_settings @@ -209,6 +210,36 @@ class TrailingSlashURLTests(TestCase): self.assertEqual('http://media.foo.com/stupid//', self.settings_module.MEDIA_URL) +class SecureProxySslHeaderTest(TestCase): + settings_module = settings + + def setUp(self): + self._original_setting = self.settings_module.SECURE_PROXY_SSL_HEADER + + def tearDown(self): + self.settings_module.SECURE_PROXY_SSL_HEADER = self._original_setting + + def test_none(self): + self.settings_module.SECURE_PROXY_SSL_HEADER = None + req = HttpRequest() + self.assertEqual(req.is_secure(), False) + + def test_set_without_xheader(self): + self.settings_module.SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') + req = HttpRequest() + self.assertEqual(req.is_secure(), False) + + def test_set_with_xheader_wrong(self): + self.settings_module.SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') + req = HttpRequest() + req.META['HTTP_X_FORWARDED_PROTOCOL'] = 'wrongvalue' + self.assertEqual(req.is_secure(), False) + + def test_set_with_xheader_right(self): + self.settings_module.SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTOCOL', 'https') + req = HttpRequest() + req.META['HTTP_X_FORWARDED_PROTOCOL'] = 'https' + self.assertEqual(req.is_secure(), True) class EnvironmentVariableTest(TestCase): """