mirror of
https://github.com/kevinveenbirkenbach/computer-playbook.git
synced 2025-06-25 11:45:32 +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, {})
|
app = applications.get(application_id, {})
|
||||||
return bool(app.get('features', {}).get(feature, False))
|
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:
|
def get_docker_compose(path_docker_compose_instances: str, application_id: str) -> dict:
|
||||||
"""
|
"""
|
||||||
Build the docker_compose dict based on
|
Build the docker_compose dict based on
|
||||||
@ -58,7 +30,5 @@ class FilterModule(object):
|
|||||||
def filters(self):
|
def filters(self):
|
||||||
return {
|
return {
|
||||||
'is_feature_enabled': is_feature_enabled,
|
'is_feature_enabled': is_feature_enabled,
|
||||||
'get_csp_whitelist': get_csp_whitelist,
|
|
||||||
'get_csp_flags': get_csp_flags,
|
|
||||||
'get_docker_compose': get_docker_compose,
|
'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}")
|
@ -1,11 +1,19 @@
|
|||||||
network: "discourse_default" # Name of the docker network
|
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.
|
||||||
|
@ -12,4 +12,8 @@ features:
|
|||||||
landingpage_iframe: false
|
landingpage_iframe: false
|
||||||
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}}"
|
||||||
|
@ -13,4 +13,9 @@ csp:
|
|||||||
- https://cdnjs.cloudflare.com
|
- https://cdnjs.cloudflare.com
|
||||||
- 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
|
||||||
|
@ -1,45 +1,2 @@
|
|||||||
{# Create a namespace to hold the accumulated CSP parts #}
|
add_header Content-Security-Policy "{{ applications | build_csp_header(application_id, domains) }}" always;
|
||||||
{% 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;
|
|
||||||
proxy_hide_header Content-Security-Policy;
|
proxy_hide_header Content-Security-Policy;
|
@ -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
|
||||||
|
@ -3,8 +3,6 @@
|
|||||||
import unittest
|
import unittest
|
||||||
from filter_plugins.configuration_filters import (
|
from filter_plugins.configuration_filters import (
|
||||||
is_feature_enabled,
|
is_feature_enabled,
|
||||||
get_csp_whitelist,
|
|
||||||
get_csp_flags,
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@ -16,25 +14,6 @@ class TestConfigurationFilters(unittest.TestCase):
|
|||||||
'features': {
|
'features': {
|
||||||
'oauth2': True,
|
'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': {
|
'app2': {
|
||||||
# no features or csp defined
|
# no features or csp defined
|
||||||
@ -51,42 +30,5 @@ class TestConfigurationFilters(unittest.TestCase):
|
|||||||
def test_is_feature_enabled_false_missing_app(self):
|
def test_is_feature_enabled_false_missing_app(self):
|
||||||
self.assertFalse(is_feature_enabled(self.applications, 'oauth2', 'unknown_app'))
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.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