Add generic hCaptcha CSP support and tests (ref: ChatGPT conversation https://chatgpt.com/share/6929f2ba-cedc-800f-9c4c-2049810cea94)

This commit is contained in:
2025-11-28 20:06:58 +01:00
parent 2132356f02
commit c2a181edd4
7 changed files with 69 additions and 27 deletions

View File

@@ -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')
# 6) Frame ancestors (desktop + logout)
# 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/')
# 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))

View File

@@ -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 %}

View File

@@ -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/

View File

@@ -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:

View File

@@ -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:"
@@ -130,6 +127,7 @@ features:
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

View File

@@ -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:

View File

@@ -565,5 +565,44 @@ class TestCspFilters(unittest.TestCase):
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()