diff --git a/csp/tests/test_middleware.py b/csp/tests/test_middleware.py
index e2e7e54..d3011cf 100644
--- a/csp/tests/test_middleware.py
+++ b/csp/tests/test_middleware.py
@@ -33,6 +33,18 @@ def test_both_headers() -> None:
assert HEADER_REPORT_ONLY in response
+@override_settings(
+ CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": {"example.com"}}},
+ CONTENT_SECURITY_POLICY_REPORT_ONLY={"DIRECTIVES": {"default-src": {SELF}}},
+)
+def test_directives_configured_as_sets() -> None:
+ request = rf.get("/")
+ response = HttpResponse()
+ mw.process_response(request, response)
+ assert HEADER in response
+ assert HEADER_REPORT_ONLY in response
+
+
def test_exempt() -> None:
request = rf.get("/")
response = HttpResponse()
diff --git a/csp/tests/test_templatetags.py b/csp/tests/test_templatetags.py
index 308175d..4aa79e8 100644
--- a/csp/tests/test_templatetags.py
+++ b/csp/tests/test_templatetags.py
@@ -39,7 +39,7 @@ def test_async_attribute_with_falsey(self) -> None:
{% script src="foo.com/bar.js" async=False %}
{% endscript %}"""
- expected = '"
+ expected = ''
self.assert_template_eq(*self.process_templates(tpl, expected))
diff --git a/csp/tests/test_utils.py b/csp/tests/test_utils.py
index 74d5c4f..03c6888 100644
--- a/csp/tests/test_utils.py
+++ b/csp/tests/test_utils.py
@@ -56,6 +56,12 @@ def test_default_src() -> None:
policy_eq("default-src example.com example2.com", policy)
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"default-src": {"example.com", "example2.com"}}})
+def test_default_src_is_set() -> None:
+ policy = build_policy()
+ policy_eq("default-src example.com example2.com", policy)
+
+
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"script-src": ["example.com"]}})
def test_script_src() -> None:
policy = build_policy()
@@ -212,6 +218,25 @@ def test_replace_string() -> None:
policy_eq("default-src 'self'; img-src example2.com", policy)
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}})
+def test_update_set() -> None:
+ """
+ GitHub issue #40 - given project settings as a tuple, and
+ an update/replace with a string, concatenate correctly.
+ """
+ policy = build_policy(update={"img-src": {"example2.com"}})
+ policy_eq("default-src 'self'; img-src example.com example2.com", policy)
+
+
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}})
+def test_replace_set() -> None:
+ """
+ Demonstrate that GitHub issue #40 doesn't affect replacements
+ """
+ policy = build_policy(replace={"img-src": {"example2.com"}})
+ policy_eq("default-src 'self'; img-src example2.com", policy)
+
+
@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"form-action": ["example.com"]}})
def test_form_action() -> None:
policy = build_policy()
@@ -313,6 +338,35 @@ def test_only_nonce_in_value() -> None:
policy_eq("default-src 'nonce-abc123'", policy)
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com", "example.com"]}})
+def test_deduplicate_values() -> None:
+ """
+ GitHub issue #40 - given project settings as a tuple, and
+ an update/replace with a string, concatenate correctly.
+ """
+ policy = build_policy()
+ policy_eq("default-src 'self'; img-src example.com", policy)
+
+
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ["example.com", "example.com"]}})
+def test_deduplicate_values_update() -> None:
+ """
+ GitHub issue #40 - given project settings as a tuple, and
+ an update/replace with a string, concatenate correctly.
+ """
+ policy = build_policy(update={"img-src": "example.com"})
+ policy_eq("default-src 'self'; img-src example.com", policy)
+
+
+@override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {"img-src": ("example.com",)}})
+def test_deduplicate_values_replace() -> None:
+ """
+ Demonstrate that GitHub issue #40 doesn't affect replacements
+ """
+ policy = build_policy(replace={"img-src": ["example2.com", "example2.com"]})
+ policy_eq("default-src 'self'; img-src example2.com", policy)
+
+
def test_boolean_directives() -> None:
for directive in ["upgrade-insecure-requests", "block-all-mixed-content"]:
with override_settings(CONTENT_SECURITY_POLICY={"DIRECTIVES": {directive: True}}):
diff --git a/csp/utils.py b/csp/utils.py
index 2b4ec19..e42bfbe 100644
--- a/csp/utils.py
+++ b/csp/utils.py
@@ -98,12 +98,17 @@ def build_policy(
v = config[k]
if v is not None:
v = copy.copy(v)
+ if isinstance(v, set):
+ v = sorted(v)
if not isinstance(v, (list, tuple)):
v = (v,)
csp[k] = v
for k, v in update.items():
if v is not None:
+ v = copy.copy(v)
+ if isinstance(v, set):
+ v = sorted(v)
if not isinstance(v, (list, tuple)):
v = (v,)
if csp.get(k) is None:
@@ -128,6 +133,7 @@ def build_policy(
# Strip the `NONCE` sentinel value if no nonce is provided.
value = [v for v in value if v != NONCE]
+ value = list(dict.fromkeys(value)) # Deduplicate
policy_parts[key] = " ".join(value)
if report_uri: