Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 11 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
15 changes: 14 additions & 1 deletion docs/flask.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
)
```
14 changes: 7 additions & 7 deletions docs/security.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
@@ -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"},
Expand Down
11 changes: 9 additions & 2 deletions tests/test_flask_talisman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
],
},
)

Expand All @@ -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(
Expand Down
6 changes: 6 additions & 0 deletions tests/test_security.py
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
29 changes: 11 additions & 18 deletions tna_utilities/flask/talisman.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
Expand Down Expand Up @@ -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)

Expand Down
16 changes: 10 additions & 6 deletions tna_utilities/security.py
Original file line number Diff line number Diff line change
Expand Up @@ -309,16 +309,16 @@ 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.

The CSP ``sandbox`` directive enables a set of restrictions for the
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"``
Expand All @@ -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",
Expand All @@ -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
Expand Down