Repository: mozilla/django-csp

Issue:
When REPORT_PERCENTAGE is set to 100%, the `report-uri` directive is not always included in the CSP header
When the setting `REPORT_PERCENTAGE` is set to 100%, the `report-uri` should always be included in the Content Security Policy header. However, it has been observed that this is not true.

The current logic allows this issue to occur because when the randomly generated integer equals 100, the condition to include the `report-uri` is not met. Specifically, the check `100 < 100` evaluates to `False`, which results in the `report-uri` being omitted from the CSP header. This effectively means that even with `REPORT_PERCENTAGE` set to 100%, there is a scenario where the `report-uri` is not included, preventing the expected reporting of CSP violations.

```python
        include_report_uri = random.randint(0, 100) < report_percentage
        if not include_report_uri:
            replace["report-uri"] = None
```

Discussion:

Patch:
diff --git a/csp/contrib/rate_limiting.py b/csp/contrib/rate_limiting.py
index 9633a87..c6a54f6 100644
--- a/csp/contrib/rate_limiting.py
+++ b/csp/contrib/rate_limiting.py
@@ -28,9 +28,14 @@ class RateLimitedCSPMiddleware(CSPMiddleware):
             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
+        remove_report = random.randint(0, 99) >= report_percentage
+        if remove_report:
+            replace.update(
+                {
+                    "report-uri": None,
+                    "report-to": None,
+                }
+            )
 
         return build_policy(config=config, update=update, replace=replace, nonce=nonce)
 
@@ -46,8 +51,13 @@ class RateLimitedCSPMiddleware(CSPMiddleware):
             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
+        remove_report = random.randint(0, 99) >= report_percentage
+        if remove_report:
+            replace.update(
+                {
+                    "report-uri": None,
+                    "report-to": None,
+                }
+            )
 
         return build_policy(config=config, update=update, replace=replace, nonce=nonce, report_only=True)


Test patch:
diff --git a/csp/tests/test_contrib.py b/csp/tests/test_contrib.py
index c62e767..c8516e6 100644
--- a/csp/tests/test_contrib.py
+++ b/csp/tests/test_contrib.py
@@ -19,10 +19,26 @@ def test_report_percentage() -> None:
         mw.process_response(request, response)
         if "report-uri" in response[HEADER]:
             times_seen += 1
+        if "report-to" in response[HEADER]:
+            times_seen += 1
     # Roughly 10%
     assert 400 <= times_seen <= 600
 
 
+@override_settings(CONTENT_SECURITY_POLICY={"REPORT_PERCENTAGE": 100, "DIRECTIVES": {"report-uri": "x"}})
+def test_report_percentage_100() -> None:
+    times_seen = 0
+    for _ in range(1000):
+        request = rf.get("/")
+        response = HttpResponse()
+        mw.process_response(request, response)
+        if "report-uri" in response[HEADER]:
+            times_seen += 1
+        if "report-to" in response[HEADER]:
+            times_seen += 1
+    assert times_seen == 1000
+
+
 @override_settings(CONTENT_SECURITY_POLICY_REPORT_ONLY={"REPORT_PERCENTAGE": 10, "DIRECTIVES": {"report-uri": "x"}})
 def test_report_percentage_report_only() -> None:
     times_seen = 0