Browse Source

Fixed #14261 - Added clickjacking protection (X-Frame-Options header)

Many thanks to rniemeyer for the patch!

git-svn-id: http://code.djangoproject.com/svn/django/trunk@16298 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Luke Plant 14 years ago
parent
commit
524c5fa07a

+ 1 - 0
AUTHORS

@@ -534,6 +534,7 @@ answer newbie questions, and generally made Django that much better:
     Jarek Zgoda <jarek.zgoda@gmail.com>
     Cheng Zhang
     Zlatko Mašek <zlatko.masek@gmail.com>
+    Ryan Niemeyer <https://profiles.google.com/ryan.niemeyer/about>
 
 A big THANK YOU goes to:
 

+ 3 - 0
django/conf/global_settings.py

@@ -406,6 +406,9 @@ URL_VALIDATOR_USER_AGENT = "Django/%s (http://www.djangoproject.com)" % get_vers
 DEFAULT_TABLESPACE = ''
 DEFAULT_INDEX_TABLESPACE = ''
 
+# Default X-Frame-Options header value
+X_FRAME_OPTIONS = 'SAMEORIGIN'
+
 ##############
 # MIDDLEWARE #
 ##############

+ 2 - 0
django/conf/project_template/settings.py

@@ -98,6 +98,8 @@ MIDDLEWARE_CLASSES = (
     'django.middleware.csrf.CsrfViewMiddleware',
     'django.contrib.auth.middleware.AuthenticationMiddleware',
     'django.contrib.messages.middleware.MessageMiddleware',
+    # Uncomment the next line for simple clickjacking protection:
+    # 'django.middleware.clickjacking.XFrameOptionsMiddleware',
 )
 
 ROOT_URLCONF = '{{ project_name }}.urls'

+ 51 - 0
django/middleware/clickjacking.py

@@ -0,0 +1,51 @@
+"""
+Clickjacking Protection Middleware.
+
+This module provides a middleware that implements protection against a
+malicious site loading resources from your site in a hidden frame.
+"""
+
+from django.conf import settings
+
+class XFrameOptionsMiddleware(object):
+    """
+    Middleware that sets the X-Frame-Options HTTP header in HTTP responses.
+
+    Does not set the header if it's already set or if the response contains
+    a xframe_options_exempt value set to True.
+
+    By default, sets the X-Frame-Options header to 'SAMEORIGIN', meaning the
+    response can only be loaded on a frame within the same site. To prevent the
+    response from being loaded in a frame in any site, set X_FRAME_OPTIONS in
+    your project's Django settings to 'DENY'.
+
+    Note: older browsers will quietly ignore this header, thus other
+    clickjacking protection techniques should be used if protection in those
+    browsers is required.
+
+    http://en.wikipedia.org/wiki/Clickjacking#Server_and_client
+    """
+    def process_response(self, request, response):
+        # Don't set it if it's already in the response
+        if response.get('X-Frame-Options', None) is not None:
+            return response
+
+        # Don't set it if they used @xframe_options_exempt
+        if getattr(response, 'xframe_options_exempt', False):
+            return response
+
+        response['X-Frame-Options'] = self.get_xframe_options_value(request,
+                                                                    response)
+        return response
+
+    def get_xframe_options_value(self, request, response):
+        """
+        Gets the value to set for the X_FRAME_OPTIONS header.
+
+        By default this uses the value from the X_FRAME_OPTIONS Django
+        settings. If not found in settings, defaults to 'SAMEORIGIN'.
+
+        This method can be overridden if needed, allowing it to vary based on
+        the request or response.
+        """
+        return getattr(settings, 'X_FRAME_OPTIONS', 'SAMEORIGIN').upper()

+ 64 - 0
django/views/decorators/clickjacking.py

@@ -0,0 +1,64 @@
+from functools import wraps
+
+from django.utils.decorators import available_attrs
+
+
+def xframe_options_deny(view_func):
+    """
+    Modifies a view function so its response has the X-Frame-Options HTTP
+    header set to 'DENY' as long as the response doesn't already have that
+    header set.
+
+    e.g.
+
+    @xframe_options_deny
+    def some_view(request):
+        ...
+
+    """
+    def wrapped_view(*args, **kwargs):
+        resp = view_func(*args, **kwargs)
+        if resp.get('X-Frame-Options', None) is None:
+            resp['X-Frame-Options'] = 'DENY'
+        return resp
+    return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)
+
+
+def xframe_options_sameorigin(view_func):
+    """
+    Modifies a view function so its response has the X-Frame-Options HTTP
+    header set to 'SAMEORIGIN' as long as the response doesn't already have
+    that header set.
+
+    e.g.
+
+    @xframe_options_sameorigin
+    def some_view(request):
+        ...
+
+    """
+    def wrapped_view(*args, **kwargs):
+        resp = view_func(*args, **kwargs)
+        if resp.get('X-Frame-Options', None) is None:
+            resp['X-Frame-Options'] = 'SAMEORIGIN'
+        return resp
+    return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)
+
+
+def xframe_options_exempt(view_func):
+    """
+    Modifies a view function by setting a response variable that instructs
+    XFrameOptionsMiddleware to NOT set the X-Frame-Options HTTP header.
+
+    e.g.
+
+    @xframe_options_exempt
+    def some_view(request):
+        ...
+
+    """
+    def wrapped_view(*args, **kwargs):
+        resp = view_func(*args, **kwargs)
+        resp.xframe_options_exempt = True
+        return resp
+    return wraps(view_func, assigned=available_attrs(view_func))(wrapped_view)

+ 2 - 1
docs/index.txt

@@ -167,8 +167,9 @@ Other batteries included
     * :doc:`Admin site <ref/contrib/admin/index>` | :doc:`Admin actions <ref/contrib/admin/actions>` | :doc:`Admin documentation generator<ref/contrib/admin/admindocs>`
     * :doc:`Authentication <topics/auth>`
     * :doc:`Cache system <topics/cache>`
-    * :doc:`Conditional content processing <topics/conditional-view-processing>`
+    * :doc:`Clickjacking protection <ref/clickjacking>`
     * :doc:`Comments <ref/contrib/comments/index>` | :doc:`Moderation <ref/contrib/comments/moderation>` | :doc:`Custom comments <ref/contrib/comments/custom>`
+    * :doc:`Conditional content processing <topics/conditional-view-processing>`
     * :doc:`Content types <ref/contrib/contenttypes>`
     * :doc:`Cross Site Request Forgery protection <ref/contrib/csrf>`
     * :doc:`Cryptographic signing <topics/signing>`

+ 126 - 0
docs/ref/clickjacking.txt

@@ -0,0 +1,126 @@
+========================
+Clickjacking Protection
+========================
+
+.. module:: django.middleware.clickjacking
+   :synopsis: Protects against Clickjacking
+
+The clickjacking middleware and decorators provide easy-to-use protection
+against `clickjacking`_.  This type of attack occurs when a malicious site
+tricks a user into clicking on a concealed element of another site which they
+have loaded in a hidden frame or iframe.
+
+.. versionadded:: 1.4
+   The clickjacking middleware and decorators were added.
+
+.. _clickjacking: http://en.wikipedia.org/wiki/Clickjacking
+
+An example of clickjacking
+==========================
+
+Suppose an online store has a page where a logged in user can click "Buy Now" to
+purchase an item. A user has chosen to stay logged into the store all the time
+for convenience. An attacker site might create an "I Like Ponies" button on one
+of their own pages, and load the store's page in a transparent iframe such that
+the "Buy Now" button is invisibly overlaid on the "I Like Ponies" button. If the
+user visits the attacker site and clicks "I Like Ponies" he will inadvertently
+click on the online store's "Buy Now" button and unknowningly purchase the item.
+
+Preventing clickjacking
+=======================
+
+Modern browsers honor the `X-Frame-Options`_ HTTP header that indicates whether
+or not a resource is allowed to load within a frame or iframe. If the response
+contains the header with a value of SAMEORIGIN then the browser will only load
+the resource in a frame if the request originated from the same site. If the
+header is set to DENY then the browser will block the resource from loading in a
+frame no matter which site made the request.
+
+.. _X-Frame-Options: https://developer.mozilla.org/en/The_X-FRAME-OPTIONS_response_header
+
+Django provides a few simple ways to include this header in responses from your
+site:
+
+1. A simple middleware that sets the header in all responses.
+
+2. A set of view decorators that can be used to override the middleware or to
+   only set the header for certain views.
+
+How to use it
+=============
+
+Setting X-Frame-Options for all responses
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To set the same X-Frame-Options value for all responses in your site, add
+``'django.middleware.clickjacking.XFrameOptionsMiddleware'`` to
+:setting:`MIDDLEWARE_CLASSES`::
+
+    MIDDLEWARE_CLASSES = (
+        ...
+        'django.middleware.clickjacking.XFrameOptionsMiddleware',
+        ...
+    )
+
+By default, the middleware will set the X-Frame-Options header to SAMEORIGIN for
+every outgoing ``HttpResponse``. If you want DENY instead, set the
+:setting:`X_FRAME_OPTIONS` setting::
+
+    X_FRAME_OPTIONS = 'DENY'
+
+When using the middleware there may be some views where you do **not** want the
+X-Frame-Options header set. For those cases, you can use a view decorator that
+tells the middleware to not set the header::
+
+    from django.http import HttpResponse
+    from django.views.decorators.clickjacking import xframe_options_exempt
+
+    @xframe_options_exempt
+    def ok_to_load_in_a_frame(request):
+        return HttpResponse("This page is safe to load in a frame on any site.")
+
+
+Setting X-Frame-Options per view
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+To set the X-Frame-Options header on a per view basis, Django provides these
+decorators::
+
+    from django.http import HttpResponse
+    from django.views.decorators.clickjacking import xframe_options_deny
+    from django.views.decorators.clickjacking import xframe_options_sameorigin
+
+    @xframe_options_deny
+    def view_one(request):
+        return HttpResponse("I won't display in any frame!")
+
+    @xframe_options_sameorigin
+    def view_two(request):
+        return HttpResponse("Display in a frame if it's from the same origin as me.")
+
+Note that you can use the decorators in conjunction with the middleware. Use of
+a decorator overrides the middleware.
+
+Limitations
+===========
+
+The `X-Frame-Options` header will only protect against clickjacking in a modern
+browser. Older browsers will quietly ignore the header and need `other
+clickjacking prevention techniques`_.
+
+Browsers that support X-Frame-Options
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+* Internet Explorer 8+
+* Firefox	3.6.9+
+* Opera	10.5+
+* Safari	4+
+* Chrome	4.1+
+
+See also
+~~~~~~~~
+
+A `complete list`_ of browsers supporting X-Frame-Options.
+
+.. _complete list: https://developer.mozilla.org/en/The_X-FRAME-OPTIONS_response_header#Browser_compatibility
+.. _other clickjacking prevention techniques: http://en.wikipedia.org/wiki/Clickjacking#Prevention

+ 1 - 0
docs/ref/index.txt

@@ -6,6 +6,7 @@ API Reference
    :maxdepth: 1
 
    authbackends
+   clickjacking
    contrib/index
    databases
    django-admin

+ 13 - 0
docs/ref/middleware.txt

@@ -204,3 +204,16 @@ Middleware modules running inside it (coming later in the stack) will be under
 the same transaction control as the view functions.
 
 See the :doc:`transaction management documentation </topics/db/transactions>`.
+
+X-Frame-Options middleware
+--------------------------
+
+.. module:: django.middleware.clickjacking
+   :synopsis: Clickjacking protection
+
+.. class:: XFrameOptionsMiddleware
+
+.. versionadded:: 1.4
+   ``XFrameOptionsMiddleware`` was added.
+
+Simple :doc:`clickjacking protection via the X-Frame-Options header </ref/clickjacking/>`.

+ 11 - 0
docs/ref/settings.txt

@@ -2023,6 +2023,17 @@ See :tfilter:`allowed date format strings <date>`. See also
 :setting:`DATE_FORMAT`, :setting:`DATETIME_FORMAT`, :setting:`TIME_FORMAT`
 and :setting:`MONTH_DAY_FORMAT`.
 
+.. setting:: X_FRAME_OPTIONS
+
+X_FRAME_OPTIONS
+---------------
+
+Default: ``'SAMEORIGIN'``
+
+The default value for the X-Frame-Options header used by
+:class:`~django.middleware.clickjacking.XFrameOptionsMiddleware`. See the
+:doc:`clickjacking protection </ref/clickjacking/>` documentation.
+
 Deprecated settings
 ===================
 

+ 9 - 0
docs/releases/1.4.txt

@@ -55,6 +55,15 @@ signing in Web applications.
 
 See :doc:`cryptographic signing </topics/signing>` docs for more information.
 
+Simple clickjacking protection
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+We've added a middleware to provide easy protection against `clickjacking
+<http://en.wikipedia.org/wiki/Clickjacking>`_ using the X-Frame-Options
+header. It's not enabled by default for backwards compatibility reasons, but
+you'll almost certainly want to :doc:`enable it </ref/clickjacking/>` to help
+plug that security hole for browsers that support the header.
+
 ``reverse_lazy``
 ~~~~~~~~~~~~~~~~
 

+ 46 - 0
tests/regressiontests/decorators/tests.py

@@ -9,6 +9,8 @@ from django.utils.unittest import TestCase
 from django.views.decorators.http import require_http_methods, require_GET, require_POST, require_safe
 from django.views.decorators.vary import vary_on_headers, vary_on_cookie
 from django.views.decorators.cache import cache_page, never_cache, cache_control
+from django.views.decorators.clickjacking import xframe_options_deny, xframe_options_sameorigin, xframe_options_exempt
+from django.middleware.clickjacking import XFrameOptionsMiddleware
 
 
 def fully_decorated(request):
@@ -216,3 +218,47 @@ class MethodDecoratorTests(TestCase):
 
         self.assertEqual(Test.method.__doc__, 'A method')
         self.assertEqual(Test.method.im_func.__name__, 'method')
+
+
+class XFrameOptionsDecoratorsTests(TestCase):
+    """
+    Tests for the X-Frame-Options decorators.
+    """
+    def test_deny_decorator(self):
+        """
+        Ensures @xframe_options_deny properly sets the X-Frame-Options header.
+        """
+        @xframe_options_deny
+        def a_view(request):
+            return HttpResponse()
+        r = a_view(HttpRequest())
+        self.assertEqual(r['X-Frame-Options'], 'DENY')
+
+    def test_sameorigin_decorator(self):
+        """
+        Ensures @xframe_options_sameorigin properly sets the X-Frame-Options
+        header.
+        """
+        @xframe_options_sameorigin
+        def a_view(request):
+            return HttpResponse()
+        r = a_view(HttpRequest())
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+    def test_exempt_decorator(self):
+        """
+        Ensures @xframe_options_exempt properly instructs the
+        XFrameOptionsMiddleware to NOT set the header.
+        """
+        @xframe_options_exempt
+        def a_view(request):
+            return HttpResponse()
+        req = HttpRequest()
+        resp = a_view(req)
+        self.assertEqual(resp.get('X-Frame-Options', None), None)
+        self.assertTrue(resp.xframe_options_exempt)
+
+        # Since the real purpose of the exempt decorator is to suppress
+        # the middleware's functionality, let's make sure it actually works...
+        r = XFrameOptionsMiddleware().process_response(req, resp)
+        self.assertEqual(r.get('X-Frame-Options', None), None)

+ 124 - 0
tests/regressiontests/middleware/tests.py

@@ -5,6 +5,8 @@ import re
 from django.conf import settings
 from django.core import mail
 from django.http import HttpRequest
+from django.http import HttpResponse
+from django.middleware.clickjacking import XFrameOptionsMiddleware
 from django.middleware.common import CommonMiddleware
 from django.middleware.http import ConditionalGetMiddleware
 from django.test import TestCase
@@ -371,3 +373,125 @@ class ConditionalGetMiddlewareTest(TestCase):
         self.resp['Last-Modified'] = 'Sat, 12 Feb 2011 17:41:44 GMT'
         self.resp = ConditionalGetMiddleware().process_response(self.req, self.resp)
         self.assertEqual(self.resp.status_code, 200)
+
+
+class XFrameOptionsMiddlewareTest(TestCase):
+    """
+    Tests for the X-Frame-Options clickjacking prevention middleware.
+    """
+    def setUp(self):
+        self.x_frame_options = settings.X_FRAME_OPTIONS
+
+    def tearDown(self):
+        settings.X_FRAME_OPTIONS = self.x_frame_options
+
+    def test_same_origin(self):
+        """
+        Tests that the X_FRAME_OPTIONS setting can be set to SAMEORIGIN to
+        have the middleware use that value for the HTTP header.
+        """
+        settings.X_FRAME_OPTIONS = 'SAMEORIGIN'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+        settings.X_FRAME_OPTIONS = 'sameorigin'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+    def test_deny(self):
+        """
+        Tests that the X_FRAME_OPTIONS setting can be set to DENY to
+        have the middleware use that value for the HTTP header.
+        """
+        settings.X_FRAME_OPTIONS = 'DENY'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'DENY')
+
+        settings.X_FRAME_OPTIONS = 'deny'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'DENY')
+
+    def test_defaults_sameorigin(self):
+        """
+        Tests that if the X_FRAME_OPTIONS setting is not set then it defaults
+        to SAMEORIGIN.
+        """
+        del settings.X_FRAME_OPTIONS
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+    def test_dont_set_if_set(self):
+        """
+        Tests that if the X-Frame-Options header is already set then the
+        middleware does not attempt to override it.
+        """
+        settings.X_FRAME_OPTIONS = 'DENY'
+        response = HttpResponse()
+        response['X-Frame-Options'] = 'SAMEORIGIN'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       response)
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+        settings.X_FRAME_OPTIONS = 'SAMEORIGIN'
+        response = HttpResponse()
+        response['X-Frame-Options'] = 'DENY'
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       response)
+        self.assertEqual(r['X-Frame-Options'], 'DENY')
+
+    def test_response_exempt(self):
+        """
+        Tests that if the response has a xframe_options_exempt attribute set
+        to False then it still sets the header, but if it's set to True then
+        it does not.
+        """
+        settings.X_FRAME_OPTIONS = 'SAMEORIGIN'
+        response = HttpResponse()
+        response.xframe_options_exempt = False
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       response)
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+        response = HttpResponse()
+        response.xframe_options_exempt = True
+        r = XFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       response)
+        self.assertEqual(r.get('X-Frame-Options', None), None)
+
+    def test_is_extendable(self):
+        """
+        Tests that the XFrameOptionsMiddleware method that determines the
+        X-Frame-Options header value can be overridden based on something in
+        the request or response.
+        """
+        class OtherXFrameOptionsMiddleware(XFrameOptionsMiddleware):
+            # This is just an example for testing purposes...
+            def get_xframe_options_value(self, request, response):
+                if getattr(request, 'sameorigin', False):
+                    return 'SAMEORIGIN'
+                if getattr(response, 'sameorigin', False):
+                    return 'SAMEORIGIN'
+                return 'DENY'
+
+        settings.X_FRAME_OPTIONS = 'DENY'
+        response = HttpResponse()
+        response.sameorigin = True
+        r = OtherXFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                            response)
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+        request = HttpRequest()
+        request.sameorigin = True
+        r = OtherXFrameOptionsMiddleware().process_response(request,
+                                                            HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'SAMEORIGIN')
+
+        settings.X_FRAME_OPTIONS = 'SAMEORIGIN'
+        r = OtherXFrameOptionsMiddleware().process_response(HttpRequest(),
+                                                       HttpResponse())
+        self.assertEqual(r['X-Frame-Options'], 'DENY')