Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Restructure CSP Configuration with Streamlined Settings (backwards incompatible) #219

Merged
merged 1 commit into from
Jun 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

So good

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