diff --git a/CHANGELOG.md b/CHANGELOG.md index 3b0dce3..1de9fe5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,7 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -## [Unreleased](https://github.com/nationalarchives/python-utilities/compare/v1.8.0...HEAD) +## [Unreleased](https://github.com/nationalarchives/python-utilities/compare/v1.9.0...HEAD) ### Added ### Changed @@ -14,6 +14,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed ### Security +## [1.9.0](https://github.com/nationalarchives/python-utilities/compare/v1.8.0...v1.9.0) - 2026-08-26 + +### Changed + +- `CspGenerator.sandbox()` now accepts multiple parameters + +### Fixed + +- Fixed issue adding `sandbox` CSP directive through Flask Talisman + ## [1.8.0](https://github.com/nationalarchives/python-utilities/compare/v1.7.0...v1.8.0) - 2026-08-19 ### Added diff --git a/docs/flask.md b/docs/flask.md index 25e37da..d28f1d1 100644 --- a/docs/flask.md +++ b/docs/flask.md @@ -82,5 +82,18 @@ from flask import Flask from tna_utilities.flask import Talisman app = Flask(__name__) -Talisman(app) +Talisman( + app, + content_security_policy={ + "img-src": ["img.example.com"], + "sandbox": [ + "allow-scripts", + "allow-downloads", + "allow-forms", + "allow-same-origin", + "allow-popups", + ], + }, + allow_google_content_security_policy=True, +) ``` diff --git a/docs/security.md b/docs/security.md index cde16a9..acd3f80 100644 --- a/docs/security.md +++ b/docs/security.md @@ -11,22 +11,22 @@ A utility class to generate a CSP. ```python from tna_utilities.security import CspGenerator -generator = CspGenerator() +csp = CspGenerator() # Add a single directive source (plus 'self') -generator.script_src("example.com") +csp.script_src("example.com") # Add multiple sources for a directive (plus 'self') -generator.style_src("example.com", "another.net") +csp.style_src("example.com", "another.net") # Add a directive source without allowing 'self' -generator.object_src("example.com", omit_self=True) +csp.object_src("example.com", omit_self=True) # Disallow a directive -generator.disallow("worker-src") +csp.disallow("worker-src") -generator.to_string() -# default-src 'self'; script-src 'self' example.com; style-src 'self' example.com another.net; object-src example.com; worker-src 'none'; +csp.to_string() +# default-src 'self'; script-src 'self' example.com; style-src 'self' example.com another.net; object-src example.com; worker-src 'none' ``` ## `common_security_headers` diff --git a/pyproject.toml b/pyproject.toml index 63329e2..7941a73 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "tna-utilities" -version = "1.8.0" +version = "1.9.0" requires-python = ">=3.10" authors = [ {name = "Andrew Hosgood", email = "andrew.hosgood@nationalarchives.gov.uk"}, diff --git a/tests/test_flask_talisman.py b/tests/test_flask_talisman.py index 67327bd..bf1fa2d 100644 --- a/tests/test_flask_talisman.py +++ b/tests/test_flask_talisman.py @@ -165,8 +165,11 @@ def test_talisman_custom_csp(self): Talisman( self.app, content_security_policy={ - "default-src": ["'self'", "example.com"], "img-src": ["'self'", "img.example.com"], + "sandbox": [ + "allow-scripts", + "allow-downloads", + ], }, ) @@ -176,13 +179,17 @@ def test_talisman_custom_csp(self): self.assertIn("Content-Security-Policy", rv.headers) self.assertIn( - "default-src 'self' example.com", + "default-src 'self'", rv.headers["Content-Security-Policy"], ) self.assertIn( "img-src 'self' img.example.com", rv.headers["Content-Security-Policy"], ) + self.assertIn( + "sandbox allow-scripts allow-downloads", + rv.headers["Content-Security-Policy"], + ) def test_talisman_custom_csp_with_google(self): Talisman( diff --git a/tests/test_security.py b/tests/test_security.py index a94ddd8..5f1ef11 100644 --- a/tests/test_security.py +++ b/tests/test_security.py @@ -342,6 +342,12 @@ def test_add_sandbox_value(self): self.assertIn("default-src 'self'", generator.to_string()) self.assertIn("sandbox allow-scripts", generator.to_string()) + def test_add_sandbox_values(self): + generator = CspGenerator() + generator.sandbox("allow-scripts", "allow-downloads") + self.assertIn("default-src 'self'", generator.to_string()) + self.assertIn("sandbox allow-scripts allow-downloads", generator.to_string()) + def test_add_sandbox_invalid_value(self): generator = CspGenerator() generator.sandbox("pizza") diff --git a/tna_utilities/flask/talisman.py b/tna_utilities/flask/talisman.py index 01e3aa5..4ff1ff4 100644 --- a/tna_utilities/flask/talisman.py +++ b/tna_utilities/flask/talisman.py @@ -155,28 +155,21 @@ def _apply_extra_headers(self, response): Applies the configured security headers to the response. """ - response.headers["Content-Security-Policy"] = self._csp( - self.content_security_policy, - self.allow_google_content_security_policy, - self.allow_typekit_content_security_policy, - ) + response.headers["Content-Security-Policy"] = self._csp() response.headers.update(common_security_headers()) response.headers["Referrer-Policy"] = self.referrer_policy response.headers.update(self.extra_headers) return response - def _csp( - self, - content_security_policy: dict, - allow_google_content_security_policy: bool = False, - allow_typekit_content_security_policy: bool = False, - ): + def _csp(self): """ Generates a Content-Security-Policy header value based on the provided content security policy configuration and the option to include Google's recommended content security policy directives. """ csp = CspGenerator( - default_src=content_security_policy.get("default-src", CspGenerator.SELF) + default_src=self.content_security_policy.get( + "default-src", CspGenerator.SELF + ) ) property_methods = [ @@ -205,19 +198,19 @@ def _csp( for directive, method in property_methods: # Intentionally pass an empty string for missing optional directives; # CspGenerator setters treat this as "not set" while keeping call signatures consistent. - method(content_security_policy.get(directive, "")) + method(self.content_security_policy.get(directive, "")) - if "sandbox" in content_security_policy: - csp.sandbox(content_security_policy["sandbox"]) + if "sandbox" in self.content_security_policy: + csp.sandbox(*self.content_security_policy["sandbox"]) - if content_security_policy.get("require-trusted-types-for", False): + if self.content_security_policy.get("require-trusted-types-for", False): csp.require_trusted_types_for() - if allow_google_content_security_policy: + if self.allow_google_content_security_policy: for directive, values in GOOGLE_CSP_DIRECTIVES.items(): csp.add_directive(directive, *values) - if allow_typekit_content_security_policy: + if self.allow_typekit_content_security_policy: for directive, values in TYPEKIT_CSP_DIRECTIVES.items(): csp.add_directive(directive, *values) diff --git a/tna_utilities/security.py b/tna_utilities/security.py index 00fa8f8..cfdc90e 100644 --- a/tna_utilities/security.py +++ b/tna_utilities/security.py @@ -309,7 +309,7 @@ def require_trusted_types_for(self) -> "CspGenerator": self.directives["require-trusted-types-for"] = ["'script'"] return self - def sandbox(self, value: str | None = None) -> "CspGenerator": + def sandbox(self, *values: str | list[str]) -> "CspGenerator": """ Add or configure the CSP ``sandbox`` directive. @@ -317,8 +317,8 @@ def sandbox(self, value: str | None = None) -> "CspGenerator": framed content. When a sandbox directive is present with no allowed flags, all sandbox restrictions are applied. - :param value: - Optional sandbox permission token to allow within the sandboxed + :param values: + Optional sandbox permission tokens to allow within the sandboxed context. Must be one of: - ``"allow-downloads"`` @@ -335,14 +335,14 @@ def sandbox(self, value: str | None = None) -> "CspGenerator": - ``"allow-top-navigation-by-user-activation"`` - ``"allow-top-navigation-to-custom-protocols"`` - If ``value`` is ``None`` (the default) or not one of the allowed + If ``values`` is ``None`` (the default) or not one of the allowed tokens, no sandbox flags are added and a bare ``sandbox`` directive is generated, applying all sandbox restrictions. :returns: This ``CspGenerator`` instance to allow method chaining. """ - values = [ + accepted_values = [ "allow-downloads", "allow-forms", "allow-modals", @@ -357,7 +357,11 @@ def sandbox(self, value: str | None = None) -> "CspGenerator": "allow-top-navigation-by-user-activation", "allow-top-navigation-to-custom-protocols", ] - sources = [value] if value is not None and value in values else [] + sources = ( + [value for value in values if value in accepted_values] + if values is not None + else [] + ) self.directives["sandbox"] = sources return self