diff --git a/django/conf/global_settings.py b/django/conf/global_settings.py index 5b15d9617d..4d9096e0a5 100644 --- a/django/conf/global_settings.py +++ b/django/conf/global_settings.py @@ -665,3 +665,8 @@ SECURE_REDIRECT_EXEMPT = [] SECURE_REFERRER_POLICY = "same-origin" SECURE_SSL_HOST = None SECURE_SSL_REDIRECT = False +SECURE_CSP = {} +SECURE_CSP_REPORT_ONLY = False +SECURE_CSP_MULTIPLE = None +SECURE_CSP_INCLUDE_NONCE_IN = None +SECURE_CSP_EXCLUDE_URL_PREFIXES = () diff --git a/django/middleware/security.py b/django/middleware/security.py index 52c81e3406..9954dd46a9 100644 --- a/django/middleware/security.py +++ b/django/middleware/security.py @@ -17,6 +17,11 @@ class SecurityMiddleware(MiddlewareMixin): self.redirect_exempt = [re.compile(r) for r in settings.SECURE_REDIRECT_EXEMPT] self.referrer_policy = settings.SECURE_REFERRER_POLICY self.cross_origin_opener_policy = settings.SECURE_CROSS_ORIGIN_OPENER_POLICY + self.csp = settings.SECURE_CSP + self.csp_multiple = settings.SECURE_CSP_MULTIPLE + self.csp_report_only = settings.SECURE_CSP_REPORT_ONLY + self.csp_nonce = settings.SECURE_CSP_INCLUDE_NONCE_IN + self.csp_exclude_url_prefixes = settings.SECURE_CSP_EXCLUDE_URL_PREFIXES def process_request(self, request): path = request.path.lstrip("/") @@ -63,4 +68,33 @@ class SecurityMiddleware(MiddlewareMixin): "Cross-Origin-Opener-Policy", self.cross_origin_opener_policy, ) + + if request.path_info.startswith(self.csp_exclude_url_prefixes): + return response + + if self.csp: + header = "Content-Security-Policy" + csp_header_value = "; ".join((f"{k} {v}" for k, v in self.csp.items())) + + if self.csp_report_only: + header += "-Report-Only" + + if self.csp_nonce: + nonce = getattr(request, "_csp_nonce", None) + csp_header_value += "; 'nonce-%s'" % nonce + response.headers[header] = csp_header_value + + if self.csp_multiple: + # Support a comma-separated string or iterable of values to allow + # fallback. + header = "Content-Security-Policy" + csp_header_value = "; ".join( + [v.strip() for v in self.csp_multiple.split(";")] + if isinstance(self.csp_multiple, str) + else self.csp_multiple + ) + if self.csp_report_only: + header += "-Report-Only" + response.headers[header] = csp_header_value + return response diff --git a/django/template/context_processors.py b/django/template/context_processors.py index 32753032fc..0a16137531 100644 --- a/django/template/context_processors.py +++ b/django/template/context_processors.py @@ -87,3 +87,9 @@ def media(request): def request(request): return {"request": request} + + +def nonce(request): + nonce = request.csp_nonce if hasattr(request, "csp_nonce") else "" + + return {"CSP_NONCE": nonce} diff --git a/django/views/decorators/csp.py b/django/views/decorators/csp.py new file mode 100644 index 0000000000..050e8806e7 --- /dev/null +++ b/django/views/decorators/csp.py @@ -0,0 +1,16 @@ +from functools import wraps + + +def csp(**kwargs): + csp_header_value = "; ".join((f"{k} {v}" for k, v in kwargs.items())) + + def decorator(f): + @wraps(f) + def _wrapped(*a, **kw): + resp = f(*a, **kw) # response object from the view + resp["Content-Security-Policy"] = csp_header_value + return resp + + return _wrapped + + return decorator