diff --git a/filter_plugins/Todo.md b/filter_plugins/Todo.md new file mode 100644 index 00000000..84820331 --- /dev/null +++ b/filter_plugins/Todo.md @@ -0,0 +1,2 @@ +# Todo +- Refactor is_feature_enabled to one function \ No newline at end of file diff --git a/filter_plugins/csp_filters.py b/filter_plugins/csp_filters.py index 43486f6a..3f4e9116 100644 --- a/filter_plugins/csp_filters.py +++ b/filter_plugins/csp_filters.py @@ -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-'". + """ + 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: diff --git a/roles/docker-discourse/vars/configuration.yml b/roles/docker-discourse/vars/configuration.yml index fb6ca445..db5720fc 100644 --- a/roles/docker-discourse/vars/configuration.yml +++ b/roles/docker-discourse/vars/configuration.yml @@ -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 \ No newline at end of file + central_database: true +csp: + flags: + style-src: + unsafe_inline: true + script-src: + unsafe_inline: true + whitelist: + font-src: + - "http://*.{{primary_domain}}" \ No newline at end of file diff --git a/roles/docker-nextcloud/vars/configuration.yml b/roles/docker-nextcloud/vars/configuration.yml index 689d72a5..1b138019 100644 --- a/roles/docker-nextcloud/vars/configuration.yml +++ b/roles/docker-nextcloud/vars/configuration.yml @@ -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. diff --git a/roles/docker-openproject/vars/configuration.yml b/roles/docker-openproject/vars/configuration.yml index e7566768..3ce99c41 100644 --- a/roles/docker-openproject/vars/configuration.yml +++ b/roles/docker-openproject/vars/configuration.yml @@ -12,4 +12,8 @@ features: landingpage_iframe: false ldap: true central_database: true - oauth2: true \ No newline at end of file + oauth2: true +csp: + flags: + script-src: + unsafe_inline: true \ No newline at end of file diff --git a/roles/docker-portfolio/vars/configuration.yml b/roles/docker-portfolio/vars/configuration.yml index ee79acb2..56768f5f 100644 --- a/roles/docker-portfolio/vars/configuration.yml +++ b/roles/docker-portfolio/vars/configuration.yml @@ -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}}" diff --git a/roles/docker-presentation/vars/configuration.yml b/roles/docker-presentation/vars/configuration.yml index cc7b62d5..d4781af7 100644 --- a/roles/docker-presentation/vars/configuration.yml +++ b/roles/docker-presentation/vars/configuration.yml @@ -13,4 +13,9 @@ csp: - https://cdnjs.cloudflare.com - https://cdn.jsdelivr.net font-src: - - https://cdnjs.cloudflare.com \ No newline at end of file + - https://cdnjs.cloudflare.com + flags: + style-src: + unsafe_inline: true + script-src: + unsafe-eval: true \ No newline at end of file diff --git a/roles/health-csp/files/health-csp.py b/roles/health-csp/files/health-csp.py index 58eeda8e..dcd032c1 100644 --- a/roles/health-csp/files/health-csp.py +++ b/roles/health-csp/files/health-csp.py @@ -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 diff --git a/roles/nginx-modifier-matomo/tasks/main.yml b/roles/nginx-modifier-matomo/tasks/main.yml index 9594f127..bd02c0ca 100644 --- a/roles/nginx-modifier-matomo/tasks/main.yml +++ b/roles/nginx-modifier-matomo/tasks/main.yml @@ -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 diff --git a/tests/unit/test_csp_filters.py b/tests/unit/test_csp_filters.py index 23d205fe..716b6ca0 100644 --- a/tests/unit/test_csp_filters.py +++ b/tests/unit/test_csp_filters.py @@ -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): @@ -74,6 +93,43 @@ class TestCspFilters(unittest.TestCase): 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() \ No newline at end of file