From 41250d97d114321469cfc2ae3cb086379f5e04ad Mon Sep 17 00:00:00 2001 From: Jorge Merlino Date: Sat, 26 Jul 2025 17:58:07 -0300 Subject: [PATCH] Add support for Content-Security-Policy header Adding a configuration parameter csp-options that, when set, adds a Content-Security-Policy header to the apache configuration. This header can prevent or minimize the risk of certain types of security threats by placing restrictions on the things the web page's code can do. Closes-Bug: #2118835 Change-Id: I06f0b1c2787fa56460e5a196d3ca07c0a85c14e3 Signed-off-by: Jorge Merlino --- config.yaml | 7 +++ hooks/horizon_contexts.py | 1 + templates/default | 3 ++ templates/default-ssl | 3 ++ unit_tests/test_horizon_contexts.py | 69 ++++++++++++++++++++--------- 5 files changed, 63 insertions(+), 20 deletions(-) diff --git a/config.yaml b/config.yaml index 87121d07..f3176f10 100644 --- a/config.yaml +++ b/config.yaml @@ -331,6 +331,13 @@ options: . For this option to have an effect, SSL must be configured and enforce-ssl option must be true. + csp-options: + type: string + default: "frame-ancestors 'self'; form-action 'self';" + description: | + Options for the CSP (Content Security Policy) header. This header allows to + control which resources the user agent is allowed to load. For more details + on CSP refer to: https://developer.mozilla.org/docs/Web/HTTP/Guides/CSP database-user: type: string default: horizon diff --git a/hooks/horizon_contexts.py b/hooks/horizon_contexts.py index e3a7f7dd..b78f786f 100644 --- a/hooks/horizon_contexts.py +++ b/hooks/horizon_contexts.py @@ -345,6 +345,7 @@ class ApacheContext(OSContextGenerator): 'enforce_ssl': False, 'hsts_max_age_seconds': config('hsts-max-age-seconds'), "custom_theme": config('custom-theme'), + 'csp_options': config('csp-options'), } if config('enforce-ssl'): diff --git a/templates/default b/templates/default index f5cac563..8bffebb7 100644 --- a/templates/default +++ b/templates/default @@ -37,4 +37,7 @@ KeepAliveTimeout 75 MaxKeepAliveRequests 1000 Header set X-Frame-Options: "sameorigin" +{% if csp_options %} + Header set Content-Security-Policy "{{ csp_options }}" +{% endif %} diff --git a/templates/default-ssl b/templates/default-ssl index e34e2b19..b3983ea6 100644 --- a/templates/default-ssl +++ b/templates/default-ssl @@ -52,6 +52,9 @@ NameVirtualHost *:{{ 443 }} Header set Strict-Transport-Security "max-age={{ hsts_max_age_seconds }}" # NOTE(ajkavanagh) due to Bug 1853173 the cookie can't be secure at this time, so disabling until a fix is found. # Header edit Set-Cookie ^(.*)$ $1;HttpOnly;Secure +{% endif %} +{% if csp_options %} + Header set Content-Security-Policy "{{ csp_options }}" {% endif %} Header set X-XSS-Protection "1; mode=block" Header set X-Content-Type-Options "nosniff" diff --git a/unit_tests/test_horizon_contexts.py b/unit_tests/test_horizon_contexts.py index dd6fc923..231258ff 100644 --- a/unit_tests/test_horizon_contexts.py +++ b/unit_tests/test_horizon_contexts.py @@ -74,39 +74,68 @@ class TestHorizonContexts(CharmTestCase): self.pwgen.return_value = "secret" def test_Apachecontext(self): - self.assertEqual(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': False, - 'hsts_max_age_seconds': 0, - 'custom_theme': False}) + self.assertEqual( + horizon_contexts.ApacheContext()(), + {'http_port': 70, 'https_port': 433, + 'enforce_ssl': False, + 'hsts_max_age_seconds': 0, + 'csp_options': "frame-ancestors 'self'; form-action 'self';", + 'custom_theme': False}, + ) def test_Apachecontext_enforce_ssl(self): self.test_config.set('enforce-ssl', True) self.https.return_value = True - self.assertEqual(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': True, - 'hsts_max_age_seconds': 0, - 'custom_theme': False}) + self.assertEqual( + horizon_contexts.ApacheContext()(), + {'http_port': 70, 'https_port': 433, + 'enforce_ssl': True, + 'hsts_max_age_seconds': 0, + 'csp_options': "frame-ancestors 'self'; form-action 'self';", + 'custom_theme': False}, + ) def test_Apachecontext_enforce_ssl_no_cert(self): self.test_config.set('enforce-ssl', True) self.https.return_value = False - self.assertEqual(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': False, - 'hsts_max_age_seconds': 0, - 'custom_theme': False}) + self.assertEqual( + horizon_contexts.ApacheContext()(), + {'http_port': 70, 'https_port': 433, + 'enforce_ssl': False, + 'hsts_max_age_seconds': 0, + 'csp_options': "frame-ancestors 'self'; form-action 'self';", + 'custom_theme': False}, + ) def test_Apachecontext_hsts_max_age_seconds(self): self.test_config.set('enforce-ssl', True) self.https.return_value = True self.test_config.set('hsts-max-age-seconds', 15768000) - self.assertEqual(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': True, - 'hsts_max_age_seconds': 15768000, - 'custom_theme': False}) + self.assertEqual( + horizon_contexts.ApacheContext()(), + {'http_port': 70, 'https_port': 433, + 'enforce_ssl': True, + 'hsts_max_age_seconds': 15768000, + 'csp_options': "frame-ancestors 'self'; form-action 'self';", + 'custom_theme': False}, + ) + + def test_Apachecontext_csp_options(self): + self.https.return_value = True + self.test_config.set( + 'csp-options', + "default-src https: 'unsafe-eval'; object-src 'none'", + ) + self.assertEqual( + horizon_contexts.ApacheContext()(), + {'http_port': 70, + 'https_port': 433, + 'enforce_ssl': False, + 'hsts_max_age_seconds': 0, + 'csp_options': + "default-src https: 'unsafe-eval'; object-src 'none'", + 'custom_theme': False}, + ) def test_HorizonContext_defaults(self): self.assertEqual(horizon_contexts.HorizonContext()(),