From 84a99fc2dffbd66ee2b2ec0e72274c4d9a0e901b 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 (cherry picked from commit 41250d97d114321469cfc2ae3cb086379f5e04ad) --- 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 b7fc9cd2..71fadf76 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 2676b021..dede8b31 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 7afcdb8e..f325fe8a 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.assertEquals(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': True, - 'hsts_max_age_seconds': 0, - 'custom_theme': False}) + self.assertEquals( + 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.assertEquals(horizon_contexts.ApacheContext()(), - {'http_port': 70, 'https_port': 433, - 'enforce_ssl': False, - 'hsts_max_age_seconds': 0, - 'custom_theme': False}) + self.assertEquals( + 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.assertEquals(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()(),