mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-05-16 01:37:19 +02:00
Optimized CSP
This commit is contained in:
parent
25d16eb620
commit
551c041452
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
|
@ -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:
|
||||
|
@ -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}}"
|
@ -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.
|
||||
|
@ -12,4 +12,8 @@ features:
|
||||
landingpage_iframe: false
|
||||
ldap: true
|
||||
central_database: true
|
||||
oauth2: 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}}"
|
||||
|
@ -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
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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()
|
Loading…
x
Reference in New Issue
Block a user