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
|
from ansible.errors import AnsibleFilterError
|
||||||
|
import hashlib
|
||||||
|
import base64
|
||||||
|
|
||||||
class FilterModule(object):
|
class FilterModule(object):
|
||||||
"""
|
"""
|
||||||
@ -7,16 +9,18 @@ class FilterModule(object):
|
|||||||
|
|
||||||
def filters(self):
|
def filters(self):
|
||||||
return {
|
return {
|
||||||
'get_csp_whitelist': self.get_csp_whitelist,
|
|
||||||
'get_csp_flags': self.get_csp_flags,
|
|
||||||
'build_csp_header': self.build_csp_header,
|
'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
|
@staticmethod
|
||||||
def get_csp_whitelist(applications, application_id, directive):
|
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, {})
|
app = applications.get(application_id, {})
|
||||||
wl = app.get('csp', {}).get('whitelist', {}).get(directive, [])
|
wl = app.get('csp', {}).get('whitelist', {}).get(directive, [])
|
||||||
if isinstance(wl, list):
|
if isinstance(wl, list):
|
||||||
@ -27,37 +31,52 @@ class FilterModule(object):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def get_csp_flags(applications, application_id, directive):
|
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, {})
|
app = applications.get(application_id, {})
|
||||||
flags_config = app.get('csp', {}).get('flags', {}).get(directive, {})
|
flags = app.get('csp', {}).get('flags', {}).get(directive, {})
|
||||||
tokens = []
|
tokens = []
|
||||||
if flags_config.get('unsafe_eval', False):
|
if flags.get('unsafe_eval', False):
|
||||||
tokens.append("'unsafe-eval'")
|
tokens.append("'unsafe-eval'")
|
||||||
if flags_config.get('unsafe_inline', False):
|
if flags.get('unsafe_inline', False):
|
||||||
tokens.append("'unsafe-inline'")
|
tokens.append("'unsafe-inline'")
|
||||||
return tokens
|
return tokens
|
||||||
|
|
||||||
@staticmethod
|
@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, {})
|
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.
|
Build the Content-Security-Policy header value dynamically based on application settings.
|
||||||
|
Inline hashes are read from applications[application_id].csp.hashes
|
||||||
: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:;"
|
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
directives = [
|
directives = [
|
||||||
@ -73,22 +92,25 @@ class FilterModule(object):
|
|||||||
|
|
||||||
for directive in directives:
|
for directive in directives:
|
||||||
tokens = ["'self'"]
|
tokens = ["'self'"]
|
||||||
# unsafe flags
|
# unsafe-eval / unsafe-inline flags
|
||||||
tokens += self.get_csp_flags(applications, application_id, directive)
|
tokens += self.get_csp_flags(applications, application_id, directive)
|
||||||
# Matomo integration
|
# Matomo integration
|
||||||
if self.is_feature_enabled(applications, matomo_feature_name, application_id) \
|
if (
|
||||||
and directive in ['script-src', 'connect-src']:
|
self.is_feature_enabled(applications, matomo_feature_name, application_id)
|
||||||
|
and directive in ['script-src', 'connect-src']
|
||||||
|
):
|
||||||
matomo_domain = domains.get('matomo')
|
matomo_domain = domains.get('matomo')
|
||||||
if matomo_domain:
|
if matomo_domain:
|
||||||
tokens.append(f"{web_protocol}://{matomo_domain}")
|
tokens.append(f"{web_protocol}://{matomo_domain}")
|
||||||
# whitelist
|
# whitelist
|
||||||
tokens += self.get_csp_whitelist(applications, application_id, directive)
|
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)};")
|
parts.append(f"{directive} {' '.join(tokens)};")
|
||||||
|
|
||||||
# static img-src
|
# static img-src
|
||||||
parts.append("img-src * data: blob:;")
|
parts.append("img-src * data: blob:;")
|
||||||
|
|
||||||
# join parts with space
|
|
||||||
return ' '.join(parts)
|
return ' '.join(parts)
|
||||||
|
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
|
@ -2,10 +2,18 @@ network: "discourse_default" # Name of the docker network
|
|||||||
container: "discourse_application" # Name of the container application
|
container: "discourse_application" # Name of the container application
|
||||||
repository: "discourse_repository" # Name of the repository folder
|
repository: "discourse_repository" # Name of the repository folder
|
||||||
credentials:
|
credentials:
|
||||||
# database_password: # Needs to be defined in inventory file
|
|
||||||
features:
|
features:
|
||||||
matomo: true
|
matomo: true
|
||||||
css: true
|
css: true
|
||||||
landingpage_iframe: false
|
landingpage_iframe: false
|
||||||
oidc: true
|
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/
|
version: "production" # @see https://nextcloud.com/blog/nextcloud-release-channels-and-how-to-track-them/
|
||||||
ldap:
|
ldap:
|
||||||
enabled: True # Enables LDAP by default
|
enabled: True # Enables LDAP by default
|
||||||
|
csp:
|
||||||
|
flags:
|
||||||
|
style-src:
|
||||||
|
unsafe_inline: true
|
||||||
oidc:
|
oidc:
|
||||||
enabled: "{{ applications.nextcloud.features.oidc | default(true) }}" # Activate OIDC for Nextcloud
|
enabled: "{{ applications.nextcloud.features.oidc | default(true) }}" # Activate OIDC for Nextcloud
|
||||||
# floavor decides which OICD plugin should be used.
|
# floavor decides which OICD plugin should be used.
|
||||||
|
@ -13,3 +13,7 @@ features:
|
|||||||
ldap: true
|
ldap: true
|
||||||
central_database: true
|
central_database: true
|
||||||
oauth2: true
|
oauth2: true
|
||||||
|
csp:
|
||||||
|
flags:
|
||||||
|
script-src:
|
||||||
|
unsafe_inline: true
|
@ -11,5 +11,8 @@ csp:
|
|||||||
- https://cdn.jsdelivr.net
|
- https://cdn.jsdelivr.net
|
||||||
font-src:
|
font-src:
|
||||||
- https://ka-f.fontawesome.com
|
- https://ka-f.fontawesome.com
|
||||||
|
- https://cdn.jsdelivr.net
|
||||||
|
connect-src:
|
||||||
|
- https://ka-f.fontawesome.com
|
||||||
frame-src:
|
frame-src:
|
||||||
- "{{ web_protocol }}://*.{{primary_domain}}"
|
- "{{ web_protocol }}://*.{{primary_domain}}"
|
||||||
|
@ -14,3 +14,8 @@ csp:
|
|||||||
- https://cdn.jsdelivr.net
|
- https://cdn.jsdelivr.net
|
||||||
font-src:
|
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.
|
Executes the 'checkcsp' command with the given domains.
|
||||||
"""
|
"""
|
||||||
cmd = ["checkcsp", "start"] + domains
|
cmd = ["checkcsp", "start", "--short"] + domains
|
||||||
try:
|
try:
|
||||||
result = subprocess.run(cmd, check=True)
|
result = subprocess.run(cmd, check=True)
|
||||||
return result.returncode
|
return result.returncode
|
||||||
|
@ -50,3 +50,38 @@
|
|||||||
- name: Set the tracking code as a one-liner
|
- name: Set the tracking code as a one-liner
|
||||||
set_fact:
|
set_fact:
|
||||||
matomo_tracking_code_one_liner: "{{ matomo_tracking_code | regex_replace('\\n', '') | regex_replace('\\s+', ' ') }}"
|
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
|
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):
|
class TestCspFilters(unittest.TestCase):
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
@ -24,6 +26,14 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
'unsafe_inline': True,
|
'unsafe_inline': True,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
'hashes': {
|
||||||
|
'script-src': [
|
||||||
|
"console.log('hello');",
|
||||||
|
],
|
||||||
|
'style-src': [
|
||||||
|
"body { background: #fff; }",
|
||||||
|
]
|
||||||
|
}
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
'app2': {}
|
'app2': {}
|
||||||
@ -62,8 +72,17 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
header = self.filter.build_csp_header(self.apps, 'app1', self.domains, web_protocol='https')
|
||||||
# Ensure core directives are present
|
# Ensure core directives are present
|
||||||
self.assertIn("default-src 'self';", header)
|
self.assertIn("default-src 'self';", header)
|
||||||
self.assertIn("script-src 'self' 'unsafe-eval' https://matomo.example.org https://cdn.example.com;", header)
|
# script-src directive should include unsafe-eval, Matomo domain and CDN (hash may follow)
|
||||||
self.assertIn("connect-src 'self' https://matomo.example.org https://api.example.com;", header)
|
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:;'))
|
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
||||||
|
|
||||||
def test_build_csp_header_without_matomo_or_flags(self):
|
def test_build_csp_header_without_matomo_or_flags(self):
|
||||||
@ -75,5 +94,42 @@ class TestCspFilters(unittest.TestCase):
|
|||||||
# ends with img-src
|
# ends with img-src
|
||||||
self.assertTrue(header.strip().endswith('img-src * data: blob:;'))
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
Loading…
x
Reference in New Issue
Block a user