Optimized CSP

This commit is contained in:
Kevin Veen-Birkenbach 2025-05-14 21:25:17 +02:00
parent 25d16eb620
commit 551c041452
No known key found for this signature in database
GPG Key ID: 44D8F11FD62F878E
10 changed files with 176 additions and 37 deletions

2
filter_plugins/Todo.md Normal file
View File

@ -0,0 +1,2 @@
# Todo
- Refactor is_feature_enabled to one function

View File

@ -1,4 +1,6 @@
from ansible.errors import AnsibleFilterError
import hashlib
import base64
class FilterModule(object):
"""
@ -7,16 +9,18 @@ class FilterModule(object):
def filters(self):
return {
'get_csp_whitelist': self.get_csp_whitelist,
'get_csp_flags': self.get_csp_flags,
'build_csp_header': self.build_csp_header,
}
def is_feature_enabled(self, applications, feature: str, application_id: str) -> bool:
"""
Check if a generic feature is enabled for the given application.
"""
app = applications.get(application_id, {})
return bool(app.get('features', {}).get(feature, False))
@staticmethod
def get_csp_whitelist(applications, application_id, directive):
"""
Return the list of extra hosts/URLs to whitelist for a given CSP directive.
"""
app = applications.get(application_id, {})
wl = app.get('csp', {}).get('whitelist', {}).get(directive, [])
if isinstance(wl, list):
@ -27,37 +31,52 @@ class FilterModule(object):
@staticmethod
def get_csp_flags(applications, application_id, directive):
"""
Read 'unsafe_eval' and 'unsafe_inline' flags for a given CSP directive.
Returns a list of string tokens, e.g. ["'unsafe-eval'", "'unsafe-inline'"]
"""
app = applications.get(application_id, {})
flags_config = app.get('csp', {}).get('flags', {}).get(directive, {})
flags = app.get('csp', {}).get('flags', {}).get(directive, {})
tokens = []
if flags_config.get('unsafe_eval', False):
if flags.get('unsafe_eval', False):
tokens.append("'unsafe-eval'")
if flags_config.get('unsafe_inline', False):
if flags.get('unsafe_inline', False):
tokens.append("'unsafe-inline'")
return tokens
@staticmethod
def is_feature_enabled(applications, feature, application_id):
def get_csp_inline_content(applications, application_id, directive):
"""
Check if a named feature is enabled for the given application.
Return inline script/style snippets to hash for a given CSP directive.
"""
app = applications.get(application_id, {})
return bool(app.get('features', {}).get(feature, False))
snippets = app.get('csp', {}).get('hashes', {}).get(directive, [])
if isinstance(snippets, list):
return snippets
if snippets:
return [snippets]
return []
def build_csp_header(self, applications, application_id, domains, web_protocol='https', matomo_feature_name='matomo'):
@staticmethod
def get_csp_hash(content):
"""
Compute the SHA256 hash of the given inline content and return
a CSP token like "'sha256-<base64>'".
"""
try:
digest = hashlib.sha256(content.encode('utf-8')).digest()
b64 = base64.b64encode(digest).decode('utf-8')
return f"'sha256-{b64}'"
except Exception as exc:
raise AnsibleFilterError(f"get_csp_hash failed: {exc}")
def build_csp_header(
self,
applications,
application_id,
domains,
web_protocol='https',
matomo_feature_name='matomo'
):
"""
Build the Content-Security-Policy header value dynamically based on application settings.
:param applications: dict of application configurations
:param application_id: the id of the application
:param domains: dict mapping names (e.g., 'matomo') to domain strings
:param web_protocol: protocol prefix for Matomo (default: 'https')
:param matomo_feature_name: feature flag name for Matomo (default: 'matomo')
:return: CSP header string, e.g. "default-src 'self'; script-src 'self' 'unsafe-eval' https://example.com; img-src * data: blob:;"
Inline hashes are read from applications[application_id].csp.hashes
"""
try:
directives = [
@ -73,22 +92,25 @@ class FilterModule(object):
for directive in directives:
tokens = ["'self'"]
# unsafe flags
# unsafe-eval / unsafe-inline flags
tokens += self.get_csp_flags(applications, application_id, directive)
# Matomo integration
if self.is_feature_enabled(applications, matomo_feature_name, application_id) \
and directive in ['script-src', 'connect-src']:
if (
self.is_feature_enabled(applications, matomo_feature_name, application_id)
and directive in ['script-src', 'connect-src']
):
matomo_domain = domains.get('matomo')
if matomo_domain:
tokens.append(f"{web_protocol}://{matomo_domain}")
# whitelist
tokens += self.get_csp_whitelist(applications, application_id, directive)
# inline hashes from config
for snippet in self.get_csp_inline_content(applications, application_id, directive):
tokens.append(self.get_csp_hash(snippet))
parts.append(f"{directive} {' '.join(tokens)};")
# static img-src
parts.append("img-src * data: blob:;")
# join parts with space
return ' '.join(parts)
except Exception as exc:

View File

@ -2,10 +2,18 @@ network: "discourse_default" # Name of the docker network
container: "discourse_application" # Name of the container application
repository: "discourse_repository" # Name of the repository folder
credentials:
# database_password: # Needs to be defined in inventory file
features:
matomo: true
css: true
landingpage_iframe: false
oidc: true
central_database: true
csp:
flags:
style-src:
unsafe_inline: true
script-src:
unsafe_inline: true
whitelist:
font-src:
- "http://*.{{primary_domain}}"

View File

@ -1,6 +1,10 @@
version: "production" # @see https://nextcloud.com/blog/nextcloud-release-channels-and-how-to-track-them/
ldap:
enabled: True # Enables LDAP by default
csp:
flags:
style-src:
unsafe_inline: true
oidc:
enabled: "{{ applications.nextcloud.features.oidc | default(true) }}" # Activate OIDC for Nextcloud
# floavor decides which OICD plugin should be used.

View File

@ -13,3 +13,7 @@ features:
ldap: true
central_database: true
oauth2: true
csp:
flags:
script-src:
unsafe_inline: true

View File

@ -11,5 +11,8 @@ csp:
- https://cdn.jsdelivr.net
font-src:
- https://ka-f.fontawesome.com
- https://cdn.jsdelivr.net
connect-src:
- https://ka-f.fontawesome.com
frame-src:
- "{{ web_protocol }}://*.{{primary_domain}}"

View File

@ -14,3 +14,8 @@ csp:
- https://cdn.jsdelivr.net
font-src:
- https://cdnjs.cloudflare.com
flags:
style-src:
unsafe_inline: true
script-src:
unsafe-eval: true

View File

@ -25,7 +25,7 @@ def run_checkcsp(domains):
"""
Executes the 'checkcsp' command with the given domains.
"""
cmd = ["checkcsp", "start"] + domains
cmd = ["checkcsp", "start", "--short"] + domains
try:
result = subprocess.run(cmd, check=True)
return result.returncode

View File

@ -50,3 +50,38 @@
- name: Set the tracking code as a one-liner
set_fact:
matomo_tracking_code_one_liner: "{{ matomo_tracking_code | regex_replace('\\n', '') | regex_replace('\\s+', ' ') }}"
- name: Ensure csp.hashes exists for this app
set_fact:
applications: >-
{{
applications
| combine({
(application_id): {
'csp': {
'hashes': {}
}
}
}, recursive=True)
}}
changed_when: false
- name: Append Matomo one-liner to script-src inline hashes
set_fact:
applications: >-
{{
applications
| combine({
(application_id): {
'csp': {
'hashes': {
'script-src': (
applications[application_id]['csp']['hashes'].get('script-src', [])
+ [ matomo_tracking_code_one_liner ]
)
}
}
}
}, recursive=True)
}}
changed_when: false

View File

@ -1,5 +1,7 @@
import unittest
from filter_plugins.csp_filters import FilterModule
import hashlib
import base64
from filter_plugins.csp_filters import FilterModule, AnsibleFilterError
class TestCspFilters(unittest.TestCase):
def setUp(self):
@ -24,6 +26,14 @@ class TestCspFilters(unittest.TestCase):
'unsafe_inline': True,
},
},
'hashes': {
'script-src': [
"console.log('hello');",
],
'style-src': [
"body { background: #fff; }",
]
}
},
},
'app2': {}
@ -62,8 +72,17 @@ class TestCspFilters(unittest.TestCase):
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
# Ensure core directives are present
self.assertIn("default-src 'self';", header)
self.assertIn("script-src 'self' 'unsafe-eval' https://matomo.example.org https://cdn.example.com;", header)
self.assertIn("connect-src 'self' https://matomo.example.org https://api.example.com;", header)
# script-src directive should include unsafe-eval, Matomo domain and CDN (hash may follow)
self.assertIn(
"script-src 'self' 'unsafe-eval' https://matomo.example.org https://cdn.example.com",
header
)
# connect-src directive unchanged (no inline hash)
self.assertIn(
"connect-src 'self' https://matomo.example.org https://api.example.com;",
header
)
# ends with img-src
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
def test_build_csp_header_without_matomo_or_flags(self):
@ -75,5 +94,42 @@ class TestCspFilters(unittest.TestCase):
# ends with img-src
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
def test_get_csp_inline_content_list(self):
snippets = self.filter.get_csp_inline_content(self.apps, 'app1', 'script-src')
self.assertEqual(snippets, ["console.log('hello');"])
def test_get_csp_inline_content_string(self):
# simulate single string instead of list
self.apps['app1']['csp']['hashes']['style-src'] = "body { color: red; }"
snippets = self.filter.get_csp_inline_content(self.apps, 'app1', 'style-src')
self.assertEqual(snippets, ["body { color: red; }"])
def test_get_csp_inline_content_none(self):
snippets = self.filter.get_csp_inline_content(self.apps, 'app1', 'font-src')
self.assertEqual(snippets, [])
def test_get_csp_hash_known_value(self):
content = "alert(1);"
# compute expected
digest = hashlib.sha256(content.encode('utf-8')).digest()
b64 = base64.b64encode(digest).decode('utf-8')
expected = f"'sha256-{b64}'"
result = self.filter.get_csp_hash(content)
self.assertEqual(result, expected)
def test_get_csp_hash_error(self):
with self.assertRaises(AnsibleFilterError):
# passing a non-decodable object
self.filter.get_csp_hash(None)
def test_build_csp_header_includes_hashes(self):
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
# check that the script-src directive includes our inline hash
script_hash = self.filter.get_csp_hash("console.log('hello');")
self.assertIn(script_hash, header)
# check that the style-src directive includes its inline hash
style_hash = self.filter.get_csp_hash("body { background: #fff; }")
self.assertIn(style_hash, header)
if __name__ == '__main__':
unittest.main()