Skip to content

Commit d88f009

Browse files
author
Rob Hudson
committed
Move to dictionary based settings (backwards-incompatible)
1 parent eabd326 commit d88f009

16 files changed

+722
-375
lines changed

.coveragerc

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

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", {})
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", {})
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: 28 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,54 +1,65 @@
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+
def decorator(f):
6+
@wraps(f)
7+
def _wrapped(*a, **kw):
8+
r = f(*a, **kw)
9+
if REPORT_ONLY:
10+
r._csp_exempt_ro = True
11+
else:
12+
r._csp_exempt = True
13+
return r
1014

11-
return _wrapped
15+
return _wrapped
1216

17+
return decorator
1318

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

20+
def csp_update(config, *, REPORT_ONLY=False):
1721
def decorator(f):
1822
@wraps(f)
1923
def _wrapped(*a, **kw):
2024
r = f(*a, **kw)
21-
r._csp_update = update
25+
if REPORT_ONLY:
26+
r._csp_update_ro = config
27+
else:
28+
r._csp_update = config
2229
return r
2330

2431
return _wrapped
2532

2633
return decorator
2734

2835

29-
def csp_replace(**kwargs):
30-
replace = {k.lower().replace("_", "-"): v for k, v in kwargs.items()}
31-
36+
def csp_replace(config, *, REPORT_ONLY=False):
3237
def decorator(f):
3338
@wraps(f)
3439
def _wrapped(*a, **kw):
3540
r = f(*a, **kw)
36-
r._csp_replace = replace
41+
if REPORT_ONLY:
42+
r._csp_replace_ro = config
43+
else:
44+
r._csp_replace = config
3745
return r
3846

3947
return _wrapped
4048

4149
return decorator
4250

4351

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

4755
def decorator(f):
4856
@wraps(f)
4957
def _wrapped(*a, **kw):
5058
r = f(*a, **kw)
51-
r._csp_config = config
59+
if REPORT_ONLY:
60+
r._csp_config_ro = config
61+
else:
62+
r._csp_config = config
5263
return r
5364

5465
return _wrapped

csp/middleware.py

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -21,8 +21,7 @@ class CSPMiddleware(MiddlewareMixin):
2121
"""
2222

2323
def _make_nonce(self, request):
24-
# Ensure that any subsequent calls to request.csp_nonce return the
25-
# same value
24+
# Ensure that any subsequent calls to request.csp_nonce return the same value
2625
if not getattr(request, "_csp_nonce", None):
2726
request._csp_nonce = base64.b64encode(os.urandom(16)).decode("ascii")
2827
return request._csp_nonce
@@ -32,32 +31,36 @@ def process_request(self, request):
3231
request.csp_nonce = SimpleLazyObject(nonce)
3332

3433
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-
4334
# Check for debug view
44-
status_code = response.status_code
4535
exempted_debug_codes = (
4636
http_client.INTERNAL_SERVER_ERROR,
4737
http_client.NOT_FOUND,
4838
)
49-
if status_code in exempted_debug_codes and settings.DEBUG:
39+
if response.status_code in exempted_debug_codes and settings.DEBUG:
5040
return response
5141

5242
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+
header_ro = "Content-Security-Policy-Report-Only"
44+
45+
csp = self.build_policy(request, response)
46+
if csp:
47+
# Only set header if not already set and not an excluded prefix and not exempted.
48+
is_not_exempt = getattr(response, "_csp_exempt", False) is False
49+
no_header = header not in response
50+
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY", {}).get("EXCLUDE_URL_PREFIXES", ())
51+
is_not_excluded = not request.path_info.startswith(prefixes)
52+
if all((no_header, is_not_exempt, is_not_excluded)):
53+
response[header] = csp
54+
55+
csp_ro = self.build_policy_ro(request, response)
56+
if csp_ro:
57+
# Only set header if not already set and not an excluded prefix and not exempted.
58+
is_not_exempt = getattr(response, "_csp_exempt_ro", False) is False
59+
no_header = header_ro not in response
60+
prefixes = getattr(settings, "CONTENT_SECURITY_POLICY_REPORT_ONLY", {}).get("EXCLUDE_URL_PREFIXES", ())
61+
is_not_excluded = not request.path_info.startswith(prefixes)
62+
if all((no_header, is_not_exempt, is_not_excluded)):
63+
response[header_ro] = csp_ro
6164

6265
return response
6366

@@ -67,3 +70,10 @@ def build_policy(self, request, response):
6770
replace = getattr(response, "_csp_replace", None)
6871
nonce = getattr(request, "_csp_nonce", None)
6972
return build_policy(config=config, update=update, replace=replace, nonce=nonce)
73+
74+
def build_policy_ro(self, request, response):
75+
config = getattr(response, "_csp_config_ro", None)
76+
update = getattr(response, "_csp_update_ro", None)
77+
replace = getattr(response, "_csp_replace_ro", None)
78+
nonce = getattr(request, "_csp_nonce", None)
79+
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()

csp/tests/test_contrib.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010
rf = RequestFactory()
1111

1212

13-
@override_settings(CSP_REPORT_PERCENTAGE=0.1, CSP_REPORT_URI="x")
13+
@override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}})
1414
def test_report_percentage():
1515
times_seen = 0
1616
for _ in range(5000):
@@ -21,3 +21,19 @@ def test_report_percentage():
2121
times_seen += 1
2222
# Roughly 10%
2323
assert 400 <= times_seen <= 600
24+
25+
26+
@override_settings(CONTENT_SECURITY_POLICY=None)
27+
def test_no_csp():
28+
request = rf.get("/")
29+
response = HttpResponse()
30+
mw.process_response(request, response)
31+
assert HEADER not in response
32+
33+
34+
@override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY=None)
35+
def test_no_csp_ro():
36+
request = rf.get("/")
37+
response = HttpResponse()
38+
mw.process_response(request, response)
39+
assert f"{HEADER}-Report-Only" not in response

0 commit comments

Comments
 (0)