Compare commits

...

4 Commits

13 changed files with 320 additions and 138 deletions

2
filter_plugins/Todo.md Normal file
View File

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

View File

@ -5,34 +5,6 @@ def is_feature_enabled(applications, feature: str, application_id: str) -> bool:
app = applications.get(application_id, {})
return bool(app.get('features', {}).get(feature, False))
def get_csp_whitelist(applications, application_id: str, directive: str) -> list:
"""
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):
return wl
if wl:
return [wl]
return []
def get_csp_flags(applications, application_id: str, directive: str) -> list:
"""
Read 'unsafe_eval' and 'unsafe_inline' flags from csp.flags.<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, {})
tokens = []
if flags_config.get('unsafe_eval', False):
tokens.append("'unsafe-eval'")
if flags_config.get('unsafe_inline', False):
tokens.append("'unsafe-inline'")
return tokens
def get_docker_compose(path_docker_compose_instances: str, application_id: str) -> dict:
"""
Build the docker_compose dict based on
@ -58,7 +30,5 @@ class FilterModule(object):
def filters(self):
return {
'is_feature_enabled': is_feature_enabled,
'get_csp_whitelist': get_csp_whitelist,
'get_csp_flags': get_csp_flags,
'get_docker_compose': get_docker_compose,
}

View File

@ -0,0 +1,117 @@
from ansible.errors import AnsibleFilterError
import hashlib
import base64
class FilterModule(object):
"""
Custom filters for Content Security Policy generation and CSP-related utilities.
"""
def filters(self):
return {
'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):
app = applications.get(application_id, {})
wl = app.get('csp', {}).get('whitelist', {}).get(directive, [])
if isinstance(wl, list):
return wl
if wl:
return [wl]
return []
@staticmethod
def get_csp_flags(applications, application_id, directive):
app = applications.get(application_id, {})
flags = app.get('csp', {}).get('flags', {}).get(directive, {})
tokens = []
if flags.get('unsafe_eval', False):
tokens.append("'unsafe-eval'")
if flags.get('unsafe_inline', False):
tokens.append("'unsafe-inline'")
return tokens
@staticmethod
def get_csp_inline_content(applications, application_id, directive):
"""
Return inline script/style snippets to hash for a given CSP directive.
"""
app = applications.get(application_id, {})
snippets = app.get('csp', {}).get('hashes', {}).get(directive, [])
if isinstance(snippets, list):
return snippets
if snippets:
return [snippets]
return []
@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.
Inline hashes are read from applications[application_id].csp.hashes
"""
try:
directives = [
'default-src',
'connect-src',
'frame-ancestors',
'frame-src',
'script-src',
'style-src',
'font-src'
]
parts = []
for directive in directives:
tokens = ["'self'"]
# 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']
):
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:;")
return ' '.join(parts)
except Exception as exc:
raise AnsibleFilterError(f"build_csp_header failed: {exc}")

View File

@ -1,11 +1,19 @@
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
credentials:
features:
matomo: true
css: true
landingpage_iframe: false
oidc: true
central_database: 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

@ -12,4 +12,8 @@ features:
landingpage_iframe: false
ldap: true
central_database: true
oauth2: 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

@ -13,4 +13,9 @@ csp:
- https://cdnjs.cloudflare.com
- https://cdn.jsdelivr.net
font-src:
- https://cdnjs.cloudflare.com
- 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

@ -1,45 +1,2 @@
{# Create a namespace to hold the accumulated CSP parts #}
{% set ns = namespace(csp_parts=[]) %}
{# List of directives to build dynamically (except img-src) #}
{% set directives = [
'default-src',
'connect-src',
'frame-ancestors',
'frame-src',
'script-src',
'style-src',
'font-src'
] %}
{# Build each directive line #}
{% for directive in directives %}
{# Always start with 'self' #}
{% set tokens = ["'self'"] %}
{# Add any unsafe flags for this directive #}
{% for flag in applications | get_csp_flags(application_id, directive) %}
{% set tokens = tokens + [flag] %}
{% endfor %}
{# If Matomo is enabled, allow its script and connect endpoints #}
{% if applications | is_feature_enabled('matomo', application_id)
and directive in ['script-src', 'connect-src'] %}
{% set tokens = tokens + [web_protocol ~ '://' ~ domains.matomo] %}
{% endif %}
{# Append any extra whitelist URLs for this directive #}
{% for url in applications | get_csp_whitelist(application_id, directive) %}
{% set tokens = tokens + [url] %}
{% endfor %}
{# Store the completed directive line in the namespace #}
{% set ns.csp_parts = ns.csp_parts + [directive ~ ' ' ~ (tokens | join(' ')) ~ ';'] %}
{% endfor %}
{# Add the (static) img-src directive #}
{% set ns.csp_parts = ns.csp_parts + ['img-src * data: blob:;'] %}
{# Emit the final header and hide any upstream header #}
add_header Content-Security-Policy "{{ ns.csp_parts | join(' ') }}" always;
add_header Content-Security-Policy "{{ applications | build_csp_header(application_id, domains) }}" always;
proxy_hide_header Content-Security-Policy;

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

@ -3,8 +3,6 @@
import unittest
from filter_plugins.configuration_filters import (
is_feature_enabled,
get_csp_whitelist,
get_csp_flags,
)
@ -16,25 +14,6 @@ class TestConfigurationFilters(unittest.TestCase):
'features': {
'oauth2': True,
},
'csp': {
'whitelist': {
# directive with a list
'script-src': ['https://example.com'],
# directive with a single string
'connect-src': 'https://api.example.com',
},
'flags': {
# both flags for script-src
'script-src': {
'unsafe_eval': True,
'unsafe_inline': False,
},
# only unsafe_inline for style-src
'style-src': {
'unsafe_inline': True,
},
},
},
},
'app2': {
# no features or csp defined
@ -51,42 +30,5 @@ class TestConfigurationFilters(unittest.TestCase):
def test_is_feature_enabled_false_missing_app(self):
self.assertFalse(is_feature_enabled(self.applications, 'oauth2', 'unknown_app'))
# Tests for get_csp_whitelist
def test_get_csp_whitelist_returns_list_as_is(self):
result = get_csp_whitelist(self.applications, 'app1', 'script-src')
self.assertEqual(result, ['https://example.com'])
def test_get_csp_whitelist_wraps_string_in_list(self):
result = get_csp_whitelist(self.applications, 'app1', 'connect-src')
self.assertEqual(result, ['https://api.example.com'])
def test_get_csp_whitelist_empty_when_not_defined(self):
result = get_csp_whitelist(self.applications, 'app1', 'frame-src')
self.assertEqual(result, [])
def test_get_csp_whitelist_empty_when_app_missing(self):
result = get_csp_whitelist(self.applications, 'nonexistent_app', 'script-src')
self.assertEqual(result, [])
# Tests for get_csp_flags
def test_get_csp_flags_includes_unsafe_eval(self):
result = get_csp_flags(self.applications, 'app1', 'script-src')
self.assertIn("'unsafe-eval'", result)
self.assertNotIn("'unsafe-inline'", result)
def test_get_csp_flags_includes_unsafe_inline(self):
result = get_csp_flags(self.applications, 'app1', 'style-src')
self.assertIn("'unsafe-inline'", result)
self.assertNotIn("'unsafe-eval'", result)
def test_get_csp_flags_empty_when_none_configured(self):
result = get_csp_flags(self.applications, 'app1', 'connect-src')
self.assertEqual(result, [])
def test_get_csp_flags_empty_when_app_missing(self):
result = get_csp_flags(self.applications, 'nonexistent_app', 'script-src')
self.assertEqual(result, [])
if __name__ == '__main__':
unittest.main()

View File

@ -0,0 +1,135 @@
import unittest
import hashlib
import base64
from filter_plugins.csp_filters import FilterModule, AnsibleFilterError
class TestCspFilters(unittest.TestCase):
def setUp(self):
self.filter = FilterModule()
self.apps = {
'app1': {
'features': {
'oauth2': True,
'matomo': True,
},
'csp': {
'whitelist': {
'script-src': ['https://cdn.example.com'],
'connect-src': 'https://api.example.com',
},
'flags': {
'script-src': {
'unsafe_eval': True,
'unsafe_inline': False,
},
'style-src': {
'unsafe_inline': True,
},
},
'hashes': {
'script-src': [
"console.log('hello');",
],
'style-src': [
"body { background: #fff; }",
]
}
},
},
'app2': {}
}
self.domains = {
'matomo': 'matomo.example.org'
}
def test_get_csp_whitelist_list(self):
result = self.filter.get_csp_whitelist(self.apps, 'app1', 'script-src')
self.assertEqual(result, ['https://cdn.example.com'])
def test_get_csp_whitelist_string(self):
result = self.filter.get_csp_whitelist(self.apps, 'app1', 'connect-src')
self.assertEqual(result, ['https://api.example.com'])
def test_get_csp_whitelist_none(self):
result = self.filter.get_csp_whitelist(self.apps, 'app1', 'font-src')
self.assertEqual(result, [])
def test_get_csp_flags_eval(self):
result = self.filter.get_csp_flags(self.apps, 'app1', 'script-src')
self.assertIn("'unsafe-eval'", result)
self.assertNotIn("'unsafe-inline'", result)
def test_get_csp_flags_inline(self):
result = self.filter.get_csp_flags(self.apps, 'app1', 'style-src')
self.assertIn("'unsafe-inline'", result)
self.assertNotIn("'unsafe-eval'", result)
def test_get_csp_flags_none(self):
result = self.filter.get_csp_flags(self.apps, 'app1', 'connect-src')
self.assertEqual(result, [])
def test_build_csp_header_basic(self):
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)
# 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):
header = self.filter.build_csp_header(self.apps, 'app2', self.domains)
# default-src only contains 'self'
self.assertIn("default-src 'self';", header)
# no external URLs
self.assertNotIn('http', header)
# 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()