forked from ungleich-public/dynamicweb
336 lines
14 KiB
Python
336 lines
14 KiB
Python
"""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 ``<script>``/``<link>``/``<style>`` tag with its attributes.
|
|
ASSET_TAG_RE = re.compile(r"<(script|link|style)\b([^>]*)>", re.IGNORECASE)
|
|
#: Everything django CMS itself ships is served from below this prefix.
|
|
CMS_STATIC_PREFIX = "/static/cms/"
|
|
|
|
requires_csp = unittest.skipUnless(CSP_NONCE_SUPPORTED, "Django < 6.1 has no CSP nonce support")
|
|
|
|
|
|
def render_template(template_string, **context):
|
|
return Template("{% load cms_static %}" + template_string).render(Context(context))
|
|
|
|
|
|
def csp_overrides():
|
|
"""Settings that turn on Django 6.1's nonce-based CSP for the test project."""
|
|
from django.utils.csp import CSP
|
|
|
|
templates = deepcopy(settings.TEMPLATES)
|
|
templates[0]["OPTIONS"]["context_processors"].append("django.template.context_processors.csp")
|
|
return {
|
|
"TEMPLATES": templates,
|
|
"MIDDLEWARE": ["django.middleware.csp.ContentSecurityPolicyMiddleware", *settings.MIDDLEWARE],
|
|
"SECURE_CSP": {
|
|
"default-src": [CSP.SELF],
|
|
"script-src": [CSP.SELF, CSP.NONCE],
|
|
"style-src": [CSP.SELF, CSP.NONCE],
|
|
},
|
|
}
|
|
|
|
|
|
class CspNonceTagTests(CMSTestCase):
|
|
"""Unit tests for the ``cms_static`` template tags."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.media = Media(css={"screen": ["cms/css/test.css"]}, js=["cms/js/test.js"])
|
|
|
|
def test_nonce_attr_renders_nothing_without_a_nonce(self):
|
|
self.assertHTMLEqual(
|
|
render_template("<script src='x' {% csp_nonce_attr %}></script>"),
|
|
"<script src='x'></script>",
|
|
)
|
|
|
|
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("<script src='x' {% csp_nonce_attr %}></script>", csp_nonce=NONCE),
|
|
f"<script src='x' nonce='{NONCE}'></script>",
|
|
)
|
|
|
|
@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.
|
|
|
|
``<link>`` 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 ``<script type="application/json">`` data blocks are
|
|
exempt: browsers never run them, so CSP never checks them, and Django's
|
|
own ``json_script()`` leaves them bare.
|
|
"""
|
|
nonce = self.get_header_nonce(response)
|
|
tags = self.get_cms_asset_tags(response)
|
|
|
|
for attributes in tags:
|
|
self.assertIn(
|
|
f'nonce="{nonce}"',
|
|
attributes,
|
|
f"<... {attributes.strip()}> is missing the CSP nonce",
|
|
)
|
|
|
|
self.assertGreaterEqual(
|
|
len(tags), minimum, f"expected at least {minimum} django CMS asset(s) in the response"
|
|
)
|
|
return tags
|
|
|
|
def assertJsonBlocksAreNonceFree(self, response):
|
|
json_tags = [
|
|
attributes
|
|
for tag, attributes in ASSET_TAG_RE.findall(response.content.decode())
|
|
if tag.lower() == "script" and 'type="application/json"' in attributes
|
|
]
|
|
self.assertGreater(len(json_tags), 0, "expected JSON data blocks in the response")
|
|
for attributes in json_tags:
|
|
self.assertNotIn("nonce=", attributes, "JSON data blocks must not carry a nonce")
|
|
|
|
|
|
@requires_csp
|
|
class CspNonceAdminViewTests(NonceAssertionsMixin, CMSTestCase):
|
|
"""django CMS admin pages must emit the request nonce on their own assets."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
self.page = create_page("home", "nav_playground.html", "en")
|
|
self.superuser = self.get_superuser()
|
|
|
|
def test_page_tree(self):
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(self.get_pages_admin_list_uri())
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
def test_page_change_form(self):
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(self.get_page_change_uri("en", self.page))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
def test_plugin_change_form(self):
|
|
placeholder = self.page.get_placeholders("en").get(slot="body")
|
|
plugin = add_plugin(placeholder, "LinkPlugin", "en", name="link", external_link="http://example.com")
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(self.get_change_plugin_uri(plugin))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
def test_usersettings_change_form(self):
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(admin_reverse("cms_usersettings_change", args=(self.superuser.pk,)))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
def test_page_delete_confirmation(self):
|
|
"""The delete confirmation carries django CMS' only inline ``<style>``.
|
|
|
|
It has no ``href``, so it is checked by hand rather than through
|
|
:meth:`assertCmsAssetsAreNonced`.
|
|
"""
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(self.get_admin_url(type(self.page), "delete", self.page.pk))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, f'<style nonce="{self.get_header_nonce(response)}">')
|
|
|
|
def test_wizard(self):
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(admin_reverse("cms_wizard_create"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
|
|
@requires_csp
|
|
class CspNonceToolbarViewTests(NonceAssertionsMixin, CMSTestCase):
|
|
"""The toolbar injects its assets into arbitrary front end pages."""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
clear_url_caches()
|
|
self.superuser = self.get_superuser()
|
|
|
|
def tearDown(self):
|
|
super().tearDown()
|
|
clear_url_caches()
|
|
|
|
def get_edit_response(self, page):
|
|
with override_settings(**csp_overrides()):
|
|
with self.login_user_context(self.superuser):
|
|
return self.client.get(get_object_edit_url(page.get_content_obj("en"), "en"))
|
|
|
|
def test_toolbar_on_a_cms_page(self):
|
|
response = self.get_edit_response(create_page("home", "nav_playground.html", "en"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "cms-config-json")
|
|
self.assertCmsAssetsAreNonced(response)
|
|
self.assertJsonBlocksAreNonceFree(response)
|
|
|
|
def test_toolbar_assets_carry_the_nonce(self):
|
|
"""The toolbar's assets are emitted one at a time through sekizai.
|
|
|
|
``cms.text.css`` comes from the toolbar's ``Media`` and therefore
|
|
exercises ``{% render_media_assets %}``; the other two are plain tags.
|
|
"""
|
|
response = self.get_edit_response(create_page("home", "nav_playground.html", "en"))
|
|
nonce = self.get_header_nonce(response)
|
|
content = response.content.decode()
|
|
for asset in ("cms.base.css", "bundle.toolbar.min.js", "djangocms_text/css/cms.text.css"):
|
|
with self.subTest(asset=asset):
|
|
tag = next(
|
|
(attrs for _, attrs in ASSET_TAG_RE.findall(content) if asset in attrs),
|
|
None,
|
|
)
|
|
self.assertIsNotNone(tag, f"{asset} is missing from the response")
|
|
self.assertIn(f'nonce="{nonce}"', tag)
|
|
|
|
def test_welcome_page(self):
|
|
with override_settings(**csp_overrides()):
|
|
response = self.client.get("/en/")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.template_name, "cms/welcome.html")
|
|
self.assertCmsAssetsAreNonced(response)
|
|
|
|
|
|
class NoCspConfiguredTests(NonceAssertionsMixin, CMSTestCase):
|
|
"""Without CSP configured no ``nonce`` attribute may appear anywhere.
|
|
|
|
These run on every supported Django version, and are the regression test
|
|
for templates referencing a tag that does not exist below Django 6.1.
|
|
"""
|
|
|
|
def setUp(self):
|
|
super().setUp()
|
|
clear_url_caches()
|
|
self.page = create_page("home", "nav_playground.html", "en")
|
|
self.superuser = self.get_superuser()
|
|
|
|
def tearDown(self):
|
|
super().tearDown()
|
|
clear_url_caches()
|
|
|
|
def test_admin_views_render_without_nonces(self):
|
|
endpoints = {
|
|
"page tree": self.get_pages_admin_list_uri(),
|
|
"page change form": self.get_page_change_uri("en", self.page),
|
|
"usersettings": admin_reverse("cms_usersettings_change", args=(self.superuser.pk,)),
|
|
"wizard": admin_reverse("cms_wizard_create"),
|
|
"delete confirmation": self.get_admin_url(type(self.page), "delete", self.page.pk),
|
|
}
|
|
with self.login_user_context(self.superuser):
|
|
for name, endpoint in endpoints.items():
|
|
with self.subTest(view=name):
|
|
response = self.client.get(endpoint)
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertNoNonces(response)
|
|
|
|
def test_toolbar_renders_without_nonces(self):
|
|
with self.login_user_context(self.superuser):
|
|
response = self.client.get(get_object_edit_url(self.page.get_content_obj("en"), "en"))
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertContains(response, "cms-config-json")
|
|
self.assertNoNonces(response)
|
|
|
|
def test_welcome_page_renders_without_nonces(self):
|
|
self.page.delete()
|
|
response = self.client.get("/en/")
|
|
self.assertEqual(response.status_code, 200)
|
|
self.assertEqual(response.template_name, "cms/welcome.html")
|
|
self.assertNoNonces(response)
|