diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 8108f8f762..0827e85f69 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -636,6 +636,7 @@ SILENCED_SYSTEM_CHECKS = [] ####################### SECURE_BROWSER_XSS_FILTER = False SECURE_CONTENT_TYPE_NOSNIFF = True +SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' SECURE_HSTS_INCLUDE_SUBDOMAINS = False SECURE_HSTS_PRELOAD = False SECURE_HSTS_SECONDS = 0 diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py index 0de9532def..d95fab19b9 100644 --- a/django/core/checks/security/base.py +++ b/django/core/checks/security/base.py @@ -3,6 +3,9 @@ from django.core.exceptions import ImproperlyConfigured from .. import Error, Tags, Warning, register +CROSS_ORIGIN_OPENER_POLICY_VALUES = { + 'same-origin', 'same-origin-allow-popups', 'unsafe-none', +} REFERRER_POLICY_VALUES = { 'no-referrer', 'no-referrer-when-downgrade', 'origin', 'origin-when-cross-origin', 'same-origin', 'strict-origin', @@ -17,8 +20,8 @@ W001 = Warning( "You do not have 'django.middleware.security.SecurityMiddleware' " "in your MIDDLEWARE so the SECURE_HSTS_SECONDS, " "SECURE_CONTENT_TYPE_NOSNIFF, SECURE_BROWSER_XSS_FILTER, " - "SECURE_REFERRER_POLICY, and SECURE_SSL_REDIRECT settings will have no " - "effect.", + "SECURE_REFERRER_POLICY, SECURE_CROSS_ORIGIN_OPENER_POLICY, " + "and SECURE_SSL_REDIRECT settings will have no effect.", id='security.W001', ) @@ -119,6 +122,15 @@ E023 = Error( id='security.E023', ) +E024 = Error( + 'You have set the SECURE_CROSS_ORIGIN_OPENER_POLICY setting to an invalid ' + 'value.', + hint='Valid values are: {}.'.format( + ', '.join(sorted(CROSS_ORIGIN_OPENER_POLICY_VALUES)), + ), + id='security.E024', +) + def _security_middleware(): return 'django.middleware.security.SecurityMiddleware' in settings.MIDDLEWARE @@ -232,3 +244,14 @@ def check_referrer_policy(app_configs, **kwargs): if not values <= REFERRER_POLICY_VALUES: return [E023] return [] + + +@register(Tags.security, deploy=True) +def check_cross_origin_opener_policy(app_configs, **kwargs): + if ( + _security_middleware() and + settings.SECURE_CROSS_ORIGIN_OPENER_POLICY is not None and + settings.SECURE_CROSS_ORIGIN_OPENER_POLICY not in CROSS_ORIGIN_OPENER_POLICY_VALUES + ): + return [E024] + return [] diff --git a/django/middleware/security.py b/django/middleware/security.py index f27c6804b9..b9c5da9db5 100644 --- a/django/middleware/security.py +++ b/django/middleware/security.py @@ -17,6 +17,7 @@ class SecurityMiddleware(MiddlewareMixin): self.redirect_host = settings.SECURE_SSL_HOST self.redirect_exempt = [re.compile(r) for r in settings.SECURE_REDIRECT_EXEMPT] self.referrer_policy = settings.SECURE_REFERRER_POLICY + self.cross_origin_opener_policy = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY def process_request(self, request): path = request.path.lstrip("/") @@ -52,4 +53,9 @@ class SecurityMiddleware(MiddlewareMixin): if isinstance(self.referrer_policy, str) else self.referrer_policy )) + if self.cross_origin_opener_policy: + response.setdefault( + 'Cross-Origin-Opener-Policy', + self.cross_origin_opener_policy, + ) return response diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 0dbd15b5c9..a9f51b3ce9 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -417,8 +417,9 @@ The following checks are run if you use the :option:`check --deploy` option: :class:`django.middleware.security.SecurityMiddleware` in your :setting:`MIDDLEWARE` so the :setting:`SECURE_HSTS_SECONDS`, :setting:`SECURE_CONTENT_TYPE_NOSNIFF`, :setting:`SECURE_BROWSER_XSS_FILTER`, - :setting:`SECURE_REFERRER_POLICY`, and :setting:`SECURE_SSL_REDIRECT` - settings will have no effect. + :setting:`SECURE_REFERRER_POLICY`, + :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY`, and + :setting:`SECURE_SSL_REDIRECT` settings will have no effect. * **security.W002**: You do not have :class:`django.middleware.clickjacking.XFrameOptionsMiddleware` in your :setting:`MIDDLEWARE`, so your pages will not be served with an @@ -510,6 +511,8 @@ The following checks are run if you use the :option:`check --deploy` option: should consider enabling this header to protect user privacy. * **security.E023**: You have set the :setting:`SECURE_REFERRER_POLICY` setting to an invalid value. +* **security.E024**: You have set the + :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to an invalid value. The following checks verify that your security-related settings are correctly configured: diff --git a/docs/ref/middleware.txt b/docs/ref/middleware.txt index 0078c716c0..82888ba3da 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -198,6 +198,7 @@ enabled or disabled with a setting. * :setting:`SECURE_BROWSER_XSS_FILTER` * :setting:`SECURE_CONTENT_TYPE_NOSNIFF` +* :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` * :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` * :setting:`SECURE_HSTS_PRELOAD` * :setting:`SECURE_HSTS_SECONDS` @@ -354,6 +355,43 @@ this setting are: __ https://w3c.github.io/webappsec-referrer-policy/#unknown-policy-values +.. _cross-origin-opener-policy: + +Cross-Origin Opener Policy +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. versionadded:: 4.0 + +Some browsers have the ability to isolate top-level windows from other +documents by putting them in a separate browsing context group based on the +value of the `Cross-Origin Opener Policy`__ (COOP) header. If a document that +is isolated in this way opens a cross-origin popup window, the popup’s +``window.opener`` property will be ``null``. Isolating windows using COOP is a +defense-in-depth protection against cross-origin attacks, especially those like +Spectre which allowed exfiltration of data loaded into a shared browsing +context. + +__ https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Cross-Origin-Opener-Policy + +``SecurityMiddleware`` can set the ``Cross-Origin-Opener-Policy`` header for +you, based on the :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting. The +valid values for this setting are: + +``same-origin`` + Isolates the browsing context exclusively to same-origin documents. + Cross-origin documents are not loaded in the same browsing context. This + is the default and most secure option. + +``same-origin-allow-popups`` + Isolates the browsing context to same-origin documents or those which + either don't set COOP or which opt out of isolation by setting a COOP of + ``unsafe-none``. + +``unsafe-none`` + Allows the document to be added to its opener's browsing context group + unless the opener itself has a COOP of ``same-origin`` or + ``same-origin-allow-popups``. + .. _x-content-type-options: ``X-Content-Type-Options: nosniff`` diff --git a/docs/ref/settings.txt b/docs/ref/settings.txt index 5c938f26f1..382ce5c08a 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2262,6 +2262,20 @@ If ``True``, the :class:`~django.middleware.security.SecurityMiddleware` sets the :ref:`x-content-type-options` header on all responses that do not already have it. +.. setting:: SECURE_CROSS_ORIGIN_OPENER_POLICY + +``SECURE_CROSS_ORIGIN_OPENER_POLICY`` +------------------------------------- + +.. versionadded:: 4.0 + +Default: ``'same-origin'`` + +Unless set to ``None``, the +:class:`~django.middleware.security.SecurityMiddleware` sets the +:ref:`cross-origin-opener-policy` header on all responses that do not already +have it to the value provided. + .. setting:: SECURE_HSTS_INCLUDE_SUBDOMAINS ``SECURE_HSTS_INCLUDE_SUBDOMAINS`` @@ -3599,6 +3613,7 @@ HTTP * :setting:`SECURE_BROWSER_XSS_FILTER` * :setting:`SECURE_CONTENT_TYPE_NOSNIFF` + * :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` * :setting:`SECURE_HSTS_INCLUDE_SUBDOMAINS` * :setting:`SECURE_HSTS_PRELOAD` * :setting:`SECURE_HSTS_SECONDS` diff --git a/docs/releases/4.0.txt b/docs/releases/4.0.txt index c7a9bb87b9..b6f826f053 100644 --- a/docs/releases/4.0.txt +++ b/docs/releases/4.0.txt @@ -229,7 +229,11 @@ Models Requests and Responses ~~~~~~~~~~~~~~~~~~~~~~ -* ... +* The :class:`~django.middleware.security.SecurityMiddleware` now adds the + :ref:`Cross-Origin Opener Policy ` header with a + value of ``'same-origin'`` to prevent cross-origin popups from sharing the + same browsing context. You can prevent this header from being added by + setting the :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` setting to ``None``. Security ~~~~~~~~ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 4ccf34ca4d..c65722cd5b 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -204,6 +204,7 @@ Ess ETag ETags exe +exfiltration extensibility Facebook fallback @@ -608,6 +609,7 @@ sortable spam spammers spatialite +Spectre Springmeyer SQL ssi diff --git a/docs/topics/security.txt b/docs/topics/security.txt index fe692cad2a..5d60805a26 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -213,6 +213,19 @@ protect the privacy of your users, restricting under which circumstances the ``Referer`` header is set. See :ref:`the referrer policy section of the security middleware reference ` for details. +Cross-origin opener policy +========================== + +.. versionadded:: 4.0 + +The cross-origin opener policy (COOP) header allows browsers to isolate a +top-level window from other documents by putting them in a different context +group so that they cannot directly interact with the top-level window. If a +document protected by COOP opens a cross-origin popup window, the popup’s +``window.opener`` property will be ``null``. COOP protects against cross-origin +attacks. See :ref:`the cross-origin opener policy section of the security +middleware reference ` for details. + Session security ================ diff --git a/tests/check_framework/test_security.py b/tests/check_framework/test_security.py index 3a3b9cf774..774ba068f9 100644 --- a/tests/check_framework/test_security.py +++ b/tests/check_framework/test_security.py @@ -504,3 +504,28 @@ class CSRFFailureViewTest(SimpleTestCase): csrf.check_csrf_failure_view(None), [Error(msg, id='security.E101')], ) + + +class CheckCrossOriginOpenerPolicyTest(SimpleTestCase): + @override_settings( + MIDDLEWARE=['django.middleware.security.SecurityMiddleware'], + SECURE_CROSS_ORIGIN_OPENER_POLICY=None, + ) + def test_no_coop(self): + self.assertEqual(base.check_cross_origin_opener_policy(None), []) + + @override_settings(MIDDLEWARE=['django.middleware.security.SecurityMiddleware']) + def test_with_coop(self): + tests = ['same-origin', 'same-origin-allow-popups', 'unsafe-none'] + for value in tests: + with self.subTest(value=value), override_settings( + SECURE_CROSS_ORIGIN_OPENER_POLICY=value, + ): + self.assertEqual(base.check_cross_origin_opener_policy(None), []) + + @override_settings( + MIDDLEWARE=['django.middleware.security.SecurityMiddleware'], + SECURE_CROSS_ORIGIN_OPENER_POLICY='invalid-value', + ) + def test_with_invalid_coop(self): + self.assertEqual(base.check_cross_origin_opener_policy(None), [base.E024]) diff --git a/tests/middleware/test_security.py b/tests/middleware/test_security.py index d766643b9b..1b7434c9a8 100644 --- a/tests/middleware/test_security.py +++ b/tests/middleware/test_security.py @@ -282,3 +282,42 @@ class SecurityMiddlewareTest(SimpleTestCase): """ response = self.process_response(headers={'Referrer-Policy': 'unsafe-url'}) self.assertEqual(response.headers['Referrer-Policy'], 'unsafe-url') + + @override_settings(SECURE_CROSS_ORIGIN_OPENER_POLICY=None) + def test_coop_off(self): + """ + With SECURE_CROSS_ORIGIN_OPENER_POLICY set to None, the middleware does + not add a "Cross-Origin-Opener-Policy" header to the response. + """ + self.assertNotIn('Cross-Origin-Opener-Policy', self.process_response()) + + def test_coop_default(self): + """SECURE_CROSS_ORIGIN_OPENER_POLICY defaults to same-origin.""" + self.assertEqual( + self.process_response().headers['Cross-Origin-Opener-Policy'], + 'same-origin', + ) + + def test_coop_on(self): + """ + With SECURE_CROSS_ORIGIN_OPENER_POLICY set to a valid value, the + middleware adds a "Cross-Origin_Opener-Policy" header to the response. + """ + tests = ['same-origin', 'same-origin-allow-popups', 'unsafe-none'] + for value in tests: + with self.subTest(value=value), override_settings( + SECURE_CROSS_ORIGIN_OPENER_POLICY=value, + ): + self.assertEqual( + self.process_response().headers['Cross-Origin-Opener-Policy'], + value, + ) + + @override_settings(SECURE_CROSS_ORIGIN_OPENER_POLICY='unsafe-none') + def test_coop_already_present(self): + """ + The middleware doesn't override a "Cross-Origin-Opener-Policy" header + already present in the response. + """ + response = self.process_response(headers={'Cross-Origin-Opener-Policy': 'same-origin'}) + self.assertEqual(response.headers['Cross-Origin-Opener-Policy'], 'same-origin') diff --git a/tests/project_template/test_settings.py b/tests/project_template/test_settings.py index e8d466938d..e526e10331 100644 --- a/tests/project_template/test_settings.py +++ b/tests/project_template/test_settings.py @@ -38,6 +38,7 @@ class TestStartProjectSettings(SimpleTestCase): self.assertEqual(headers, [ b'Content-Length: 0', b'Content-Type: text/html; charset=utf-8', + b'Cross-Origin-Opener-Policy: same-origin', b'Referrer-Policy: same-origin', b'X-Content-Type-Options: nosniff', b'X-Frame-Options: DENY',