diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 381ad63ae623..f16caaee9874 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -649,3 +649,4 @@ def gettext_noop(s): SECURE_REFERRER_POLICY = 'same-origin' SECURE_SSL_HOST = None SECURE_SSL_REDIRECT = False +SECURE_CROSS_ORIGIN_OPENER_POLICY = 'same-origin' diff --git a/django/core/checks/security/base.py b/django/core/checks/security/base.py index d96c318add24..a59754ba65ce 100644 --- a/django/core/checks/security/base.py +++ b/django/core/checks/security/base.py @@ -9,6 +9,10 @@ 'strict-origin-when-cross-origin', 'unsafe-url', } +CROSS_ORIGIN_OPENER_POLICY_VALUES = { + 'same-origin', 'same-origin-allow-popups', 'unsafe-none', +} + SECRET_KEY_MIN_LENGTH = 50 SECRET_KEY_MIN_UNIQUE_CHARACTERS = 5 @@ -16,8 +20,8 @@ "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', ) @@ -116,6 +120,19 @@ id='security.E023', ) +W024 = Warning( + 'You have not set the SECURE_CROSS_ORIGIN_OPENER_POLICY setting. Without this ' + 'your site will not send the Cross-Origin-Opener-Policy header. Consider ' + 'setting this header to protect your site from cross-origin attacks.', + id='security.W024', +) +E025 = Error( + 'You have set the SECURE_CROSS_ORIGIN_OPENER_POLICY setting to an invalid ' + 'value.', + hint='Valid values are: {}.'.format(', '.join(CROSS_ORIGIN_OPENER_POLICY_VALUES)), + id='security.E025' +) + E100 = Error( "DEFAULT_HASHING_ALGORITHM must be 'sha1' or 'sha256'.", id='security.E100', @@ -235,6 +252,16 @@ def check_referrer_policy(app_configs, **kwargs): return [] +@register(Tags.security, deploy=True) +def check_cross_origin_opener_policy(app_configs, **kwargs): + if _security_middleware(): + if settings.SECURE_CROSS_ORIGIN_OPENER_POLICY is None: + return [W024] + if settings.SECURE_CROSS_ORIGIN_OPENER_POLICY not in CROSS_ORIGIN_OPENER_POLICY_VALUES: + return [E025] + return [] + + # RemovedInDjango40Warning @register(Tags.security) def check_default_hashing_algorithm(app_configs, **kwargs): diff --git a/django/middleware/security.py b/django/middleware/security.py index 44921cd22b94..c8ae97a13fd3 100644 --- a/django/middleware/security.py +++ b/django/middleware/security.py @@ -19,6 +19,8 @@ def __init__(self, get_response=None): 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.coop = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY + self.get_response = get_response def process_request(self, request): path = request.path.lstrip("/") @@ -54,4 +56,7 @@ def process_response(self, request, response): if isinstance(self.referrer_policy, str) else self.referrer_policy )) + if self.coop: + response.setdefault('Cross-Origin-Opener-Policy', self.coop) + return response diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index 0e1ee50b4637..ae7c05ee0c56 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -391,8 +391,8 @@ 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_SSL_REDIRECT`, and + :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` 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 @@ -483,6 +483,12 @@ 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.W024**: You have not set the :setting:`SECURE_CROSS_ORIGIN_OPENER_POLICY` + setting. Without this, your site will not send the Cross-Origin-Opener-Policy + header. Consider setting this header to protect your site from cross-origin + attacks. +* **security.E025**: 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 c52bcc5d1889..9763ad2e5e66 100644 --- a/docs/ref/middleware.txt +++ b/docs/ref/middleware.txt @@ -182,6 +182,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` @@ -338,6 +339,37 @@ this setting are: __ https://w3c.github.io/webappsec-referrer-policy/#unknown-policy-values +.. _cross-origin-opener-policy: + +Cross-Origin Opener Policy +~~~~~~~~~~~~~~~~~~~~~~~~~~ + +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 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 make data loaded into a shared browsing context +group vulnerable. + +__ 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. + +``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 9a2f1457ac9c..58dba7f2bd8d 100644 --- a/docs/ref/settings.txt +++ b/docs/ref/settings.txt @@ -2273,6 +2273,17 @@ 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`` +--------------------------------------- + +Default: ``'same-origin'`` + +If configured, 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`` @@ -3631,6 +3642,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/3.2.txt b/docs/releases/3.2.txt index cc7ea744a2bf..112f4cf7cd76 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -319,6 +319,9 @@ Security ``SECRET_KEY``, and then going on to access ``settings.SECRET_KEY`` will now raise an :exc:`~django.core.exceptions.ImproperlyConfigured` exception. +* :class:`~django.middleware.security.SecurityMiddleware` can now send the + :ref:`Cross-Origin Opener Policy ` header. + Serialization ~~~~~~~~~~~~~ diff --git a/docs/spelling_wordlist b/docs/spelling_wordlist index 727d9cf66eb7..de5b5410be54 100644 --- a/docs/spelling_wordlist +++ b/docs/spelling_wordlist @@ -607,6 +607,7 @@ sortable spam spammers spatialite +Spectre Springmeyer SQL ssi diff --git a/docs/topics/security.txt b/docs/topics/security.txt index fe692cad2a33..d95754034ab2 100644 --- a/docs/topics/security.txt +++ b/docs/topics/security.txt @@ -213,6 +213,17 @@ 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 +========================== + +The cross-origin opener policy (COOP) header allows you to isolate a top-level +window from other documents by putting them in a different browsing context +group, so that they cannot directly interact with the top-level window. If a +document protected by COOP opens a popup window, the popup’s *window.opener* +property will be null. COOP is used to protect 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 270fece65929..aa662f461c77 100644 --- a/tests/check_framework/test_security.py +++ b/tests/check_framework/test_security.py @@ -464,3 +464,38 @@ def test_with_referrer_policy(self): ) def test_with_invalid_referrer_policy(self): self.assertEqual(base.check_referrer_policy(None), [base.E023]) + + +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), [base.W024]) + + @override_settings(MIDDLEWARE=[], SECURE_CROSS_ORIGIN_OPENER_POLICY=None) + def test_no_coop_no_middleware(self): + """ + Don't warn if SECURE_CROSS_ORIGIN_OPENER_POLICY is None and SecurityMiddleware + isn't in MIDDLEWARE. + """ + 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.E025]) diff --git a/tests/middleware/test_security.py b/tests/middleware/test_security.py index d907c2516654..ead6220cca0f 100644 --- a/tests/middleware/test_security.py +++ b/tests/middleware/test_security.py @@ -255,3 +255,34 @@ def test_referrer_policy_already_present(self): """ response = self.process_response(headers={'Referrer-Policy': 'unsafe-url'}) self.assertEqual(response['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_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'), + ('same-origin-allow-popups', 'same-origin-allow-popups'), + ('unsafe-none', 'unsafe-none'), + ) + for value, expected in tests: + with self.subTest(value=value), override_settings(SECURE_CROSS_ORIGIN_OPENER_POLICY=value): + self.assertEqual(self.process_response()['Cross-Origin-Opener-Policy'], expected) + + @override_settings(SECURE_CROSS_ORIGIN_OPENER_POLICY='unsafe-none') + def test_coop_already_present(self): + """ + The middleware will not 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['Cross-Origin-Opener-Policy'], 'same-origin') diff --git a/tests/project_template/test_settings.py b/tests/project_template/test_settings.py index e8d466938dcb..e526e10331c4 100644 --- a/tests/project_template/test_settings.py +++ b/tests/project_template/test_settings.py @@ -38,6 +38,7 @@ def test_middleware_headers(self): 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',