Skip to content

Commit

Permalink
Fixes #36: Move to dictionary based settings
Browse files Browse the repository at this point in the history
This is a backwards incompatible change.

Also fixes #139, #191
  • Loading branch information
robhudson committed Jun 6, 2024
1 parent eabd326 commit bbfc8bb
Show file tree
Hide file tree
Showing 26 changed files with 1,228 additions and 460 deletions.
7 changes: 7 additions & 0 deletions .coveragerc
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
[run]
source = csp
omit =
csp/tests/*

[report]
show_missing = True
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@
.tox
dist
build
docs/_build
13 changes: 10 additions & 3 deletions CHANGES
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,17 @@
CHANGES
=======

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

- Add pyproject-fmt to pre-commit, and update pre-commit versions.
BACKWARDS INCOMPATIBLE changes:
- Move to dict-based configuration which allows for setting policies for both enforced and
report-only. See the migration guide in the docs for migrating your settings.

Other changes:
- Add pyproject-fmt to pre-commit, and update pre-commit versions
- Fixes #36: Add support for enforced and report-only policies simultaneously
- Drop support for Django <=3.2, end of extended support

3.8
===
Expand Down
11 changes: 11 additions & 0 deletions csp/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
from django.apps import AppConfig
from django.core import checks

from csp.checks import check_django_csp_lt_4_0


class CspConfig(AppConfig):
name = "csp"

def ready(self):
checks.register(check_django_csp_lt_4_0, checks.Tags.security)
81 changes: 81 additions & 0 deletions csp/checks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
import pprint

from django.conf import settings
from django.core.checks import Error


OUTDATED_SETTINGS = [
"CSP_CHILD_SRC",
"CSP_CONNECT_SRC",
"CSP_DEFAULT_SRC",
"CSP_SCRIPT_SRC",
"CSP_SCRIPT_SRC_ATTR",
"CSP_SCRIPT_SRC_ELEM",
"CSP_OBJECT_SRC",
"CSP_STYLE_SRC",
"CSP_STYLE_SRC_ATTR",
"CSP_STYLE_SRC_ELEM",
"CSP_FONT_SRC",
"CSP_FRAME_SRC",
"CSP_IMG_SRC",
"CSP_MANIFEST_SRC",
"CSP_MEDIA_SRC",
"CSP_PREFETCH_SRC",
"CSP_WORKER_SRC",
"CSP_BASE_URI",
"CSP_PLUGIN_TYPES",
"CSP_SANDBOX",
"CSP_FORM_ACTION",
"CSP_FRAME_ANCESTORS",
"CSP_NAVIGATE_TO",
"CSP_REQUIRE_SRI_FOR",
"CSP_REQUIRE_TRUSTED_TYPES_FOR",
"CSP_TRUSTED_TYPES",
"CSP_UPGRADE_INSECURE_REQUESTS",
"CSP_BLOCK_ALL_MIXED_CONTENT",
"CSP_REPORT_URI",
"CSP_REPORT_TO",
"CSP_INCLUDE_NONCE_IN",
]


def migrate_settings():
# This function is used to migrate settings from the old format to the new format.
config = {
"DIRECTIVES": {},
}
REPORT_ONLY = False

if hasattr(settings, "CSP_REPORT_ONLY"):
REPORT_ONLY = settings.CSP_REPORT_ONLY

if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"):
config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES

if hasattr(settings, "CSP_REPORT_PERCENTAGE"):
config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100)

for setting in OUTDATED_SETTINGS:
if hasattr(settings, setting):
directive = setting[4:].replace("_", "-").lower()
value = getattr(settings, setting)
if value:
config["DIRECTIVES"][directive] = value

return config, REPORT_ONLY


def check_django_csp_lt_4_0(app_configs, **kwargs):
check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"]
if any(hasattr(settings, setting) for setting in check_settings):
# Try to build the new config.
config, REPORT_ONLY = migrate_settings()
warning = (
"You are using django-csp < 4.0 settings. Please update your settings to use the new format.\n"
"See https://django-csp.readthedocs.io/en/latest/migration-guide.html for more information.\n\n"
"We have attempted to build the new CSP config for you based on your current settings:\n\n"
f"CONTENT_SECURITY_POLICY{'_REPORT_ONLY' if REPORT_ONLY else ''} = " + pprint.pformat(config, sort_dicts=True)
)
return [Error(warning, id="csp.E001")]

return []
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", None)

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", None)

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)
79 changes: 57 additions & 22 deletions csp/decorators.py
Original file line number Diff line number Diff line change
@@ -1,55 +1,90 @@
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):
if callable(REPORT_ONLY):
raise RuntimeError(
"Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, "
"even if none are passed. Change bare decorator usage (@csp_exempt) to parameterized "
"decorator usage (@csp_exempt()). See the django-csp 4.0 migration guide for more "
"information."
)

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

return _wrapped

return decorator


# Error message for deprecated decorator arguments.
DECORATOR_DEPRECATION_ERROR = (
"Incompatible `{fname}` decorator arguments. This decorator now takes a single dict argument. "
"See the django-csp 4.0 migration guide for more information."
)

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

def csp_update(config=None, REPORT_ONLY=False, **kwargs):
if config is None and kwargs:
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update"))

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

return _wrapped

return decorator


def csp_replace(**kwargs):
replace = {k.lower().replace("_", "-"): v for k, v in kwargs.items()}
def csp_replace(config=None, REPORT_ONLY=False, **kwargs):
if config is None and kwargs:
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_replace"))

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

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=None, REPORT_ONLY=False, **kwargs):
if config is None and kwargs:
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp"))

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
return r
resp = f(*a, **kw)
if REPORT_ONLY:
resp._csp_config_ro = config
else:
resp._csp_config = config
return resp

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()
Loading

0 comments on commit bbfc8bb

Please sign in to comment.