csp/utils.py (154 lines of code) (raw):

from __future__ import annotations import copy import re from itertools import chain from typing import Any, Callable from django.conf import settings from django.utils.encoding import force_str from csp.constants import NONCE, SELF DEFAULT_DIRECTIVES = { # Fetch Directives "child-src": None, "connect-src": None, "default-src": [SELF], "script-src": None, "script-src-attr": None, "script-src-elem": None, "object-src": None, "style-src": None, "style-src-attr": None, "style-src-elem": None, "font-src": None, "frame-src": None, "img-src": None, "manifest-src": None, "media-src": None, "prefetch-src": None, # Deprecated. # Document Directives "base-uri": None, "plugin-types": None, # Deprecated. "sandbox": None, # Navigation Directives "form-action": None, "frame-ancestors": None, "navigate-to": None, # Reporting Directives "report-uri": None, "report-to": None, "require-sri-for": None, # Trusted Types Directives "require-trusted-types-for": None, "trusted-types": None, # Other Directives "webrtc": None, "worker-src": None, # Directives Defined in Other Documents "upgrade-insecure-requests": False, "block-all-mixed-content": False, # Deprecated. } DIRECTIVES_T = dict[str, Any] def default_config(csp: DIRECTIVES_T | None) -> DIRECTIVES_T | None: if csp is None: return None # Make a copy of the passed in config to avoid mutating it, and also to drop any unknown keys. config = {} for key, value in DEFAULT_DIRECTIVES.items(): config[key] = csp.get(key, value) return config def build_policy( config: DIRECTIVES_T | None = None, update: DIRECTIVES_T | None = None, replace: DIRECTIVES_T | None = None, nonce: str | None = None, report_only: bool = False, ) -> str: """Builds the policy as a string from the settings.""" if config is None: if report_only: config = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}) config = default_config(config.get("DIRECTIVES", {})) if config else None else: config = getattr(settings, "CONTENT_SECURITY_POLICY", {}) config = default_config(config.get("DIRECTIVES", {})) if config else None # If config is still `None`, return empty policy. if config is None: return "" update = update if update is not None else {} replace = replace if replace is not None else {} csp = {} for k in set(chain(config, replace)): if k in replace: v = replace[k] else: v = config[k] if v is not None: v = copy.copy(v) if isinstance(v, set): v = sorted(v) if not isinstance(v, (list, tuple)): v = (v,) csp[k] = v for k, v in update.items(): if v is not None: v = copy.copy(v) if isinstance(v, set): v = sorted(v) if not isinstance(v, (list, tuple)): v = (v,) if csp.get(k) is None: csp[k] = v else: csp[k] += tuple(v) report_uri = csp.pop("report-uri", None) policy_parts = {} for key, value in csp.items(): # Check for boolean directives. if len(value) == 1 and isinstance(value[0], bool): if value[0] is True: policy_parts[key] = "" continue if NONCE in value: if nonce: value = [f"'nonce-{nonce}'" if v == NONCE else v for v in value] else: # Strip the `NONCE` sentinel value if no nonce is provided. value = [v for v in value if v != NONCE] value = list(dict.fromkeys(value)) # Deduplicate policy_parts[key] = " ".join(value) if report_uri: report_uri = map(force_str, report_uri) policy_parts["report-uri"] = " ".join(report_uri) return "; ".join([f"{k} {val}".strip() for k, val in policy_parts.items()]) def _default_attr_mapper(attr_name: str, val: str) -> str: if val: return f' {attr_name}="{val}"' else: return "" def _bool_attr_mapper(attr_name: str, val: bool) -> str: # Only return the bare word if the value is truthy # ie - defer=False should actually return an empty string if val: return f" {attr_name}" else: return "" def _async_attr_mapper(attr_name: str, val: str | bool) -> str: """The `async` attribute works slightly different than the other bool attributes. It can be set explicitly to `false` with no surrounding quotes according to the spec.""" if val in [False, "False"]: return f" {attr_name}=false" elif val: return f" {attr_name}" else: return "" # Allow per-attribute customization of returned string template SCRIPT_ATTRS: dict[str, Callable[[str, Any], str]] = { "nonce": _default_attr_mapper, "id": _default_attr_mapper, "src": _default_attr_mapper, "type": _default_attr_mapper, "async": _async_attr_mapper, "defer": _bool_attr_mapper, "integrity": _default_attr_mapper, "nomodule": _bool_attr_mapper, } # Generates an interpolatable string of valid attrs eg - '{nonce}{id}...' ATTR_FORMAT_STR = "".join([f"{{{a}}}" for a in SCRIPT_ATTRS]) _script_tag_contents_re = re.compile( r"""<script # match the opening script tag [\s|\S]*?> # minimally match attrs and spaces in opening script tag ([\s|\S]+) # greedily capture the script tag contents </script> # match the closing script tag """, re.VERBOSE, ) def _unwrap_script(text: str) -> str: """Extract content defined between script tags""" matches = re.search(_script_tag_contents_re, text) if matches and len(matches.groups()): return matches.group(1).strip() return text def build_script_tag(content: str | None = None, **kwargs: Any) -> str: data = {} # Iterate all possible script attrs instead of kwargs to make # interpolation as easy as possible below for attr_name, mapper in SCRIPT_ATTRS.items(): data[attr_name] = mapper(attr_name, kwargs.get(attr_name)) # Don't render block contents if the script has a 'src' attribute c = _unwrap_script(content) if content and not kwargs.get("src") else "" attrs = ATTR_FORMAT_STR.format(**data).rstrip() return f"<script{attrs}>{c}</script>".strip()