Skip to content

Commit 9b2cee0

Browse files
author
Rob Hudson
committed
Move to NONCE sentinel instead of 'include-nonce-in'
1 parent 193c0f5 commit 9b2cee0

13 files changed

+134
-64
lines changed

CHANGES

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,14 @@ CHANGES
88
BACKWARDS INCOMPATIBLE changes:
99
- Move to dict-based configuration which allows for setting policies for both enforced and
1010
report-only. See the migration guide in the docs for migrating your settings.
11+
- Switch from specifying which directives should contain the nonce as a separate list, and instead
12+
use a sentinel `NONCE` in the directive itself.
1113

1214
Other changes:
1315
- Add pyproject-fmt to pre-commit, and update pre-commit versions
1416
- Fixes #36: Add support for enforced and report-only policies simultaneously
1517
- Drop support for Django <=3.2, end of extended support
18+
- Add CSP keyword constants in `csp.constants`, e.g. to replace `"'self'"` with `SELF`
1619

1720
3.8
1821
===

csp/checks.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
from django.conf import settings
44
from django.core.checks import Error
55

6+
from csp.constants import NONCE
7+
68

79
OUTDATED_SETTINGS = [
810
"CSP_CHILD_SRC",
@@ -35,7 +37,6 @@
3537
"CSP_BLOCK_ALL_MIXED_CONTENT",
3638
"CSP_REPORT_URI",
3739
"CSP_REPORT_TO",
38-
"CSP_INCLUDE_NONCE_IN",
3940
]
4041

4142

@@ -55,12 +56,16 @@ def migrate_settings():
5556
if hasattr(settings, "CSP_REPORT_PERCENTAGE"):
5657
config["REPORT_PERCENTAGE"] = round(settings.CSP_REPORT_PERCENTAGE * 100)
5758

59+
include_nonce_in = getattr(settings, "CSP_INCLUDE_NONCE_IN", [])
60+
5861
for setting in OUTDATED_SETTINGS:
5962
if hasattr(settings, setting):
6063
directive = setting[4:].replace("_", "-").lower()
6164
value = getattr(settings, setting)
6265
if value:
6366
config["DIRECTIVES"][directive] = value
67+
if directive in include_nonce_in:
68+
config["DIRECTIVES"][directive].append(NONCE)
6469

6570
return config, REPORT_ONLY
6671

csp/constants.py

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,3 +10,18 @@
1010
UNSAFE_HASHES = "'unsafe-hashes'"
1111
UNSAFE_INLINE = "'unsafe-inline'"
1212
WASM_UNSAFE_EVAL = "'wasm-unsafe-eval'"
13+
14+
15+
class Nonce:
16+
_instance = None
17+
18+
def __new__(cls, *args, **kwargs):
19+
if cls._instance is None:
20+
cls._instance = super().__new__(cls)
21+
return cls._instance
22+
23+
def __repr__(self):
24+
return "csp.constants.NONCE"
25+
26+
27+
NONCE = Nonce()

csp/tests/settings.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1+
from csp.constants import NONCE, SELF
2+
3+
14
CONTENT_SECURITY_POLICY = {
25
"DIRECTIVES": {
3-
"include-nonce-in": ["default-src"],
6+
"default-src": [SELF, NONCE],
47
}
58
}
69

csp/tests/test_checks.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
from django.test.utils import override_settings
22

33
from csp.checks import check_django_csp_lt_4_0, migrate_settings
4+
from csp.constants import NONCE
45

56

67
@override_settings(
@@ -30,8 +31,7 @@ def test_migrate_settings_report_only():
3031
assert config == {
3132
"DIRECTIVES": {
3233
"default-src": ["'self'", "example.com"],
33-
"script-src": ["'self'", "example.com", "'unsafe-inline'"],
34-
"include-nonce-in": ["script-src"],
34+
"script-src": ["'self'", "example.com", "'unsafe-inline'", NONCE],
3535
}
3636
}
3737
assert report_only is True

csp/tests/test_constants.py

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
from csp import constants
2+
3+
4+
def test_nonce():
5+
assert constants.Nonce() == constants.Nonce()
6+
assert constants.NONCE == constants.Nonce()
7+
assert repr(constants.Nonce()) == "csp.constants.NONCE"

csp/tests/test_decorators.py

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
from django.test import RequestFactory
44
from django.test.utils import override_settings
55

6-
from csp.constants import HEADER, HEADER_REPORT_ONLY
6+
from csp.constants import HEADER, HEADER_REPORT_ONLY, NONCE
77
from csp.decorators import csp, csp_exempt, csp_replace, csp_update
88
from csp.middleware import CSPMiddleware
99
from csp.tests.utils import response
@@ -44,12 +44,12 @@ def view_without_decorator(request):
4444
policy_list = sorted(response[HEADER].split("; "))
4545
assert policy_list == ["default-src 'self'", "img-src foo.com"]
4646

47-
@csp_update({"img-src": ["bar.com"], "include-nonce-in": ["img-src"]})
47+
@csp_update({"img-src": ["bar.com", NONCE]})
4848
def view_with_decorator(request):
4949
return HttpResponse()
5050

5151
response = view_with_decorator(request)
52-
assert response._csp_update == {"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}
52+
assert response._csp_update == {"img-src": ["bar.com", NONCE]}
5353
mw.process_request(request)
5454
assert request.csp_nonce # Here to trigger the nonce creation.
5555
mw.process_response(request, response)
@@ -77,12 +77,12 @@ def view_without_decorator(request):
7777
policy_list = sorted(response[HEADER_REPORT_ONLY].split("; "))
7878
assert policy_list == ["default-src 'self'", "img-src foo.com"]
7979

80-
@csp_update({"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}, REPORT_ONLY=True)
80+
@csp_update({"img-src": ["bar.com", NONCE]}, REPORT_ONLY=True)
8181
def view_with_decorator(request):
8282
return HttpResponse()
8383

8484
response = view_with_decorator(request)
85-
assert response._csp_update_ro == {"img-src": ["bar.com"], "include-nonce-in": ["img-src"]}
85+
assert response._csp_update_ro == {"img-src": ["bar.com", NONCE]}
8686
mw.process_request(request)
8787
assert request.csp_nonce # Here to trigger the nonce creation.
8888
mw.process_response(request, response)

csp/tests/test_middleware.py

Lines changed: 0 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -143,13 +143,3 @@ def test_nonce_regenerated_on_new_request():
143143
mw.process_response(request2, response2)
144144
assert nonce1 not in response2[HEADER]
145145
assert nonce2 not in response1[HEADER]
146-
147-
148-
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": []}})
149-
def test_no_nonce_when_disabled_by_settings():
150-
request = rf.get("/")
151-
mw.process_request(request)
152-
nonce = str(request.csp_nonce)
153-
response = HttpResponse()
154-
mw.process_response(request, response)
155-
assert nonce not in response[HEADER]

csp/tests/test_utils.py

Lines changed: 13 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
from django.test.utils import override_settings
22
from django.utils.functional import lazy
33

4-
from csp.constants import NONE, SELF
4+
from csp.constants import NONCE, NONE, SELF
55
from csp.utils import build_policy, default_config, DEFAULT_DIRECTIVES
66

77

@@ -44,6 +44,12 @@ def test_empty_policy():
4444
policy_eq("default-src 'self'", policy)
4545

4646

47+
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": None}})
48+
def test_default_src_none():
49+
policy = build_policy()
50+
policy_eq("", policy)
51+
52+
4753
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": ["example.com", "example2.com"]}})
4854
def test_default_src():
4955
policy = build_policy()
@@ -292,18 +298,19 @@ def test_nonce():
292298
policy_eq("default-src 'self' 'nonce-abc123'", policy)
293299

294300

295-
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"include-nonce-in": ["script-src", "style-src"]}})
296-
def test_nonce_include_in():
301+
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [SELF], "script-src": [SELF, NONCE], "style-src": [SELF, NONCE]}})
302+
def test_nonce_in_value():
297303
policy = build_policy(nonce="abc123")
298304
policy_eq(
299-
"default-src 'self'; script-src 'nonce-abc123'; style-src 'nonce-abc123'",
305+
"default-src 'self'; script-src 'self' 'nonce-abc123'; style-src 'self' 'nonce-abc123'",
300306
policy,
301307
)
302308

303309

304-
def test_nonce_include_in_absent():
310+
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": [NONCE]}})
311+
def test_only_nonce_in_value():
305312
policy = build_policy(nonce="abc123")
306-
policy_eq("default-src 'self' 'nonce-abc123'", policy)
313+
policy_eq("default-src 'nonce-abc123'", policy)
307314

308315

309316
def test_boolean_directives():

csp/utils.py

Lines changed: 14 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from django.conf import settings
77
from django.utils.encoding import force_str
88

9-
from csp.constants import SELF
9+
from csp.constants import NONCE, SELF
1010

1111
DEFAULT_DIRECTIVES = {
1212
# Fetch Directives
@@ -47,8 +47,6 @@
4747
# Directives Defined in Other Documents
4848
"upgrade-insecure-requests": None,
4949
"block-all-mixed-content": None, # Deprecated.
50-
# Pseudo-directive that affects other directives.
51-
"include-nonce-in": None,
5250
}
5351

5452

@@ -103,28 +101,28 @@ def build_policy(config=None, update=None, replace=None, nonce=None, report_only
103101
csp[k] += tuple(v)
104102

105103
report_uri = csp.pop("report-uri", None)
106-
include_nonce_in = csp.pop("include-nonce-in", [])
107104

108105
policy_parts = {}
109106

110107
for key, value in csp.items():
111-
# flag directives with an empty directive value
112-
if len(value) and value[0] is True:
113-
policy_parts[key] = ""
114-
elif len(value) and value[0] is False:
115-
pass
116-
else: # directives with many values like src lists
117-
policy_parts[key] = " ".join(value)
108+
# Check for boolean directives.
109+
if len(value) == 1 and isinstance(value[0], bool):
110+
if value[0] is True:
111+
policy_parts[key] = ""
112+
continue
113+
if NONCE in value:
114+
if nonce:
115+
value = [f"'nonce-{nonce}'" if v == NONCE else v for v in value]
116+
else:
117+
# Strip the `NONCE` sentinel value if no nonce is provided.
118+
value = [v for v in value if v != NONCE]
119+
120+
policy_parts[key] = " ".join(value)
118121

119122
if report_uri:
120123
report_uri = map(force_str, report_uri)
121124
policy_parts["report-uri"] = " ".join(report_uri)
122125

123-
if nonce:
124-
for section in include_nonce_in:
125-
policy = policy_parts.get(section, "")
126-
policy_parts[section] = f"{policy} 'nonce-{nonce}'".strip()
127-
128126
return "; ".join([f"{k} {val}".strip() for k, val in policy_parts.items()])
129127

130128

0 commit comments

Comments
 (0)