From aa19a97ed62b7e79440e6ec324968e3e3e2d8fce Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Mon, 29 Sep 2025 12:23:58 +0200 Subject: [PATCH] CORS/CSP hardening & centralization - Add reusable Nginx include: roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2 (dynamic ACAO/credentials/methods/headers via role vars) - Set global 'Vary: Origin' in nginx.conf.j2 to prevent cache poisoning - CSP: allow Simple Icons via connect-src when feature is enabled - Front proxy: rename vars to lowercase + flush handlers after config deploy - Desktop: gate & load Simple Icons role; inject brand logos when enabled - Bluesky + Logout: replace inline CORS with centralized include - Simpleicons: public CORS (ACAO='*', no credentials), keep GET/OPTIONS, allow headers - Taiga: adjust canonical domain to taiga.kanban.{{ PRIMARY_DOMAIN }} - LibreTranslate: remove unused images/versions keys Fixes: https://open.project.infinito.nexus/projects/cymais/work_packages/342/activity Discussion: https://chatgpt.com/share/68da5e27-ffd4-800f-91a3-0ef103058d44 --- filter_plugins/csp_filters.py | 21 ++++++++++------ roles/sys-stk-front-proxy/tasks/01_base.yml | 11 +++++--- roles/sys-stk-front-proxy/vars/main.yml | 4 +-- .../headers/access_control_allow.conf.j2 | 25 +++++++++++++++++++ .../templates/vhost/basic.conf.j2 | 2 -- .../templates/nginx.conf.j2 | 5 ++++ .../templates/extra_locations.conf.j2 | 12 ++++----- roles/web-app-desktop/tasks/01_core.yml | 24 +++++++++--------- roles/web-app-desktop/vars/main.yml | 3 +++ roles/web-app-taiga/config/main.yml | 2 +- roles/web-svc-libretranslate/config/main.yml | 2 -- roles/web-svc-logout/tasks/01_core.yml | 5 ++++ .../templates/logout-proxy.conf.j2 | 13 ++++------ roles/web-svc-simpleicons/config/main.yml | 4 +-- roles/web-svc-simpleicons/tasks/main.yml | 4 +++ 15 files changed, 89 insertions(+), 48 deletions(-) create mode 100644 roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2 diff --git a/filter_plugins/csp_filters.py b/filter_plugins/csp_filters.py index a0af0180..3074ffde 100644 --- a/filter_plugins/csp_filters.py +++ b/filter_plugins/csp_filters.py @@ -158,26 +158,31 @@ class FilterModule(object): for directive in directives: tokens = ["'self'"] - # 1) Load flags (includes defaults from get_csp_flags) + # Load flags (includes defaults from get_csp_flags) flags = self.get_csp_flags(applications, application_id, directive) tokens += flags - # 2) Allow fetching from internal CDN by default for selected directives + # Allow fetching from internal CDN by default for selected directives if directive in ['script-src-elem', 'connect-src', 'style-src-elem']: tokens.append(get_url(domains, 'web-svc-cdn', web_protocol)) - # 3) Matomo integration if feature is enabled + # Matomo integration if feature is 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) ReCaptcha integration (scripts + frames) if feature is enabled + # Simpleicons integration if feature is enabled + if directive in ['connect-src']: + if self.is_feature_enabled(applications, 'simpleicons', application_id): + tokens.append(get_url(domains, 'web-svc-simpleicons', web_protocol)) + + # ReCaptcha integration (scripts + frames) if feature is enabled 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') - # 5) Frame ancestors handling (desktop + logout support) + # Frame ancestors handling (desktop + logout support) if directive == 'frame-ancestors': if self.is_feature_enabled(applications, 'desktop', application_id): # Allow being embedded by the desktop app domain (and potentially its parent) @@ -189,10 +194,10 @@ class FilterModule(object): tokens.append(get_url(domains, 'web-svc-logout', web_protocol)) tokens.append(get_url(domains, 'web-app-keycloak', web_protocol)) - # 6) Custom whitelist entries + # Custom whitelist entries tokens += self.get_csp_whitelist(applications, application_id, directive) - # 7) Add inline content hashes ONLY if final tokens do NOT include 'unsafe-inline' + # Add inline content hashes ONLY if final tokens do NOT include 'unsafe-inline' # (Check tokens, not flags, to include defaults and later modifications.) if "'unsafe-inline'" not in tokens: for snippet in self.get_csp_inline_content(applications, application_id, directive): @@ -201,7 +206,7 @@ class FilterModule(object): # Append directive parts.append(f"{directive} {' '.join(tokens)};") - # 8) Static img-src directive (kept permissive for data/blob and any host) + # Static img-src directive (kept permissive for data/blob and any host) parts.append("img-src * data: blob:;") return ' '.join(parts) diff --git a/roles/sys-stk-front-proxy/tasks/01_base.yml b/roles/sys-stk-front-proxy/tasks/01_base.yml index 3d4e7375..25dafdf0 100644 --- a/roles/sys-stk-front-proxy/tasks/01_base.yml +++ b/roles/sys-stk-front-proxy/tasks/01_base.yml @@ -6,10 +6,10 @@ include_role: name: sys-util-csp-cert -- name: "Copy nginx config to '{{ FRONT_PROXY_DOMAIN_CONF_DST }}'" +- name: "Copy nginx config to '{{ front_proxy_domain_conf_dst }}'" template: - src: "{{ FRONT_PROXY_DOMAIN_CONF_SRC }}" - dest: "{{ FRONT_PROXY_DOMAIN_CONF_DST }}" + src: "{{ front_proxy_domain_conf_src }}" + dest: "{{ front_proxy_domain_conf_dst }}" register: nginx_conf notify: restart openresty @@ -28,4 +28,7 @@ when: - site_check.status is defined - not site_check.status in [200,301,302] - when: not nginx_conf.changed \ No newline at end of file + when: not nginx_conf.changed + +- name: "Restart Webserver for '{{ front_proxy_domain_conf_dst }}'" + meta: flush_handlers \ No newline at end of file diff --git a/roles/sys-stk-front-proxy/vars/main.yml b/roles/sys-stk-front-proxy/vars/main.yml index 2665b22d..d086ae73 100644 --- a/roles/sys-stk-front-proxy/vars/main.yml +++ b/roles/sys-stk-front-proxy/vars/main.yml @@ -1,2 +1,2 @@ -FRONT_PROXY_DOMAIN_CONF_DST: "{{ [ NGINX.DIRECTORIES.HTTP.SERVERS, domain ~ '.conf'] | path_join }}" -FRONT_PROXY_DOMAIN_CONF_SRC: "roles/sys-svc-proxy/templates/vhost/{{ vhost_flavour }}.conf.j2" +front_proxy_domain_conf_dst: "{{ [ NGINX.DIRECTORIES.HTTP.SERVERS, domain ~ '.conf'] | path_join }}" +front_proxy_domain_conf_src: "roles/sys-svc-proxy/templates/vhost/{{ vhost_flavour }}.conf.j2" diff --git a/roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2 b/roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2 new file mode 100644 index 00000000..3ec50101 --- /dev/null +++ b/roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2 @@ -0,0 +1,25 @@ +# Configure CORS headers dynamically based on role variables. +# If no variable is defined, defaults are applied (e.g. same-origin). +# Discussion: https://chat.openai.com/share/2671b961-c1b0-472d-bae2-2804d0455e8a + +{# Access-Control-Allow-Origin #} +{% if aca_origin is defined %} +add_header 'Access-Control-Allow-Origin' {{ aca_origin }}; +{% else %} +add_header 'Access-Control-Allow-Origin' $scheme://$host always; +{% endif %} + +{# Access-Control-Allow-Credentials #} +{% if aca_credentials is defined %} +add_header 'Access-Control-Allow-Credentials' {{ aca_credentials }}; +{% endif %} + +{# Access-Control-Allow-Methods #} +{% if aca_methods is defined %} +add_header 'Access-Control-Allow-Methods' {{ aca_methods }}; +{% endif %} + +{# Access-Control-Allow-Headers #} +{% if aca_headers is defined %} +add_header 'Access-Control-Allow-Headers' {{ aca_headers }}; +{% endif %} diff --git a/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 b/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 index 57e00447..fc4fa7d2 100644 --- a/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 +++ b/roles/sys-svc-proxy/templates/vhost/basic.conf.j2 @@ -58,5 +58,3 @@ server {% endif %} } - - diff --git a/roles/sys-svc-webserver-core/templates/nginx.conf.j2 b/roles/sys-svc-webserver-core/templates/nginx.conf.j2 index fbe70b36..919d014b 100644 --- a/roles/sys-svc-webserver-core/templates/nginx.conf.j2 +++ b/roles/sys-svc-webserver-core/templates/nginx.conf.j2 @@ -13,6 +13,11 @@ http default_type text/html; + {# Ensure caches (browsers, proxies, CDNs) treat responses as dependent on the Origin header + to prevent cross-domain cache poisoning issues. + Discussion: https://chat.openai.com/share/2671b961-c1b0-472d-bae2-2804d0455e8a #} + add_header 'Vary' 'Origin' always; + {# caching #} proxy_cache_path {{ NGINX.DIRECTORIES.CACHE.GENERAL }} levels=1:2 keys_zone=cache:20m max_size=20g inactive=14d use_temp_path=off; proxy_cache_path {{ NGINX.DIRECTORIES.CACHE.IMAGE }} levels=1:2 keys_zone=imgcache:10m inactive=60m use_temp_path=off; diff --git a/roles/web-app-bluesky/templates/extra_locations.conf.j2 b/roles/web-app-bluesky/templates/extra_locations.conf.j2 index 2c14937e..4f871713 100644 --- a/roles/web-app-bluesky/templates/extra_locations.conf.j2 +++ b/roles/web-app-bluesky/templates/extra_locations.conf.j2 @@ -2,17 +2,17 @@ # Exposes a same-origin /config to avoid CORS when the social-app fetches config. location = /config { proxy_pass {{ BLUESKY_CONFIG_UPSTREAM_URL }}; - # Nur Hostname extrahieren: + + {# Just extract hostname #} set $up_host "{{ BLUESKY_CONFIG_UPSTREAM_URL | regex_replace('^https?://', '') | regex_replace('/.*$', '') }}"; proxy_set_header Host $up_host; proxy_http_version 1.1; proxy_set_header Connection ""; proxy_ssl_server_name on; - # Make response clearly same-origin for browsers + {# Access Control Allow Configurations #} proxy_hide_header Access-Control-Allow-Origin; - add_header Access-Control-Allow-Origin $scheme://$host always; - add_header Vary Origin always; + {% include 'roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2' %} } location = /ipcc { @@ -23,7 +23,7 @@ location = /ipcc { proxy_set_header Connection ""; proxy_ssl_server_name on; + {# Access Control Allow Configurations #} proxy_hide_header Access-Control-Allow-Origin; - add_header Access-Control-Allow-Origin $scheme://$host always; - add_header Vary Origin always; + {% include 'roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2' %} } diff --git a/roles/web-app-desktop/tasks/01_core.yml b/roles/web-app-desktop/tasks/01_core.yml index e52101c4..ba3f098c 100644 --- a/roles/web-app-desktop/tasks/01_core.yml +++ b/roles/web-app-desktop/tasks/01_core.yml @@ -1,3 +1,12 @@ +- name: "Load brand logos role for '{{ application_id }}'" + include_role: + name: web-svc-simpleicons + vars: + flush_handlers: true + when: + - run_once_web_svc_simpleicons is not defined + - DESKTOP_SIMPLEICONS_ENABLED | bool + - name: "Validate configuration" include_tasks: "02_validate.yml" when: MODE_ASSERT | bool @@ -24,25 +33,16 @@ set_fact: portfolio_cards: "{{ lookup('docker_cards', 'roles') }}" -- name: "Load images for applications feature simpleicons is enabled " +- name: "Load Desktop Brand logos" set_fact: portfolio_cards: "{{ portfolio_cards | add_simpleicon_source(domains, WEB_PROTOCOL) }}" - when: - - (applications | get_app_conf(application_id, 'features.simpleicons', False)) + when: DESKTOP_SIMPLEICONS_ENABLED | bool + changed_when: false - name: Group docker cards set_fact: portfolio_menu_data: "{{ lookup('docker_cards_grouped', portfolio_cards, portfolio_menu_categories) }}" -- name: Debug portfolio data - debug: - msg: - portfolio_cards: "{{ portfolio_cards }}" - portfolio_menu_categories: "{{ portfolio_menu_categories}}" - portfolio_menu_data: "{{ portfolio_menu_data }}" - service_provider: "{{ service_provider }}" - when: MODE_DEBUG | bool - - name: Copy host-specific config.yaml if it exists template: src: "{{ DESKTOP_CONFIG_INV_PATH }}" diff --git a/roles/web-app-desktop/vars/main.yml b/roles/web-app-desktop/vars/main.yml index 1cf267c9..167041ab 100644 --- a/roles/web-app-desktop/vars/main.yml +++ b/roles/web-app-desktop/vars/main.yml @@ -10,6 +10,9 @@ docker_pull_git_repository: true # Desktop +## Simpleicons +DESKTOP_SIMPLEICONS_ENABLED: "{{ applications | get_app_conf(application_id, 'features.simpleicons') }}" + ## Javascript DESKTOP_JS_CDN_URL: "{{ domains | get_url('web-svc-cdn', WEB_PROTOCOL) }}" DESKTOP_JS_FILES: ['iframe.js','oidc.js'] diff --git a/roles/web-app-taiga/config/main.yml b/roles/web-app-taiga/config/main.yml index ac20b2c0..6680bddf 100644 --- a/roles/web-app-taiga/config/main.yml +++ b/roles/web-app-taiga/config/main.yml @@ -31,4 +31,4 @@ server: unsafe-eval: true domains: canonical: - - "kanban.project.{{ PRIMARY_DOMAIN }}" + - "taiga.kanban.{{ PRIMARY_DOMAIN }}" diff --git a/roles/web-svc-libretranslate/config/main.yml b/roles/web-svc-libretranslate/config/main.yml index 304361cf..592515c8 100644 --- a/roles/web-svc-libretranslate/config/main.yml +++ b/roles/web-svc-libretranslate/config/main.yml @@ -1,8 +1,6 @@ credentials: {} docker: - images: {} # @todo Move under services - versions: {} # @todo Move under services services: redis: enabled: false # Enable Redis diff --git a/roles/web-svc-logout/tasks/01_core.yml b/roles/web-svc-logout/tasks/01_core.yml index 9a4111e1..5afea29f 100644 --- a/roles/web-svc-logout/tasks/01_core.yml +++ b/roles/web-svc-logout/tasks/01_core.yml @@ -21,6 +21,11 @@ - name: "load docker, proxy for '{{ application_id }}'" include_role: name: sys-stk-full-stateless + vars: + aca_origin: "'{{ domains | get_url('web-svc-logout', WEB_PROTOCOL) }}' always" + aca_credentials: "'true' always" + aca_methods: "'GET, OPTIONS' always" + aca_headers: "'Accept, Authorization' always" - name: Create symbolic link from .env file to repository file: diff --git a/roles/web-svc-logout/templates/logout-proxy.conf.j2 b/roles/web-svc-logout/templates/logout-proxy.conf.j2 index bb20cab8..de65db69 100644 --- a/roles/web-svc-logout/templates/logout-proxy.conf.j2 +++ b/roles/web-svc-logout/templates/logout-proxy.conf.j2 @@ -1,5 +1,5 @@ location = /logout { - # Proxy to the logout service + {# Proxy to the logout service #} proxy_pass http://127.0.0.1:{{ ports.localhost.http['web-svc-logout'] }}/logout; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; @@ -7,18 +7,15 @@ location = /logout { proxy_set_header X-Forwarded-Proto $scheme; proxy_http_version 1.1; - # CORS headers – allow your central page to call this - add_header 'Access-Control-Allow-Origin' '{{ domains | get_url('web-svc-logout', WEB_PROTOCOL) }}' always; - add_header 'Access-Control-Allow-Credentials' 'true' always; - add_header 'Access-Control-Allow-Methods' 'GET, OPTIONS' always; - add_header 'Access-Control-Allow-Headers' 'Accept, Authorization' always; + {# CORS headers – allow your central page to call this #} + {% include 'roles/sys-svc-proxy/templates/headers/access_control_allow.conf.j2' %} - # Disable caching absolutely + {# Disable caching absolutely #} add_header Cache-Control "no-store, no-cache, must-revalidate, proxy-revalidate, max-age=0" always; add_header Pragma "no-cache" always; add_header Expires "0" always; - # Handle preflight + {# Handle preflight #} if ($request_method = OPTIONS) { return 204; } diff --git a/roles/web-svc-simpleicons/config/main.yml b/roles/web-svc-simpleicons/config/main.yml index 23a8c731..14895990 100644 --- a/roles/web-svc-simpleicons/config/main.yml +++ b/roles/web-svc-simpleicons/config/main.yml @@ -1,8 +1,6 @@ credentials: {} docker: - images: {} # @todo Move under services - versions: {} # @todo Move under services services: redis: enabled: false # Enable Redis @@ -11,7 +9,7 @@ docker: features: matomo: false # Matomo tracking isn't necessary css: true # Enable Global CSS Styling - desktop: true # Enable loading of app in iframe + desktop: true # Enable loading of app in iframe ldap: false # Enable LDAP Network central_database: false # Enable Central Database Network recaptcha: false # Enable ReCaptcha diff --git a/roles/web-svc-simpleicons/tasks/main.yml b/roles/web-svc-simpleicons/tasks/main.yml index df8de367..56fe5e72 100644 --- a/roles/web-svc-simpleicons/tasks/main.yml +++ b/roles/web-svc-simpleicons/tasks/main.yml @@ -3,6 +3,10 @@ - name: "load docker, proxy for '{{ application_id }}'" include_role: name: sys-stk-full-stateless + vars: + aca_origin: "* always" + aca_methods: "'GET, OPTIONS' always" + aca_headers: "'Accept, Authorization, Content-Type' always" - name: "Copy '{{ application_id }}' files" template: