From c2a181edd4b66b5bdd904d9e4784c357b48bb1e9 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Fri, 28 Nov 2025 20:06:58 +0100 Subject: [PATCH] Add generic hCaptcha CSP support and tests (ref: ChatGPT conversation https://chatgpt.com/share/6929f2ba-cedc-800f-9c4c-2049810cea94) --- filter_plugins/csp_filters.py | 26 ++++++++----- .../templates/vhost/ws_generic.conf.j2 | 2 +- roles/web-app-espocrm/config/main.yml | 3 +- roles/web-app-listmonk/config/main.yml | 7 +--- roles/web-app-nextcloud/config/main.yml | 18 ++++----- roles/web-svc-logout/tasks/01_core.yml | 1 + tests/unit/filter_plugins/test_csp_filters.py | 39 +++++++++++++++++++ 7 files changed, 69 insertions(+), 27 deletions(-) diff --git a/filter_plugins/csp_filters.py b/filter_plugins/csp_filters.py index 06104d46..6ba2c45e 100644 --- a/filter_plugins/csp_filters.py +++ b/filter_plugins/csp_filters.py @@ -202,31 +202,39 @@ class FilterModule(object): tokens = ["'self'"] - # 1) Flags (with sane defaults) + # Flags (with sane defaults) flags = self.get_csp_flags(applications, application_id, directive) tokens += flags - # 2) Internal CDN defaults for selected directives + # Internal CDN defaults for selected directives if directive in ('script-src-elem', 'connect-src', 'style-src-elem', 'style-src'): tokens.append(get_url(domains, 'web-svc-cdn', web_protocol)) - # 3) Matomo (if enabled) + # Matomo (if enabled) if directive in ('script-src-elem', 'connect-src'): if self.is_feature_enabled(applications, matomo_feature_name, application_id): tokens.append(get_url(domains, 'web-app-matomo', web_protocol)) - # 4) Simpleicons (if enabled) – typically used via connect-src (fetch) + # Simpleicons (if enabled) – typically used via connect-src (fetch) if directive == 'connect-src': if self.is_feature_enabled(applications, 'simpleicons', application_id): tokens.append(get_url(domains, 'web-svc-simpleicons', web_protocol)) - # 5) reCAPTCHA (if enabled) – scripts + frames + # reCAPTCHA (if enabled) – scripts + frames if self.is_feature_enabled(applications, 'recaptcha', application_id): if directive in ('script-src-elem', 'frame-src'): tokens.append('https://www.gstatic.com') tokens.append('https://www.google.com') + + # hCaptcha (if enabled) – scripts + frames + if self.is_feature_enabled(applications, 'hcaptcha', application_id): + if directive in ('script-src-elem'): + tokens.append('https://www.hcaptcha.com') + tokens.append('https://js.hcaptcha.com') + if directive in ('frame-src'): + tokens.append('https://newassets.hcaptcha.com/') - # 6) Frame ancestors (desktop + logout) + # Frame ancestors (desktop + logout) if directive == 'frame-ancestors': if self.is_feature_enabled(applications, 'desktop', application_id): # Allow being embedded by the desktop app domain's site @@ -237,16 +245,16 @@ class FilterModule(object): tokens.append(get_url(domains, 'web-svc-logout', web_protocol)) tokens.append(get_url(domains, 'web-app-keycloak', web_protocol)) - # 6b) Logout support requires inline handlers (script-src-attr) + # Logout support requires inline handlers (script-src-attr) if directive in ('script-src-attr','script-src-elem'): if self.is_feature_enabled(applications, 'logout', application_id): tokens.append("'unsafe-inline'") - # 7) Custom whitelist + # Custom whitelist tokens += self.get_csp_whitelist(applications, application_id, directive) - # 8) Inline hashes (only if this directive does NOT include 'unsafe-inline') + # Inline hashes (only if this directive does NOT include 'unsafe-inline') if "'unsafe-inline'" not in tokens: for snippet in self.get_csp_inline_content(applications, application_id, directive): tokens.append(self.get_csp_hash(snippet)) diff --git a/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 b/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 index 4fca3871..34e9ae84 100644 --- a/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 +++ b/roles/sys-svc-proxy/templates/vhost/ws_generic.conf.j2 @@ -22,7 +22,7 @@ server { {% include 'roles/sys-svc-proxy/templates/location/html.conf.j2' %} - {% if location_ws is defined %} + {% if location_ws | default(false) %} {% include 'roles/sys-svc-proxy/templates/location/ws.conf.j2' %} {% endif %} diff --git a/roles/web-app-espocrm/config/main.yml b/roles/web-app-espocrm/config/main.yml index 46082591..f0dfd365 100644 --- a/roles/web-app-espocrm/config/main.yml +++ b/roles/web-app-espocrm/config/main.yml @@ -6,6 +6,7 @@ features: oidc: true central_database: true logout: true + recaptcha: true # Required for leads formulars server: csp: flags: @@ -16,7 +17,7 @@ server: unsafe-eval: true whitelist: connect-src: - - "{{ WEBSOCKET_PROTOCOL }}://espocrm.{{ PRIMARY_DOMAIN }}" + - "{{ WEBSOCKET_PROTOCOL }}://espo.crm.{{ PRIMARY_DOMAIN }}" - "data:" frame-src: - https://s.espocrm.com/ diff --git a/roles/web-app-listmonk/config/main.yml b/roles/web-app-listmonk/config/main.yml index c1782f62..fc905937 100644 --- a/roles/web-app-listmonk/config/main.yml +++ b/roles/web-app-listmonk/config/main.yml @@ -6,6 +6,7 @@ features: central_database: true oidc: true logout: true + hcaptcha: true server: domains: canonical: @@ -17,12 +18,6 @@ server: flags: script-src-elem: unsafe-inline: true - whitelist: - script-src-elem: - - "https://www.hcaptcha.com" - - "https://js.hcaptcha.com" - frame-src: - - "https://newassets.hcaptcha.com/" docker: services: database: diff --git a/roles/web-app-nextcloud/config/main.yml b/roles/web-app-nextcloud/config/main.yml index 458212f0..fe1dea83 100644 --- a/roles/web-app-nextcloud/config/main.yml +++ b/roles/web-app-nextcloud/config/main.yml @@ -11,8 +11,6 @@ server: unsafe-inline: true # Required for ONLYOFFICE whitelist: script-src-elem: - - "https://www.hcaptcha.com" - - "https://js.hcaptcha.com" - "{{ WEB_PROTOCOL }}://onlyoffice.{{ PRIMARY_DOMAIN }}" font-src: - "data:" @@ -27,7 +25,6 @@ server: - "{{ WEBSOCKET_PROTOCOL }}://collabora.{{ PRIMARY_DOMAIN }}" - "{{ WEB_PROTOCOL }}://onlyoffice.{{ PRIMARY_DOMAIN }}" - "{{ WEB_PROTOCOL }}://collabora.{{ PRIMARY_DOMAIN }}" - - "https://newassets.hcaptcha.com/" - "*" # Required to load all external websites in Whiteboard worker-src: - "blob:" @@ -123,13 +120,14 @@ docker: # @see https://apps.nextcloud.com/apps/sociallogin flavor: "oidc_login" # Keeping on sociallogin because the other option is not implemented yet features: - matomo: true - css: false - desktop: true - ldap: true - oidc: true - central_database: true - logout: true + matomo: true + css: false + desktop: true + ldap: true + oidc: true + central_database: true + logout: true + hcaptcha: true default_quota: '1000000000' # Quota to assign if no quota is specified in the OIDC response (bytes) legacy_login_mask: enabled: False # If true, then legacy login mask is shown. Otherwise just SSO diff --git a/roles/web-svc-logout/tasks/01_core.yml b/roles/web-svc-logout/tasks/01_core.yml index 008d1780..79f04dec 100644 --- a/roles/web-svc-logout/tasks/01_core.yml +++ b/roles/web-svc-logout/tasks/01_core.yml @@ -24,6 +24,7 @@ vars: # Necessary to overwrite parent values client_max_body_size: "10M" + location_ws: "" - name: Create symbolic link from .env file to repository file: diff --git a/tests/unit/filter_plugins/test_csp_filters.py b/tests/unit/filter_plugins/test_csp_filters.py index 5fe8f3c4..78c1f6e0 100644 --- a/tests/unit/filter_plugins/test_csp_filters.py +++ b/tests/unit/filter_plugins/test_csp_filters.py @@ -564,6 +564,45 @@ class TestCspFilters(unittest.TestCase): self.assertIn("'unsafe-inline'", base_tokens) self.assertIn("'unsafe-inline'", attr_tokens) self.assertIn("'unsafe-inline'", elem_tokens) + + def test_build_csp_header_hcaptcha_toggle(self): + """ + When the 'hcaptcha' feature is enabled, the CSP must include + the hCaptcha script and frame hosts. When disabled, they must + not appear in any directive. + """ + # enabled case + self.apps['app1'].setdefault('features', {})['hcaptcha'] = True + header_enabled = self.filter.build_csp_header( + self.apps, 'app1', self.domains, web_protocol='https' + ) + + # script-src-elem must contain hCaptcha hosts + script_elem_tokens = self._get_directive_tokens(header_enabled, 'script-src-elem') + self.assertIn("https://www.hcaptcha.com", script_elem_tokens) + self.assertIn("https://js.hcaptcha.com", script_elem_tokens) + + # base script-src must also include them (family union) + script_base_tokens = self._get_directive_tokens(header_enabled, 'script-src') + self.assertIn("https://www.hcaptcha.com", script_base_tokens) + self.assertIn("https://js.hcaptcha.com", script_base_tokens) + + # frame-src must contain the hCaptcha assets host + frame_tokens = self._get_directive_tokens(header_enabled, 'frame-src') + self.assertIn("https://newassets.hcaptcha.com/", frame_tokens) + + # disabled case + self.apps['app1']['features']['hcaptcha'] = False + header_disabled = self.filter.build_csp_header( + self.apps, 'app1', self.domains, web_protocol='https' + ) + + for directive in ('script-src', 'script-src-elem', 'frame-src'): + tokens = self._get_directive_tokens(header_disabled, directive) + self.assertNotIn("https://www.hcaptcha.com", tokens) + self.assertNotIn("https://js.hcaptcha.com", tokens) + self.assertNotIn("https://newassets.hcaptcha.com/", tokens) + if __name__ == '__main__': unittest.main()