mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-06-25 03:38:59 +02:00
Compare commits
4 Commits
0ac9dac658
...
551c041452
Author | SHA1 | Date | |
---|---|---|---|
551c041452 | |||
25d16eb620 | |||
02d773b6e1 | |||
ec5dbc7e80 |
2
filter_plugins/Todo.md
Normal file
2
filter_plugins/Todo.md
Normal file
@ -0,0 +1,2 @@
|
||||
# Todo
|
||||
- Refactor is_feature_enabled to one function
|
@ -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,
|
||||
}
|
117
filter_plugins/csp_filters.py
Normal file
117
filter_plugins/csp_filters.py
Normal 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}")
|
@ -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}}"
|
@ -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.
|
||||
|
@ -13,3 +13,7 @@ features:
|
||||
ldap: true
|
||||
central_database: true
|
||||
oauth2: true
|
||||
csp:
|
||||
flags:
|
||||
script-src:
|
||||
unsafe_inline: true
|
@ -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}}"
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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;
|
@ -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
|
||||
|
@ -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()
|
135
tests/unit/test_csp_filters.py
Normal file
135
tests/unit/test_csp_filters.py
Normal 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()
|
Loading…
x
Reference in New Issue
Block a user