Content Security Policy (CSP) enforcement / XSS mitigation
Description
The commit replaces a custom CSP middleware used in the Django admin Selenium tests with Django's official ContentSecurityPolicyMiddleware and configures SECURE_CSP to a strict policy. It also adds a test assertion to verify that no CSP violations are emitted in browser console logs. This is a defensive hardening fix addressing content security policy handling to reduce the risk of XSS in the admin interface stemming from misconfigurations in a custom CSP middleware, aligning admin CSP behavior with Django's built-in CSP support.
Commit Details
Author: Natalia
Date: 2025-06-25 20:22 UTC
Message:
Refs #15727 -- Updated AdminSeleniumTestCase to use ContentSecurityPolicyMiddleware.
Replaced the custom CSP middleware previously used in the admin's
AdminSeleniumTestCase with the official ContentSecurityPolicyMiddleware.
This change ensures alignment with Django's built-in CSP support.
Also updates the test logic to inspect browser console logs to assert
that no CSP violations are triggered during Selenium admin tests.
Triage Assessment
Vulnerability Type: Content Security Policy / XSS
Confidence: HIGH
Reasoning:
The commit replaces a custom CSP middleware with Django's official ContentSecurityPolicyMiddleware and updates tests to verify CSP behavior. CSP helps prevent Cross-Site Scripting (XSS) and related content injection by enforcing allowed sources. Aligning with built-in CSP support and adding validation that no CSP violations occur during admin Selenium tests indicates a security hardening fix.
Verification Assessment
Vulnerability Type: Content Security Policy (CSP) enforcement / XSS mitigation
Confidence: HIGH
Affected Versions: stable/5.1.x
Code Diff
diff --git a/django/contrib/admin/tests.py b/django/contrib/admin/tests.py
index f64a4c47f81a..3982142330a8 100644
--- a/django/contrib/admin/tests.py
+++ b/django/contrib/admin/tests.py
@@ -1,24 +1,27 @@
from contextlib import contextmanager
from django.contrib.staticfiles.testing import StaticLiveServerTestCase
-from django.test import modify_settings
+from django.test import modify_settings, override_settings
from django.test.selenium import SeleniumTestCase
-from django.utils.deprecation import MiddlewareMixin
+from django.utils.csp import CSP
from django.utils.translation import gettext as _
# Make unittest ignore frames in this module when reporting failures.
__unittest = True
-class CSPMiddleware(MiddlewareMixin):
- """The admin's JavaScript should be compatible with CSP."""
-
- def process_response(self, request, response):
- response.headers["Content-Security-Policy"] = "default-src 'self'"
- return response
-
-
-@modify_settings(MIDDLEWARE={"append": "django.contrib.admin.tests.CSPMiddleware"})
+@modify_settings(
+ MIDDLEWARE={"append": "django.middleware.csp.ContentSecurityPolicyMiddleware"}
+)
+@override_settings(
+ SECURE_CSP={
+ "default-src": [CSP.NONE],
+ "connect-src": [CSP.SELF],
+ "img-src": [CSP.SELF],
+ "script-src": [CSP.SELF],
+ "style-src": [CSP.SELF],
+ },
+)
class AdminSeleniumTestCase(SeleniumTestCase, StaticLiveServerTestCase):
available_apps = [
"django.contrib.admin",
@@ -28,6 +31,11 @@ class AdminSeleniumTestCase(SeleniumTestCase, StaticLiveServerTestCase):
"django.contrib.sites",
]
+ def tearDown(self):
+ # Ensure that no CSP violations were logged in the browser.
+ self.assertEqual(self.get_browser_logs(source="security"), [])
+ super().tearDown()
+
def wait_until(self, callback, timeout=10):
"""
Block the execution of the tests until the specified callback returns a
diff --git a/django/test/selenium.py b/django/test/selenium.py
index 15ee3002eca4..be8f4a815f00 100644
--- a/django/test/selenium.py
+++ b/django/test/selenium.py
@@ -77,7 +77,11 @@ def import_options(cls, browser):
def get_capability(cls, browser):
from selenium.webdriver.common.desired_capabilities import DesiredCapabilities
- return getattr(DesiredCapabilities, browser.upper())
+ caps = getattr(DesiredCapabilities, browser.upper())
+ if browser == "chrome":
+ caps["goog:loggingPrefs"] = {"browser": "ALL"}
+
+ return caps
def create_options(self):
options = self.import_options(self.browser)()
@@ -237,6 +241,19 @@ def take_screenshot(self, name):
path.parent.mkdir(exist_ok=True, parents=True)
self.selenium.save_screenshot(path)
+ def get_browser_logs(self, source=None, level="ALL"):
+ """Return Chrome console logs filtered by level and optionally source."""
+ try:
+ logs = self.selenium.get_log("browser")
+ except AttributeError:
+ logs = []
+ return [
+ log
+ for log in logs
+ if (level == "ALL" or log["level"] == level)
+ and (source is None or log["source"] == source)
+ ]
+
@classmethod
def _quit_selenium(cls):
# quit() the WebDriver before attempting to terminate and join the