Skip to content

Instantly share code, notes, and snippets.

@dryan
Created September 13, 2023 14:40
Show Gist options
  • Save dryan/74fdb5d03a746b0492eab4cc9b73c329 to your computer and use it in GitHub Desktop.
Save dryan/74fdb5d03a746b0492eab4cc9b73c329 to your computer and use it in GitHub Desktop.
Django CSP Middleware
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
import functools
from unittest import TestCase
from django.conf import settings
from django.http import HttpRequest
from django.test import override_settings
from django.urls import reverse
from .middleware import ContentSecurityPolicyMiddleware
from .middleware import CSPSources
from .middleware import ReportToMiddleware
def get_response(request):
class MockResponse(dict):
def __init__(self, request):
self.request = request
def has_header(self, name):
return name in self
return MockResponse(request)
def get_html_response(request, markup=""):
response = get_response(request)
response["Content-Type"] = "text/html"
response.content = (
f'<!DOCTYPE html><html><head><link href="https://example.com/test" '
f'rel="shortlink"></head><body>{markup}</body></html>'
).encode()
return response
get_html_response_with_nonce = functools.partial(
get_html_response, markup='<script nonce="CSP_NONCE">console.log("test");</script>'
)
TEST_REPORT_TO_DIRECTIVE = (
'{"group":"default","max_age":31536000,'
'"endpoints":[{"url":"https://example.com/"}],'
'"include_subdomains":true}'
)
class ReportToMiddlewareTests(TestCase):
@override_settings(REPORT_TO_DIRECTIVE=TEST_REPORT_TO_DIRECTIVE)
def setUp(self):
super().setUp()
self.request = HttpRequest()
self.request.method = "GET"
self.request.path = reverse("home")
self.middleware = ReportToMiddleware(get_response)
def test_header_is_set(self):
output = self.middleware(self.request)
self.assertIn("Report-To", output)
self.assertEqual(output.get("Report-To"), TEST_REPORT_TO_DIRECTIVE)
def test_header_is_not_set_for_assets(self):
self.request.path = settings.STATIC_URL
output = self.middleware(self.request)
self.assertNotIn("Report-To", output)
class ContentSecurityPolicyMiddlewareTests(TestCase):
maxDiff = None
def setUp(self):
super().setUp()
self.request = HttpRequest()
self.request.method = "GET"
self.request.path = reverse("home")
self.middleware = ContentSecurityPolicyMiddleware(get_response)
def test_default_csp_header(self):
self.middleware.nonce = "TEST_NONCE"
output = self.middleware(self.request)
self.assertIn("Content-Security-Policy", output)
self.assertEqual(
output["Content-Security-Policy"],
CSPSources.get_csp_header(nonce=self.middleware.nonce),
)
@override_settings(STATIC_URL="/static/")
def test_header_is_not_set_for_assets(self):
self.request.path = "/static/test.js"
output = self.middleware(self.request)
self.assertNotIn("Content-Security-Policy", output)
def test_csp_nonce_is_replaced(self):
self.middleware = ContentSecurityPolicyMiddleware(get_html_response_with_nonce)
self.middleware.nonce = "TEST_NONCE"
output = self.middleware(self.request)
self.assertIn(b'<script nonce="TEST_NONCE">', output.content)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment