diff --git a/CHANGELOG.txt b/CHANGELOG.txt index f8ebdbeeded..07ff99b6cc9 100644 --- a/CHANGELOG.txt +++ b/CHANGELOG.txt @@ -14,6 +14,10 @@ * Removed frontend integration tests (written with Selenium) * Provides the ability to declare cache expiration periods on a per-plugin basis * Minor UI improvements +* Adds new setting CMS_INTERNAL_IPS for defining a set of IP address for which + the toolbar will appear for authorized users. If left unset, retains the + existing behavior of allowing toolbar for authorized users at any IP address. + === 3.2.3 (2016-03-09) === diff --git a/cms/middleware/toolbar.py b/cms/middleware/toolbar.py index 1bc1061e2b7..f5602934609 100644 --- a/cms/middleware/toolbar.py +++ b/cms/middleware/toolbar.py @@ -12,8 +12,11 @@ from cms.utils.conf import get_cms_setting from cms.utils.i18n import force_language from cms.utils.placeholder import get_toolbar_plugin_struct +from cms.utils.request_ip_resolvers import get_request_ip_resolver from menus.menu_pool import menu_pool +get_request_ip = get_request_ip_resolver() + def toolbar_plugin_processor(instance, placeholder, rendered_content, original_context): from cms.plugin_pool import plugin_pool @@ -54,6 +57,12 @@ class ToolbarMiddleware(object): def is_cms_request(self, request): toolbar_hide = get_cms_setting('TOOLBAR_HIDE') + internal_ips = get_cms_setting('INTERNAL_IPS') + + if internal_ips: + client_ip = get_request_ip(request) + if client_ip not in internal_ips: + return False if not toolbar_hide: return True diff --git a/cms/tests/test_toolbar.py b/cms/tests/test_toolbar.py index 6e5ead563ea..aef5d59bac9 100644 --- a/cms/tests/test_toolbar.py +++ b/cms/tests/test_toolbar.py @@ -1,5 +1,7 @@ # -*- coding: utf-8 -*- + import datetime +import iptools import re from django.contrib import admin @@ -128,6 +130,31 @@ def test_app_setted_show_toolbar_in_cms_urls_subpage(self): request = self.get_page_request(page, self.get_anon()) self.assertTrue(hasattr(request, 'toolbar')) + def test_cms_internal_ips_unset(self): + with self.settings(CMS_INTERNAL_IPS=[]): + request = self.get_page_request(None, self.get_staff(), '/en/example/') + self.assertTrue(hasattr(request, 'toolbar')) + + def test_cms_internal_ips_set_no_match(self): + with self.settings(CMS_INTERNAL_IPS=['123.45.67.89', ]): + request = self.get_page_request(None, self.get_staff(), '/en/example/') + self.assertFalse(hasattr(request, 'toolbar')) + + def test_cms_internal_ips_set_match(self): + with self.settings(CMS_INTERNAL_IPS=['127.0.0.0', '127.0.0.1', '127.0.0.2', ]): + request = self.get_page_request(None, self.get_staff(), '/en/example/') + self.assertTrue(hasattr(request, 'toolbar')) + + def test_cms_internal_ips_iptools(self): + with self.settings(CMS_INTERNAL_IPS=iptools.IpRangeList(('127.0.0.0', '127.0.0.255'))): + request = self.get_page_request(None, self.get_staff(), '/en/example/') + self.assertTrue(hasattr(request, 'toolbar')) + + def test_cms_internal_ips_iptools_bad_range(self): + with self.settings(CMS_INTERNAL_IPS=iptools.IpRangeList(('128.0.0.0', '128.0.0.255'))): + request = self.get_page_request(None, self.get_staff(), '/en/example/') + self.assertFalse(hasattr(request, 'toolbar')) + @override_settings(CMS_PERMISSION=False) class ToolbarTests(ToolbarTestBase): diff --git a/cms/utils/conf.py b/cms/utils/conf.py index 49c2c7db57e..9e14f94be97 100644 --- a/cms/utils/conf.py +++ b/cms/utils/conf.py @@ -65,6 +65,8 @@ def wrapper(): 'WIZARD_DEFAULT_TEMPLATE': constants.TEMPLATE_INHERITANCE_MAGIC, 'WIZARD_CONTENT_PLUGIN': 'TextPlugin', 'WIZARD_CONTENT_PLUGIN_BODY': 'body', + 'INTERNAL_IPS': settings.INTERNAL_IPS, # Django default is [] + 'REQUEST_IP_RESOLVER': 'cms.utils.request_ip_resolvers.default_request_ip_resolver', } diff --git a/cms/utils/request_ip_resolvers.py b/cms/utils/request_ip_resolvers.py new file mode 100644 index 00000000000..fb3ba98a4bf --- /dev/null +++ b/cms/utils/request_ip_resolvers.py @@ -0,0 +1,76 @@ +# -*- coding: utf-8 -*- + +import importlib + +from django.core.exceptions import ImproperlyConfigured +from django.utils.translation import ugettext as _ + +from cms.utils.conf import get_cms_setting + + +def get_request_ip_resolver(): + """ + This is the recommended method for obtaining the specified + CMS_REQUEST_IP_RESOLVER as it also does some basic import validation. + + Returns the resolver or raises an ImproperlyConfigured exception. + """ + module, attribute = get_cms_setting('REQUEST_IP_RESOLVER').rsplit('.', 1) + try: + ip_resolver_module = importlib.import_module(module) + ip_resolver = getattr(ip_resolver_module, attribute) + except ImportError: + raise ImproperlyConfigured( + _('Unable to find the specified CMS_REQUEST_IP_RESOLVER module: ' + '"%s".') % module) + except AttributeError: + raise ImproperlyConfigured( + _('Unable to find the specified CMS_REQUEST_IP_RESOLVER function: ' + '"%s" in module "%s".') % (attribute, module, )) + return ip_resolver + + +def default_request_ip_resolver(request): + """ + This is a hybrid request IP resolver that attempts should address most + cases. Order is important here. A 'REAL_IP' header supersedes an + 'X_FORWARDED_FOR' header which supersedes a 'REMOTE_ADDR' header. + """ + return ( + real_ip(request) or + x_forwarded_ip(request) or + remote_addr_ip(request) + ) + + +def real_ip(request): + """ + Returns the IP Address contained in the HTTP_X_REAL_IP headers, if + present. Otherwise, `None`. + + Should handle Nginx and some other WSGI servers. + """ + return request.META.get('HTTP_X_REAL_IP') + + +def remote_addr_ip(request): + """ + Returns the IP Address contained in the 'REMOTE_ADDR' header, if + present. Otherwise, `None`. + + Should be suitable for local-development servers and some HTTP servers. + """ + return request.META.get('REMOTE_ADDR') + + +def x_forwarded_ip(request): + """ + Returns the IP Address contained in the 'HTTP_X_FORWARDED_FOR' header, if + present. Otherwise, `None`. + + Should handle properly configured proxy servers. + """ + ip_address_list = request.META.get('HTTP_X_FORWARDED_FOR') + if ip_address_list: + ip_address_list = ip_address_list.split(',') + return ip_address_list[0] diff --git a/docs/reference/configuration.rst b/docs/reference/configuration.rst index c00f65d3ce3..dd11403031e 100644 --- a/docs/reference/configuration.rst +++ b/docs/reference/configuration.rst @@ -717,6 +717,47 @@ user under which Django will be running. Advanced Settings ***************** +.. setting:: CMS_INTERNAL_IPS + +CMS_INTERNAL_IPS +================ + +default + settings.INTERNAL_IPS + +By default ``CMS_INTERNAL_IPS`` takes the same value as the Django setting +``INTERNAL_IPS`` which has a default value of an empty list. + +If left as an empty list (or anything Falsey, really), this setting does not +add any restrictions to the toolbar. However, if set, the toolbar will only +appear for client IP addresses that are in this list. + +This setting may also be set to an `IpRangeList` from the external package +``iptools``. This package allows convenient syntax for defining complex IP +address ranges. + +The client IP address is obtained via the :setting:`CMS_REQUEST_IP_RESOLVER` +in the ``cms.middleware.toolbar.ToolbarMiddleware`` middleware. + + +.. setting:: CMS_REQUEST_IP_RESOLVER + +CMS_REQUEST_IP_RESOLVER +======================= + +default + 'cms.utils.request_ip_resolvers.default_request_ip_resolver' + +This setting is used system-wide to provide a consistent and plug-able means +of extracting a client IP address from the HTTP request. The default +implementation should work for most project architectures, but if not, the +administrator can provide their own method to handle the project's +specific circumstances. + +The supplied method should accept a single argument `request` and return an +IP address String. + + .. setting:: CMS_PERMISSION CMS_PERMISSION diff --git a/test_requirements/requirements_base.txt b/test_requirements/requirements_base.txt index bf754eaf6f6..0596855bfb2 100644 --- a/test_requirements/requirements_base.txt +++ b/test_requirements/requirements_base.txt @@ -20,6 +20,7 @@ django-classy-tags>=0.7.2 -e git+git://github.com/divio/djangocms-link.git#egg=djangocms-link https://github.com/ojii/django-better-test/archive/8aa2407d097fe3789b74682f0e6bd7d15d449416.zip#egg=django-better-test https://github.com/ojii/django-app-manage/archive/65da18ef234a4e985710c2c0ec760023695b40fe.zip#egg=django-app-manage +iptools sphinx sphinxcontrib-spelling pyflakes