Repository: pallets/werkzeug
Pull request #2237 (2021-09-21 17:25:51)

Fix the CSP header
The header_property does not set the on_update method in the CSP
datastructure which means any changes wouldn't be set in the
headers. This fixes the issue by specifying the properties directly
and including tests to ensure it works.

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [x] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [x] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.



Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 300e04ea..74571797 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -36,6 +36,7 @@ Unreleased
     greenlet versions. :pr:`2212`
 -   Fix type annotation in ``CallbackDict``, because it is not
     utilizing a bound TypeVar. :issue:`2235`
+-   Fix setting CSP header options on the response. :pr:`2237`
 
 
 Version 2.0.1
diff --git a/src/werkzeug/sansio/response.py b/src/werkzeug/sansio/response.py
index 40bdcd8d..82817e8c 100644
--- a/src/werkzeug/sansio/response.py
+++ b/src/werkzeug/sansio/response.py
@@ -12,12 +12,12 @@ from ..http import HTTP_STATUS_CODES
 from ..utils import get_content_type
 from werkzeug.datastructures import CallbackDict
 from werkzeug.datastructures import ContentRange
+from werkzeug.datastructures import ContentSecurityPolicy
 from werkzeug.datastructures import ResponseCacheControl
 from werkzeug.datastructures import WWWAuthenticate
 from werkzeug.http import COEP
 from werkzeug.http import COOP
 from werkzeug.http import dump_age
-from werkzeug.http import dump_csp_header
 from werkzeug.http import dump_header
 from werkzeug.http import dump_options_header
 from werkzeug.http import http_date
@@ -567,23 +567,72 @@ class Response:
 
     # CSP
 
-    content_security_policy = header_property(
-        "Content-Security-Policy",
-        None,
-        parse_csp_header,  # type: ignore
-        dump_csp_header,
-        doc="""The Content-Security-Policy header adds an additional layer of
-        security to help detect and mitigate certain types of attacks.""",
-    )
-    content_security_policy_report_only = header_property(
-        "Content-Security-Policy-Report-Only",
-        None,
-        parse_csp_header,  # type: ignore
-        dump_csp_header,
-        doc="""The Content-Security-Policy-Report-Only header adds a csp policy
+    @property
+    def content_security_policy(self) -> ContentSecurityPolicy:
+        """The ``Content-Security-Policy`` header as a
+        :class:`~werkzeug.datastructures.ContentSecurityPolicy` object. Available
+        even if the header is not set.
+
+        The Content-Security-Policy header adds an additional layer of
+        security to help detect and mitigate certain types of attacks.
+        """
+
+        def on_update(csp: ContentSecurityPolicy) -> None:
+            if not csp:
+                del self.headers["content-security-policy"]
+            else:
+                self.headers["Content-Security-Policy"] = csp.to_header()
+
+        rv = parse_csp_header(self.headers.get("content-security-policy"), on_update)
+        if rv is None:
+            rv = ContentSecurityPolicy(None, on_update=on_update)
+        return rv
+
+    @content_security_policy.setter
+    def content_security_policy(
+        self, value: t.Optional[t.Union[ContentSecurityPolicy, str]]
+    ) -> None:
+        if not value:
+            del self.headers["content-security-policy"]
+        elif isinstance(value, str):
+            self.headers["Content-Security-Policy"] = value
+        else:
+            self.headers["Content-Security-Policy"] = value.to_header()
+
+    @property
+    def content_security_policy_report_only(self) -> ContentSecurityPolicy:
+        """The ``Content-Security-policy-report-only`` header as a
+        :class:`~werkzeug.datastructures.ContentSecurityPolicy` object. Available
+        even if the header is not set.
+
+        The Content-Security-Policy-Report-Only header adds a csp policy
         that is not enforced but is reported thereby helping detect
-        certain types of attacks.""",
-    )
+        certain types of attacks.
+        """
+
+        def on_update(csp: ContentSecurityPolicy) -> None:
+            if not csp:
+                del self.headers["content-security-policy-report-only"]
+            else:
+                self.headers["Content-Security-policy-report-only"] = csp.to_header()
+
+        rv = parse_csp_header(
+            self.headers.get("content-security-policy-report-only"), on_update
+        )
+        if rv is None:
+            rv = ContentSecurityPolicy(None, on_update=on_update)
+        return rv
+
+    @content_security_policy_report_only.setter
+    def content_security_policy_report_only(
+        self, value: t.Optional[t.Union[ContentSecurityPolicy, str]]
+    ) -> None:
+        if not value:
+            del self.headers["content-security-policy-report-only"]
+        elif isinstance(value, str):
+            self.headers["Content-Security-policy-report-only"] = value
+        else:
+            self.headers["Content-Security-policy-report-only"] = value.to_header()
 
     # CORS
 


Test patch:
diff --git a/tests/test_wrappers.py b/tests/test_wrappers.py
index 8d7a5ae8..91303307 100644
--- a/tests/test_wrappers.py
+++ b/tests/test_wrappers.py
@@ -1343,6 +1343,20 @@ def test_ranges():
     assert resp.content_range.length == 1000
 
 
+def test_csp():
+    resp = wrappers.Response()
+    resp.content_security_policy.default_src = "'self'"
+    assert resp.headers["Content-Security-Policy"] == "default-src 'self'"
+    resp.content_security_policy.script_src = "'self' palletsprojects.com"
+    assert (
+        resp.headers["Content-Security-Policy"]
+        == "default-src 'self'; script-src 'self' palletsprojects.com"
+    )
+
+    resp.content_security_policy = None
+    assert "Content-Security-Policy" not in resp.headers
+
+
 def test_auto_content_length():
     resp = wrappers.Response("Hello World!")
     assert resp.content_length == 12