diff --git a/README.md b/README.md index 78749a6..81b673a 100644 --- a/README.md +++ b/README.md @@ -119,6 +119,11 @@ or minimum configuration. DEPRECATED: Will be removed in future releases.
Adds the HTTP header attribute specifying compact P3P policy. Required. + +ReferrerPolicyMiddleware +Specify when the browser will set a `Referer` header. +Optional. + SessionExpiryPolicyMiddleware Expire sessions on browser close, and on expiry times stored in the cookie itself. diff --git a/security/middleware.py b/security/middleware.py index 98a0573..af50896 100644 --- a/security/middleware.py +++ b/security/middleware.py @@ -17,7 +17,7 @@ from django.utils.deprecation import MiddlewareMixin import django.views.static -from ua_parser.user_agent_parser import ParseUserAgent +from ua_parser import user_agent_parser logger = logging.getLogger(__name__) @@ -901,7 +901,7 @@ def process_response(self, request, response): # choose headers based enforcement mode is_ie = False if 'HTTP_USER_AGENT' in request.META: - parsed_ua = ParseUserAgent(request.META['HTTP_USER_AGENT']) + parsed_ua = user_agent_parser.ParseUserAgent(request.META['HTTP_USER_AGENT']) is_ie = parsed_ua['family'] == 'IE' csp_header = 'Content-Security-Policy' @@ -1315,3 +1315,56 @@ def process_request(self, request): login_url = login_url + '?next=' + next_url return HttpResponseRedirect(login_url) + +class ReferrerPolicyMiddleware(BaseMiddleware): + """ + Sends Referrer-Policy HTTP header that controls when the browser will set + the `Referer` header. Use REFERRER_POLICY option in settings file + with the following values: + + - ``no-referrer`` + - ``no-referrer-when-downgrade`` + - ``origin`` + - ``origin-when-cross-origin`` + - ``same-origin`` (*default*) + - ``strict-origin`` + - ``strict-origin-when-cross-origin`` + - ``unsafe-url`` + - ``off`` + + Reference: + - `Referrer-Policy from Mozilla Developer Network + ` + """ + + OPTIONAL_SETTINGS = ("REFERRER_POLICY",) + + OPTIONS = [ 'no-referrer', 'no-referrer-when-downgrade', 'origin', + 'origin-when-cross-origin', 'same-origin', 'strict-origin', + 'strict-origin-when-cross-origin', 'unsafe-url', 'off' ] + + DEFAULT = 'same-origin' + + def load_setting(self, setting, value): + if not value: + self.option = self.DEFAULT + return + + value = value.lower() + + if value in self.OPTIONS: + self.option = value + return + + raise ImproperlyConfigured( + self.__class__.__name__ + " invalid option for REFERRER_POLICY." + ) + + def process_response(self, request, response): + """ + Add Referrer-Policy to the reponse header. + """ + if self.option != 'off': + header = self.option + response['Referrer-Policy'] = header + return response diff --git a/setup.py b/setup.py index 78c15fb..d02ffb5 100644 --- a/setup.py +++ b/setup.py @@ -6,7 +6,7 @@ from distutils.core import Command from setuptools import setup -with open(os.path.join(os.path.dirname(__file__), 'README.md')) as f: +with open(os.path.join(os.path.dirname(__file__), "README.md")) as f: readme = f.read() @@ -20,37 +20,44 @@ def finalize_options(self): pass def run(self): - errno = subprocess.call([sys.executable, 'testing/manage.py', 'test']) + errno = subprocess.call([sys.executable, "testing/manage.py", "test"]) raise SystemExit(errno) -setup(name="django-security", - description='A collection of tools to help secure a Django project.', - long_description=readme, - long_description_content_type='text/markdown', - maintainer="SD Elements", - maintainer_email="django-security@sdelements.com", - version="0.13.2", - packages=["security", "security.south_migrations", - "security.migrations", "security.auth_throttling"], - url='https://github.com/sdelements/django-security', - classifiers=[ - 'Framework :: Django', - 'Framework :: Django :: 1.11', - 'Framework :: Django :: 2.2', - 'Framework :: Django :: 3.0', - 'Environment :: Web Environment', - 'Programming Language :: Python', - 'Programming Language :: Python :: 3', - 'Programming Language :: Python :: 3.6', - 'Intended Audience :: Developers', - 'Operating System :: OS Independent', - 'License :: OSI Approved :: BSD License', - 'Topic :: Software Development :: Libraries :: Python Modules', - 'Topic :: Security', - ], - install_requires=[ - 'django>=1.11', - 'ua_parser>=0.7.1', - 'python-dateutil==2.8.1', - ], - cmdclass={'test': Test}) + +setup( + name="django-security", + description="A collection of tools to help secure a Django project.", + long_description=readme, + long_description_content_type="text/markdown", + maintainer="SD Elements", + maintainer_email="django-security@sdelements.com", + version="0.13.2", + packages=[ + "security", + "security.south_migrations", + "security.migrations", + "security.auth_throttling", + ], + url="https://github.com/sdelements/django-security", + classifiers=[ + "Framework :: Django", + "Framework :: Django :: 1.11", + "Framework :: Django :: 2.2", + "Framework :: Django :: 3.0", + "Environment :: Web Environment", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3.6", + "Intended Audience :: Developers", + "Operating System :: OS Independent", + "License :: OSI Approved :: BSD License", + "Topic :: Software Development :: Libraries :: Python Modules", + "Topic :: Security", + ], + install_requires=[ + "django>=1.11", + "ua_parser>=0.7.1", + "python-dateutil==2.8.1", + ], + cmdclass={"test": Test}, +) diff --git a/testing/settings.py b/testing/settings.py index f81ce2d..a81213b 100644 --- a/testing/settings.py +++ b/testing/settings.py @@ -50,6 +50,7 @@ 'security.middleware.MandatoryPasswordChangeMiddleware', 'security.middleware.NoConfidentialCachingMiddleware', 'security.auth_throttling.Middleware', + 'security.middleware.ReferrerPolicyMiddleware', ) ROOT_URLCONF = 'testing.urls' diff --git a/testing/tests/tests.py b/testing/tests/tests.py index d9b5e02..3ce346d 100644 --- a/testing/tests/tests.py +++ b/testing/tests/tests.py @@ -24,7 +24,7 @@ from security.middleware import ( BaseMiddleware, ContentSecurityPolicyMiddleware, DoNotTrackMiddleware, SessionExpiryPolicyMiddleware, MandatoryPasswordChangeMiddleware, - XssProtectMiddleware, XFrameOptionsMiddleware, + XssProtectMiddleware, XFrameOptionsMiddleware, ReferrerPolicyMiddleware ) from security.models import PasswordExpiry from security.password_expiry import never_expire_password @@ -1064,24 +1064,70 @@ def test_DNT_echo_default(self): self.dnt.process_response(self.request, self.response) self.assertNotIn('DNT', self.response) +class ReferrerPolicyTests(TestCase): -@override_settings(MIDDLEWARE=( - 'security.middleware.ClearSiteDataMiddleware', -)) -class ClearSiteDataMiddlewareTests(TestCase): - def test_request_that_matches_the_whitelist_with_default_directives(self): - response = self.client.get('/home/') - self.assertEqual(response['Clear-Site-Data'], '"cookies", "storage"') + def test_option_set(self): + """ + Verify the HTTP Referrer-Policy Header is set. + """ + response = self.client.get('/accounts/login/') + self.assertNotEqual(response['Referrer-Policy'], None) - def test_request_that_misses_the_whitelist(self): - response = self.client.get('/test1/') - self.assertNotIn("Clear-Site-Data", response) + def test_default_setting(self): + with self.settings(REFERRER_POLICY=None): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'same-origin') - @override_settings(CLEAR_SITE_DATA_DIRECTIVES=( - 'cache', 'cookies', 'executionContexts', '*' - )) - def test_request_that_matches_the_whitelist_with_custom_directives(self): - response = self.client.get('/home/') - self.assertEqual( - response['Clear-Site-Data'], - '"cache", "cookies", "executionContexts", "*"') + def test_no_referrer_setting(self): + with self.settings(REFERRER_POLICY='no-referrer'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'no-referrer') + + def test_no_referrer_when_downgrade_setting(self): + with self.settings(REFERRER_POLICY='no-referrer-when-downgrade'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'no-referrer-when-downgrade') + + def test_origin_setting(self): + with self.settings(REFERRER_POLICY='origin'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'origin') + + def test_origin_when_cross_origin_setting(self): + with self.settings(REFERRER_POLICY='origin-when-cross-origin'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'origin-when-cross-origin') + + def test_same_origin_setting(self): + with self.settings(REFERRER_POLICY='same-origin'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'same-origin') + + def test_strict_origin_setting(self): + with self.settings(REFERRER_POLICY='strict-origin'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'strict-origin') + + def test_strict_origin_when_cross_origin_setting(self): + with self.settings(REFERRER_POLICY='strict-origin-when-cross-origin'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'strict-origin-when-cross-origin') + + def test_unsafe_url_setting(self): + with self.settings(REFERRER_POLICY='unsafe-url'): + response = self.client.get('/accounts/login/') + self.assertEqual(response['Referrer-Policy'], 'unsafe-url') + + def test_off_setting(self): + with self.settings(REFERRER_POLICY='off'): + response = self.client.get('/accounts/login/') + self.assertEqual('Referrer-Policy' in response, False) + + def test_improper_configuration_raises(self): + referer_policy_middleware = ReferrerPolicyMiddleware() + self.assertRaises( + ImproperlyConfigured, + referer_policy_middleware.load_setting, + 'REFERRER_POLICY', + 'invalid', + )