Skip to content

Commit

Permalink
Move to dictionary based settings (backwards-incompatible)
Browse files Browse the repository at this point in the history
  • Loading branch information
robhudson committed May 1, 2024
1 parent eabd326 commit 77ab5f0
Show file tree
Hide file tree
Showing 18 changed files with 740 additions and 392 deletions.
4 changes: 4 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
[run]
source = csp
omit =
csp/tests/*
8 changes: 5 additions & 3 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,12 @@
CHANGES
=======

Unreleased
==========
4.x - Unreleased
================

- Add pyproject-fmt to pre-commit, and update pre-commit versions.
- Add pyproject-fmt to pre-commit, and update pre-commit versions
- [backwards-incompatible] Move to 2 dict-based setting configuration
- Fixes #36: Add support for enforced and report-only policies simultaneously

3.8
===
Expand Down
2 changes: 2 additions & 0 deletions csp/constants.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
HEADER = "Content-Security-Policy"
HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"
27 changes: 25 additions & 2 deletions csp/contrib/rate_limiting.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,32 @@ def build_policy(self, request, response):
replace = getattr(response, "_csp_replace", {})
nonce = getattr(request, "_csp_nonce", None)

report_percentage = getattr(settings, "CSP_REPORT_PERCENTAGE")
include_report_uri = random.random() < report_percentage
policy = getattr(settings, "CONTENT_SECURITY_POLICY", {})

if policy is None:
return ""

report_percentage = policy.get("REPORT_PERCENTAGE", 100)
include_report_uri = random.randint(0, 100) < report_percentage
if not include_report_uri:
replace["report-uri"] = None

return build_policy(config=config, update=update, replace=replace, nonce=nonce)

def build_policy_ro(self, request, response):
config = getattr(response, "_csp_config_ro", None)
update = getattr(response, "_csp_update_ro", None)
replace = getattr(response, "_csp_replace_ro", {})
nonce = getattr(request, "_csp_nonce", None)

policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {})

if policy is None:
return ""

report_percentage = policy.get("REPORT_PERCENTAGE", 100)
include_report_uri = random.randint(0, 100) < report_percentage
if not include_report_uri:
replace["report-uri"] = None

return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True)
45 changes: 28 additions & 17 deletions csp/decorators.py
Original file line number Diff line number Diff line change
@@ -1,54 +1,65 @@
from functools import wraps


def csp_exempt(f):
@wraps(f)
def _wrapped(*a, **kw):
r = f(*a, **kw)
r._csp_exempt = True
return r
def csp_exempt(REPORT_ONLY=None):
def decorator(f):
@wraps(f)
def _wrapped(*a, **kw):
r = f(*a, **kw)
if REPORT_ONLY:
r._csp_exempt_ro = True
else:
r._csp_exempt = True
return r

return _wrapped
return _wrapped

return decorator

def csp_update(**kwargs):
update = {k.lower().replace("_", "-"): v for k, v in kwargs.items()}

def csp_update(config, *, REPORT_ONLY=False):
def decorator(f):
@wraps(f)
def _wrapped(*a, **kw):
r = f(*a, **kw)
r._csp_update = update
if REPORT_ONLY:
r._csp_update_ro = config
else:
r._csp_update = config
return r

return _wrapped

return decorator


def csp_replace(**kwargs):
replace = {k.lower().replace("_", "-"): v for k, v in kwargs.items()}

def csp_replace(config, *, REPORT_ONLY=False):
def decorator(f):
@wraps(f)
def _wrapped(*a, **kw):
r = f(*a, **kw)
r._csp_replace = replace
if REPORT_ONLY:
r._csp_replace_ro = config
else:
r._csp_replace = config
return r

return _wrapped

return decorator


def csp(**kwargs):
config = {k.lower().replace("_", "-"): [v] if isinstance(v, str) else v for k, v in kwargs.items()}
def csp(config, *, REPORT_ONLY=False):
config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}

def decorator(f):
@wraps(f)
def _wrapped(*a, **kw):
r = f(*a, **kw)
r._csp_config = config
if REPORT_ONLY:
r._csp_config_ro = config
else:
r._csp_config = config
return r

return _wrapped
Expand Down
50 changes: 29 additions & 21 deletions csp/middleware.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
from django.utils.deprecation import MiddlewareMixin
from django.utils.functional import SimpleLazyObject

from csp.constants import HEADER, HEADER_REPORT_ONLY
from csp.utils import build_policy


Expand All @@ -21,8 +22,7 @@ class CSPMiddleware(MiddlewareMixin):
"""

def _make_nonce(self, request):
# Ensure that any subsequent calls to request.csp_nonce return the
# same value
# Ensure that any subsequent calls to request.csp_nonce return the same value
if not getattr(request, "_csp_nonce", None):
request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii")
return request._csp_nonce
Expand All @@ -32,32 +32,33 @@ def process_request(self, request):
request.csp_nonce = SimpleLazyObject(nonce)

def process_response(self, request, response):
if getattr(response, "_csp_exempt", False):
return response

# Check for ignored path prefix.
prefixes = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", ())
if request.path_info.startswith(prefixes):
return response

# Check for debug view
status_code = response.status_code
exempted_debug_codes = (
http_client.INTERNAL_SERVER_ERROR,
http_client.NOT_FOUND,
)
if status_code in exempted_debug_codes and settings.DEBUG:
if response.status_code in exempted_debug_codes and settings.DEBUG:
return response

header = "Content-Security-Policy"
if getattr(settings, "CSP_REPORT_ONLY", False):
header += "-Report-Only"

if header in response:
# Don't overwrite existing headers.
return response

response[header] = self.build_policy(request, response)
csp = self.build_policy(request, response)
if csp:
# Only set header if not already set and not an excluded prefix and not exempted.
is_not_exempt = getattr(response, "_csp_exempt", False) is False
no_header = HEADER not in response
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ())
is_not_excluded = not request.path_info.startswith(prefixes)
if all((no_header, is_not_exempt, is_not_excluded)):
response[HEADER] = csp

csp_ro = self.build_policy_ro(request, response)
if csp_ro:
# Only set header if not already set and not an excluded prefix and not exempted.
is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False
no_header = HEADER_REPORT_ONLY not in response
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ())
is_not_excluded = not request.path_info.startswith(prefixes)
if all((no_header, is_not_exempt, is_not_excluded)):
response[HEADER_REPORT_ONLY] = csp_ro

return response

Expand All @@ -67,3 +68,10 @@ def build_policy(self, request, response):
replace = getattr(response, "_csp_replace", None)
nonce = getattr(request, "_csp_nonce", None)
return build_policy(config=config, update=update, replace=replace, nonce=nonce)

def build_policy_ro(self, request, response):
config = getattr(response, "_csp_config_ro", None)
update = getattr(response, "_csp_update_ro", None)
replace = getattr(response, "_csp_replace_ro", None)
nonce = getattr(request, "_csp_nonce", None)
return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True)
15 changes: 5 additions & 10 deletions csp/tests/settings.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import django

CSP_REPORT_ONLY = False

CSP_INCLUDE_NONCE_IN = ["default-src"]
CONTENT_SECURITY_POLICY = {
"DIRECTIVES": {
"include-nonce-in": ["default-src"],
}
}

DATABASES = {
"default": {
Expand Down Expand Up @@ -39,8 +39,3 @@
"OPTIONS": {},
},
]


# Django >1.6 requires `setup` call to initialise apps framework
if hasattr(django, "setup"):
django.setup()
20 changes: 18 additions & 2 deletions csp/tests/test_contrib.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,15 +2,15 @@
from django.test import RequestFactory
from django.test.utils import override_settings

from csp.constants import HEADER, HEADER_REPORT_ONLY
from csp.contrib.rate_limiting import RateLimitedCSPMiddleware
from csp.tests.utils import response

HEADER = "Content-Security-Policy"
mw = RateLimitedCSPMiddleware(response())
rf = RequestFactory()


@override_settings(CSP_REPORT_PERCENTAGE=0.1, CSP_REPORT_URI="x")
@override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}})
def test_report_percentage():
times_seen = 0
for _ in range(5000):
Expand All @@ -21,3 +21,19 @@ def test_report_percentage():
times_seen += 1
# Roughly 10%
assert 400 <= times_seen <= 600


@override_settings(CONTENT_SECURITY_POLICY=None)
def test_no_csp():
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
assert HEADER not in response


@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=None)
def test_no_csp_ro():
request = rf.get("/")
response = HttpResponse()
mw.process_response(request, response)
assert HEADER_REPORT_ONLY not in response
Loading

0 comments on commit 77ab5f0

Please sign in to comment.