From 524c5fa07a38cc68a81a12428befdafcfde1afc3 Mon Sep 17 00:00:00 2001 From: Luke Plant Date: Mon, 30 May 2011 22:27:47 +0000 Subject: [PATCH] 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 --- AUTHORS | 1 + django/conf/global_settings.py | 3 + django/conf/project_template/settings.py | 2 + django/middleware/clickjacking.py | 51 +++++++++ django/views/decorators/clickjacking.py | 64 +++++++++++ docs/index.txt | 3 +- docs/ref/clickjacking.txt | 126 ++++++++++++++++++++++ docs/ref/index.txt | 1 + docs/ref/middleware.txt | 13 +++ docs/ref/settings.txt | 11 ++ docs/releases/1.4.txt | 9 ++ tests/regressiontests/decorators/tests.py | 46 ++++++++ tests/regressiontests/middleware/tests.py | 124 +++++++++++++++++++++ 13 files changed, 453 insertions(+), 1 deletion(-) create mode 100644 django/middleware/clickjacking.py create mode 100644 django/views/decorators/clickjacking.py create mode 100644 docs/ref/clickjacking.txt diff --git a/AUTHORS b/AUTHORS index a32e0d62cb..8b6f6544cd 100644 --- a/AUTHORS +++ b/AUTHORS @@ -534,6 +534,7 @@ answer newbie questions, and generally made Django that much better: Jarek Zgoda Cheng Zhang Zlatko MaĊĦek + Ryan Niemeyer A big THANK YOU goes to: diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index c98cab763d..288dc9a1d9 100644 --- a/django/conf/global_settings.py +++ b/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 # ############## diff --git a/django/conf/project_template/settings.py b/django/conf/project_template/settings.py index b74408ace8..e719dec5db 100644 --- a/django/conf/project_template/settings.py +++ b/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' diff --git a/django/middleware/clickjacking.py b/django/middleware/clickjacking.py new file mode 100644 index 0000000000..81763ef41f --- /dev/null +++ b/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() diff --git a/django/views/decorators/clickjacking.py b/django/views/decorators/clickjacking.py new file mode 100644 index 0000000000..fcd78871dd --- /dev/null +++ b/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) diff --git a/docs/index.txt b/docs/index.txt index 2c090d98c5..0896806da1 100644 --- a/docs/index.txt +++ b/docs/index.txt @@ -167,8 +167,9 @@ Other batteries included * :doc:`Admin site ` | :doc:`Admin actions ` | :doc:`Admin documentation generator` * :doc:`Authentication ` * :doc:`Cache system ` - * :doc:`Conditional content processing ` + * :doc:`Clickjacking protection ` * :doc:`Comments ` | :doc:`Moderation ` | :doc:`Custom comments ` + * :doc:`Conditional content processing ` * :doc:`Content types ` * :doc:`Cross Site Request Forgery protection ` * :doc:`Cryptographic signing ` diff --git a/docs/ref/clickjacking.txt b/docs/ref/clickjacking.txt new file mode 100644 index 0000000000..234fab2fa8 --- /dev/null +++ b/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 diff --git a/docs/ref/index.txt b/docs/ref/index.txt index f544e3cd98..db09afe4ac 100644 --- a/docs/ref/index.txt +++ b/docs/ref/index.txt @@ -6,6 +6,7 @@ API Reference :maxdepth: 1 authbackends + clickjacking contrib/index databases django-admin diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index cb90684847..7bb70986ef 100644 --- a/docs/ref/middleware.txt +++ b/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 `. + +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 `. diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 60448d2c26..816c3e92ff 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2023,6 +2023,17 @@ See :tfilter:`allowed date format strings `. 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 ` documentation. + Deprecated settings =================== diff --git a/docs/releases/1.4.txt b/docs/releases/1.4.txt index a579e96f93..8742103a36 100644 --- a/docs/releases/1.4.txt +++ b/docs/releases/1.4.txt @@ -55,6 +55,15 @@ signing in Web applications. See :doc:`cryptographic signing ` docs for more information. +Simple clickjacking protection +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +We've added a middleware to provide easy protection against `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 ` to help +plug that security hole for browsers that support the header. + ``reverse_lazy`` ~~~~~~~~~~~~~~~~ diff --git a/tests/regressiontests/decorators/tests.py b/tests/regressiontests/decorators/tests.py index d9a6b4caf7..8a03980f88 100644 --- a/tests/regressiontests/decorators/tests.py +++ b/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) diff --git a/tests/regressiontests/middleware/tests.py b/tests/regressiontests/middleware/tests.py index c069228487..124eb191f0 100644 --- a/tests/regressiontests/middleware/tests.py +++ b/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')