csp/checks.py (97 lines of code) (raw):

from __future__ import annotations import pprint from collections.abc import Sequence from importlib.metadata import version from typing import TYPE_CHECKING, Any from django.conf import settings from django.core import checks from django.core.checks import Error, register from packaging.version import Version from csp.constants import NONCE if TYPE_CHECKING: from django.apps.config import AppConfig OUTDATED_SETTINGS = [ "CSP_CHILD_SRC", "CSP_CONNECT_SRC", "CSP_DEFAULT_SRC", "CSP_SCRIPT_SRC", "CSP_SCRIPT_SRC_ATTR", "CSP_SCRIPT_SRC_ELEM", "CSP_OBJECT_SRC", "CSP_STYLE_SRC", "CSP_STYLE_SRC_ATTR", "CSP_STYLE_SRC_ELEM", "CSP_FONT_SRC", "CSP_FRAME_SRC", "CSP_IMG_SRC", "CSP_MANIFEST_SRC", "CSP_MEDIA_SRC", "CSP_PREFETCH_SRC", "CSP_WORKER_SRC", "CSP_BASE_URI", "CSP_PLUGIN_TYPES", "CSP_SANDBOX", "CSP_FORM_ACTION", "CSP_FRAME_ANCESTORS", "CSP_NAVIGATE_TO", "CSP_REQUIRE_SRI_FOR", "CSP_REQUIRE_TRUSTED_TYPES_FOR", "CSP_TRUSTED_TYPES", "CSP_UPGRADE_INSECURE_REQUESTS", "CSP_BLOCK_ALL_MIXED_CONTENT", "CSP_REPORT_URI", "CSP_REPORT_TO", ] def migrate_settings() -> tuple[dict[str, Any], bool]: # This function is used to migrate settings from the old format to the new format. config: dict[str, Any] = { "DIRECTIVES": {}, } REPORT_ONLY = getattr(settings, "CSP_REPORT_ONLY", False) _EXCLUDE_URL_PREFIXES = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", None) if _EXCLUDE_URL_PREFIXES is not None: config["EXCLUDE_URL_PREFIXES"] = _EXCLUDE_URL_PREFIXES _REPORT_PERCENTAGE = getattr(settings, "CSP_REPORT_PERCENTAGE", None) if _REPORT_PERCENTAGE is not None: config["REPORT_PERCENTAGE"] = _REPORT_PERCENTAGE * 100 include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", []) for setting in OUTDATED_SETTINGS: if hasattr(settings, setting): directive = setting[4:].replace("_", "-").lower() value = getattr(settings, setting) if value: config["DIRECTIVES"][directive] = value if directive in include_nonce_in: config["DIRECTIVES"][directive].append(NONCE) return config, REPORT_ONLY @register(checks.Tags.security) def check_django_csp_lt_4_0(app_configs: Sequence[AppConfig] | None, **kwargs: Any) -> list[Error]: check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"] if any(hasattr(settings, setting) for setting in check_settings): # Try to build the new config. config, REPORT_ONLY = migrate_settings() warning = ( "You are using django-csp < 4.0 settings. Please update your settings to use the new format.\n" "See https://django-csp.readthedocs.io/en/latest/migration-guide.html for more information.\n\n" "We have attempted to build the new CSP config for you based on your current settings:\n\n" f"CONTENT_SECURITY_POLICY{'_REPORT_ONLY' if REPORT_ONLY else ''} = " + pprint.pformat(config, sort_dicts=True) ) return [Error(warning, id="csp.E001")] return [] @register(checks.Tags.security) def check_exclude_url_prefixes_is_not_string(app_configs: Sequence[AppConfig] | None, **kwargs: Any) -> list[Error]: """ Check that EXCLUDE_URL_PREFIXES in settings is not a string. If it is a string it can lead to a security issue where the string is treated as a list of characters, resulting in '/' matching all paths excluding the CSP header from all responses. """ # Skip check for django-csp < 4.0. if Version(version("django-csp")) < Version("4.0a1"): return [] errors = [] keys = ( "CONTENT_SECURITY_POLICY", "CONTENT_SECURITY_POLICY_REPORT_ONLY", ) for key in keys: config = getattr(settings, key, {}) if isinstance(config, dict) and isinstance(config.get("EXCLUDE_URL_PREFIXES"), str): errors.append( Error( f"EXCLUDE_URL_PREFIXES in {key} settings must be a list or tuple.", id="csp.E002", ) ) return errors