import random import string import typing from django.conf import settings # if you have a Report-To service, add it to settings.py along with # adding ReportToMiddleware to settings.MIDDLEWARE class ReportToMiddleware: def __init__(self, get_response): self.get_response = get_response self.directive = getattr(settings, "REPORT_TO_DIRECTIVE", None) def __call__(self, request): response = self.get_response(request) if request.path.startswith(settings.STATIC_URL): return response if self.directive and not response.has_header("Report-To"): response["Report-To"] = self.directive return response # CSPSources is where you configure all your various source sections class CSPSources: DEFAULT = ["'self'"] STYLE = [ "'self'", "https://cdn.jsdelivr.net", settings.STATIC_URL if settings.STATIC_URL.startswith("http") else None, # add more domains or sha hashes here ] SCRIPT = [ "'self'", "https://cdn.jsdelivr.net", settings.STATIC_URL if settings.STATIC_URL.startswith("http") else None, # add more domains or sha hashes here ] FONT = [ "'self'", settings.STATIC_URL if settings.STATIC_URL.startswith("http") else None, # add more domains or sha hashes here ] IMG = [ "'self'", "https:", # allow all secure images "data:", # allow inline images ] # for iframes, uncomment the YouTube lines if you want to embed YouTube videos FRAME = [ "'self'", # "https://www.youtube-nocookie.com", # "https://www.youtube.com", # "https://youtube.com", # add more domains or sha hashes here ] # for websockets. some analytics tools use these. # look for blocked domains in the web console and add them CONNECT = [ "'self'", ] @classmethod def get_source_section( cls, section: str, *, nonce: typing.Optional[str] = None ) -> typing.Sequence[str]: sources = list(getattr(cls, section.upper(), [])) if nonce and "'unsafe-inline'" not in sources: # it's an error to set nonce and unsafe-inline at the same time # https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/script-src#Unsafe_inline_script sources.append(f"'nonce-{nonce}'") return [x for x in sources if x] @classmethod def get_csp_header(cls, *, nonce: typing.Optional[str] = None) -> str: sources = { "default-src": cls.get_source_section("default", nonce=nonce), "style-src": cls.get_source_section("style", nonce=nonce), "script-src": cls.get_source_section("script", nonce=nonce), "font-src": cls.get_source_section("font"), "img-src": cls.get_source_section("img"), "frame-src": cls.get_source_section("frame"), "connect-src": cls.get_source_section("connect"), "report-to": ["default"], } csp = [ f"{key} {' '.join(sorted(values))}" for key, values in sorted(sources.items(), key=lambda x: x[0]) ] return "; ".join(csp).replace(" ; ", "; ").replace(" ", " ") class ContentSecurityPolicyMiddleware: """ This middleware adds a Content-Security-Policy header to most responses. It also replaces CSP_NONCE in the response body with a nonce value. By default, server errors are excluded from CSP _if_ DEBUG is True. Additionally, adding paths to settings.CSP_EXCLUDE_URL_PREFIXES will exclude those paths from CSP. """ def __init__(self, get_reponse): self.get_response = get_reponse self.nonce = "".join(random.choices(string.ascii_letters + string.digits, k=32)) def __call__(self, request): response = self.get_response(request) # this let's Django's error pages be styled during local development if settings.DEBUG and response.status_code in [500]: return response for prefix in list(getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", [])) + [ settings.STATIC_URL ]: if prefix and request.path.startswith(prefix): return response response["Content-Security-Policy"] = CSPSources.get_csp_header( nonce=self.nonce ) if response.get("Content-Type", "").startswith("text/html") and getattr( response, "content", None ): response.content = response.content.replace( b"CSP_NONCE", self.nonce.encode("utf-8"), ) if "Content-Length" in response: response["Content-Length"] = len(response.content) return response