Skip to content

Commit bbfc8bb

Browse files
author
Rob Hudson
committed
Fixes #36: Move to dictionary based settings
This is a backwards incompatible change. Also fixes #139, #191
1 parent eabd326 commit bbfc8bb

26 files changed

+1228
-460
lines changed

.coveragerc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
[run]
2+
source = csp
3+
omit =
4+
csp/tests/*
5+
6+
[report]
7+
show_missing = True

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,3 +7,4 @@
77
.tox
88
dist
99
build
10+
docs/_build

CHANGES

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,17 @@
22
CHANGES
33
=======
44

5-
Unreleased
6-
==========
5+
4.x - Unreleased
6+
================
77

8-
- Add pyproject-fmt to pre-commit, and update pre-commit versions.
8+
BACKWARDS INCOMPATIBLE changes:
9+
- Move to dict-based configuration which allows for setting policies for both enforced and
10+
report-only. See the migration guide in the docs for migrating your settings.
11+
12+
Other changes:
13+
- Add pyproject-fmt to pre-commit, and update pre-commit versions
14+
- Fixes #36: Add support for enforced and report-only policies simultaneously
15+
- Drop support for Django <=3.2, end of extended support
916

1017
3.8
1118
===

csp/apps.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
from django.apps import AppConfig
2+
from django.core import checks
3+
4+
from csp.checks import check_django_csp_lt_4_0
5+
6+
7+
class CspConfig(AppConfig):
8+
name = "csp"
9+
10+
def ready(self):
11+
checks.register(check_django_csp_lt_4_0, checks.Tags.security)

csp/checks.py

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import pprint
2+
3+
from django.conf import settings
4+
from django.core.checks import Error
5+
6+
7+
OUTDATED_SETTINGS = [
8+
"CSP_CHILD_SRC",
9+
"CSP_CONNECT_SRC",
10+
"CSP_DEFAULT_SRC",
11+
"CSP_SCRIPT_SRC",
12+
"CSP_SCRIPT_SRC_ATTR",
13+
"CSP_SCRIPT_SRC_ELEM",
14+
"CSP_OBJECT_SRC",
15+
"CSP_STYLE_SRC",
16+
"CSP_STYLE_SRC_ATTR",
17+
"CSP_STYLE_SRC_ELEM",
18+
"CSP_FONT_SRC",
19+
"CSP_FRAME_SRC",
20+
"CSP_IMG_SRC",
21+
"CSP_MANIFEST_SRC",
22+
"CSP_MEDIA_SRC",
23+
"CSP_PREFETCH_SRC",
24+
"CSP_WORKER_SRC",
25+
"CSP_BASE_URI",
26+
"CSP_PLUGIN_TYPES",
27+
"CSP_SANDBOX",
28+
"CSP_FORM_ACTION",
29+
"CSP_FRAME_ANCESTORS",
30+
"CSP_NAVIGATE_TO",
31+
"CSP_REQUIRE_SRI_FOR",
32+
"CSP_REQUIRE_TRUSTED_TYPES_FOR",
33+
"CSP_TRUSTED_TYPES",
34+
"CSP_UPGRADE_INSECURE_REQUESTS",
35+
"CSP_BLOCK_ALL_MIXED_CONTENT",
36+
"CSP_REPORT_URI",
37+
"CSP_REPORT_TO",
38+
"CSP_INCLUDE_NONCE_IN",
39+
]
40+
41+
42+
def migrate_settings():
43+
# This function is used to migrate settings from the old format to the new format.
44+
config = {
45+
"DIRECTIVES": {},
46+
}
47+
REPORT_ONLY = False
48+
49+
if hasattr(settings, "CSP_REPORT_ONLY"):
50+
REPORT_ONLY = settings.CSP_REPORT_ONLY
51+
52+
if hasattr(settings, "CSP_EXCLUDE_URL_PREFIXES"):
53+
config["EXCLUDE_URL_PREFIXES"] = settings.CSP_EXCLUDE_URL_PREFIXES
54+
55+
if hasattr(settings, "CSP_REPORT_PERCENTAGE"):
56+
config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100)
57+
58+
for setting in OUTDATED_SETTINGS:
59+
if hasattr(settings, setting):
60+
directive = setting[4:].replace("_", "-").lower()
61+
value = getattr(settings, setting)
62+
if value:
63+
config["DIRECTIVES"][directive] = value
64+
65+
return config, REPORT_ONLY
66+
67+
68+
def check_django_csp_lt_4_0(app_configs, **kwargs):
69+
check_settings = OUTDATED_SETTINGS + ["CSP_REPORT_ONLY", "CSP_EXCLUDE_URL_PREFIXES", "CSP_REPORT_PERCENTAGE"]
70+
if any(hasattr(settings, setting) for setting in check_settings):
71+
# Try to build the new config.
72+
config, REPORT_ONLY = migrate_settings()
73+
warning = (
74+
"You are using django-csp < 4.0 settings. Please update your settings to use the new format.\n"
75+
"See https://django-csp.readthedocs.io/en/latest/migration-guide.html for more information.\n\n"
76+
"We have attempted to build the new CSP config for you based on your current settings:\n\n"
77+
f"CONTENT_SECURITY_POLICY{'_REPORT_ONLY' if REPORT_ONLY else ''} = " + pprint.pformat(config, sort_dicts=True)
78+
)
79+
return [Error(warning, id="csp.E001")]
80+
81+
return []

csp/constants.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
HEADER = "Content-Security-Policy"
2+
HEADER_REPORT_ONLY = "Content-Security-Policy-Report-Only"

csp/contrib/rate_limiting.py

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,9 +16,32 @@ def build_policy(self, request, response):
1616
replace = getattr(response, "_csp_replace", {})
1717
nonce = getattr(request, "_csp_nonce", None)
1818

19-
report_percentage = getattr(settings, "CSP_REPORT_PERCENTAGE")
20-
include_report_uri = random.random() < report_percentage
19+
policy = getattr(settings, "CONTENT_SECURITY_POLICY", None)
20+
21+
if policy is None:
22+
return ""
23+
24+
report_percentage = policy.get("REPORT_PERCENTAGE", 100)
25+
include_report_uri = random.randint(0, 100) < report_percentage
2126
if not include_report_uri:
2227
replace["report-uri"] = None
2328

2429
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
30+
31+
def build_policy_ro(self, request, response):
32+
config = getattr(response, "_csp_config_ro", None)
33+
update = getattr(response, "_csp_update_ro", None)
34+
replace = getattr(response, "_csp_replace_ro", {})
35+
nonce = getattr(request, "_csp_nonce", None)
36+
37+
policy = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", None)
38+
39+
if policy is None:
40+
return ""
41+
42+
report_percentage = policy.get("REPORT_PERCENTAGE", 100)
43+
include_report_uri = random.randint(0, 100) < report_percentage
44+
if not include_report_uri:
45+
replace["report-uri"] = None
46+
47+
return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True)

csp/decorators.py

Lines changed: 57 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,55 +1,90 @@
11
from functools import wraps
22

33

4-
def csp_exempt(f):
5-
@wraps(f)
6-
def _wrapped(*a, **kw):
7-
r = f(*a, **kw)
8-
r._csp_exempt = True
9-
return r
4+
def csp_exempt(REPORT_ONLY=None):
5+
if callable(REPORT_ONLY):
6+
raise RuntimeError(
7+
"Incompatible `csp_exempt` decorator usage. This decorator now requires arguments, "
8+
"even if none are passed. Change bare decorator usage (@csp_exempt) to parameterized "
9+
"decorator usage (@csp_exempt()). See the django-csp 4.0 migration guide for more "
10+
"information."
11+
)
1012

11-
return _wrapped
13+
def decorator(f):
14+
@wraps(f)
15+
def _wrapped(*a, **kw):
16+
resp = f(*a, **kw)
17+
if REPORT_ONLY:
18+
resp._csp_exempt_ro = True
19+
else:
20+
resp._csp_exempt = True
21+
return resp
22+
23+
return _wrapped
1224

25+
return decorator
26+
27+
28+
# Error message for deprecated decorator arguments.
29+
DECORATOR_DEPRECATION_ERROR = (
30+
"Incompatible `{fname}` decorator arguments. This decorator now takes a single dict argument. "
31+
"See the django-csp 4.0 migration guide for more information."
32+
)
1333

14-
def csp_update(**kwargs):
15-
update = {k.lower().replace("_", "-"): v for k, v in kwargs.items()}
34+
35+
def csp_update(config=None, REPORT_ONLY=False, **kwargs):
36+
if config is None and kwargs:
37+
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp_update"))
1638

1739
def decorator(f):
1840
@wraps(f)
1941
def _wrapped(*a, **kw):
20-
r = f(*a, **kw)
21-
r._csp_update = update
22-
return r
42+
resp = f(*a, **kw)
43+
if REPORT_ONLY:
44+
resp._csp_update_ro = config
45+
else:
46+
resp._csp_update = config
47+
return resp
2348

2449
return _wrapped
2550

2651
return decorator
2752

2853

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

3258
def decorator(f):
3359
@wraps(f)
3460
def _wrapped(*a, **kw):
35-
r = f(*a, **kw)
36-
r._csp_replace = replace
37-
return r
61+
resp = f(*a, **kw)
62+
if REPORT_ONLY:
63+
resp._csp_replace_ro = config
64+
else:
65+
resp._csp_replace = config
66+
return resp
3867

3968
return _wrapped
4069

4170
return decorator
4271

4372

44-
def csp(**kwargs):
45-
config = {k.lower().replace("_", "-"): [v] if isinstance(v, str) else v for k, v in kwargs.items()}
73+
def csp(config=None, REPORT_ONLY=False, **kwargs):
74+
if config is None and kwargs:
75+
raise RuntimeError(DECORATOR_DEPRECATION_ERROR.format(fname="csp"))
76+
77+
config = {k: [v] if isinstance(v, str) else v for k, v in config.items()}
4678

4779
def decorator(f):
4880
@wraps(f)
4981
def _wrapped(*a, **kw):
50-
r = f(*a, **kw)
51-
r._csp_config = config
52-
return r
82+
resp = f(*a, **kw)
83+
if REPORT_ONLY:
84+
resp._csp_config_ro = config
85+
else:
86+
resp._csp_config = config
87+
return resp
5388

5489
return _wrapped
5590

csp/middleware.py

Lines changed: 29 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
from django.utils.deprecation import MiddlewareMixin
88
from django.utils.functional import SimpleLazyObject
99

10+
from csp.constants import HEADER, HEADER_REPORT_ONLY
1011
from csp.utils import build_policy
1112

1213

@@ -21,8 +22,7 @@ class CSPMiddleware(MiddlewareMixin):
2122
"""
2223

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

3434
def process_response(self, request, response):
35-
if getattr(response, "_csp_exempt", False):
36-
return response
37-
38-
# Check for ignored path prefix.
39-
prefixes = getattr(settings, "CSP_EXCLUDE_URL_PREFIXES", ())
40-
if request.path_info.startswith(prefixes):
41-
return response
42-
4335
# Check for debug view
44-
status_code = response.status_code
4536
exempted_debug_codes = (
4637
http_client.INTERNAL_SERVER_ERROR,
4738
http_client.NOT_FOUND,
4839
)
49-
if status_code in exempted_debug_codes and settings.DEBUG:
40+
if response.status_code in exempted_debug_codes and settings.DEBUG:
5041
return response
5142

52-
header = "Content-Security-Policy"
53-
if getattr(settings, "CSP_REPORT_ONLY", False):
54-
header += "-Report-Only"
55-
56-
if header in response:
57-
# Don't overwrite existing headers.
58-
return response
59-
60-
response[header] = self.build_policy(request, response)
43+
csp = self.build_policy(request, response)
44+
if csp:
45+
# Only set header if not already set and not an excluded prefix and not exempted.
46+
is_not_exempt = getattr(response, "_csp_exempt", False) is False
47+
no_header = HEADER not in response
48+
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ())
49+
is_not_excluded = not request.path_info.startswith(prefixes)
50+
if all((no_header, is_not_exempt, is_not_excluded)):
51+
response[HEADER] = csp
52+
53+
csp_ro = self.build_policy_ro(request, response)
54+
if csp_ro:
55+
# Only set header if not already set and not an excluded prefix and not exempted.
56+
is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False
57+
no_header = HEADER_REPORT_ONLY not in response
58+
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ())
59+
is_not_excluded = not request.path_info.startswith(prefixes)
60+
if all((no_header, is_not_exempt, is_not_excluded)):
61+
response[HEADER_REPORT_ONLY] = csp_ro
6162

6263
return response
6364

@@ -67,3 +68,10 @@ def build_policy(self, request, response):
6768
replace = getattr(response, "_csp_replace", None)
6869
nonce = getattr(request, "_csp_nonce", None)
6970
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
71+
72+
def build_policy_ro(self, request, response):
73+
config = getattr(response, "_csp_config_ro", None)
74+
update = getattr(response, "_csp_update_ro", None)
75+
replace = getattr(response, "_csp_replace_ro", None)
76+
nonce = getattr(request, "_csp_nonce", None)
77+
return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True)

csp/tests/settings.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
1-
import django
2-
3-
CSP_REPORT_ONLY = False
4-
5-
CSP_INCLUDE_NONCE_IN = ["default-src"]
1+
CONTENT_SECURITY_POLICY = {
2+
"DIRECTIVES": {
3+
"include-nonce-in": ["default-src"],
4+
}
5+
}
66

77
DATABASES = {
88
"default": {
@@ -39,8 +39,3 @@
3939
"OPTIONS": {},
4040
},
4141
]
42-
43-
44-
# Django >1.6 requires `setup` call to initialise apps framework
45-
if hasattr(django, "setup"):
46-
django.setup()

0 commit comments

Comments
 (0)