"""Tests for Content Security Policy nonce support in django CMS templates. Django 6.1 introduced a built-in ``{% csp_nonce_attr %}`` tag. django CMS ships its own implementation in ``cms_static`` so that the very same templates render on Django 5.2 and 6.0, where the built-in does not exist. """ import re import unittest from copy import deepcopy from django.conf import settings from django.forms import Media from django.template import Context, Template, TemplateSyntaxError from django.test.utils import override_settings from django.urls import clear_url_caches from cms.api import add_plugin, create_page from cms.templatetags.cms_static import CSP_NONCE_SUPPORTED from cms.test_utils.testcases import CMSTestCase from cms.toolbar.utils import get_object_edit_url from cms.utils.urlutils import admin_reverse NONCE = "cms-test-nonce" #: Matches ``nonce="..."`` on any element. NONCE_RE = re.compile(r'nonce="([^"]*)"') #: Matches the nonce echoed back in the ``Content-Security-Policy`` header. HEADER_NONCE_RE = re.compile(r"'nonce-([^']+)'") #: Matches an opening ``"), "", ) def test_media_renders_without_a_nonce(self): rendered = render_template("{% csp_nonce_attr media %}", media=self.media) self.assertIn("cms/css/test.css", rendered) self.assertIn("cms/js/test.js", rendered) self.assertNotIn("nonce=", rendered) def test_render_media_assets_returns_one_string_per_asset(self): for media_type, expected in (("css", "cms/css/test.css"), ("js", "cms/js/test.js")): with self.subTest(media_type=media_type): rendered = render_template( '{%% render_media_assets media "%s" as assets %%}' "{%% for asset in assets %%}[{{ asset }}]{%% endfor %%}" % media_type, media=self.media, ) self.assertEqual(rendered.count("["), 1) self.assertIn(expected, rendered) def test_render_media_assets_rejects_unknown_media_type(self): with self.assertRaises(TemplateSyntaxError): render_template('{% render_media_assets media "images" as assets %}', media=self.media) @requires_csp def test_nonce_attr_renders_the_nonce(self): self.assertHTMLEqual( render_template("", csp_nonce=NONCE), f"", ) @requires_csp def test_media_carries_the_nonce(self): rendered = render_template("{% csp_nonce_attr media %}", media=self.media, csp_nonce=NONCE) self.assertEqual(NONCE_RE.findall(rendered), [NONCE, NONCE]) @requires_csp def test_render_media_assets_carries_the_nonce(self): for media_type in ("css", "js"): with self.subTest(media_type=media_type): rendered = render_template( '{%% render_media_assets media "%s" as assets %%}' "{%% for asset in assets %%}{{ asset }}{%% endfor %%}" % media_type, media=self.media, csp_nonce=NONCE, ) self.assertEqual(NONCE_RE.findall(rendered), [NONCE]) class NonceAssertionsMixin: """Helpers shared by the view level tests. Only django CMS' own assets -- everything served from ``/static/cms/`` -- are asserted on. Django's admin templates nonce their assets themselves, and assets contributed by third party apps or by the project's own templates are outside django CMS' control. """ def assertNoNonces(self, response): self.assertNotIn("nonce=", response.content.decode()) def get_header_nonce(self, response): """Return the nonce the CSP middleware put in the response header.""" policy = response.headers["Content-Security-Policy"] nonces = set(HEADER_NONCE_RE.findall(policy)) self.assertEqual(len(nonces), 1, f"expected exactly one nonce in {policy!r}") return nonces.pop() def get_cms_asset_tags(self, response): """Yield the attribute strings of every nonce-relevant ``/static/cms/`` tag. ```` elements that are not stylesheets -- favicons, for instance -- are governed by ``img-src`` rather than ``style-src`` and take no nonce. """ return [ attributes for tag, attributes in ASSET_TAG_RE.findall(response.content.decode()) if CMS_STATIC_PREFIX in attributes and not (tag.lower() == "link" and 'rel="stylesheet"' not in attributes) ] def assertCmsAssetsAreNonced(self, response, minimum=1): """Every django CMS asset must carry the response's nonce. Non-executable ``