"""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 ``