From 44aaa68924eb41bee54feaf0b0ff93a9e7617fa0 Mon Sep 17 00:00:00 2001 From: Kevin Veen-Birkenbach Date: Wed, 28 Oct 2026 17:21:53 +0100 Subject: [PATCH] refactor(tls): add url.origin and use it wherever url.base lost its trailing slash lookup('tls', app, 'url.base') always ends in '/', and 39 call sites in 25 files rebuilt the slash-less form by hand with regex_replace('/$', ''), regex_replace('/+$', ''), trim('/') or .rstrip('/'), directly or on a variable holding url.base. The lookup now returns that value as url.origin (scheme://host, url.base minus its final '/'), and every call site reads it; two sites that stripped the slash only to append it again now take url.base as is, and GITLAB_URL and DASHBOARD_JS_CDN_URL lost their last consumer. test_url_base_no_double_slash gains the rule url-base-strip, so url.origin stays the only way to the slash-less URL; it flags all nine HEAD sites probed and none after the change. The same lint keeps url-base-double-slash and url-base-addon-value, which put the nine Nextcloud integration URLs, the Mattermost and SuiteCRM facts and the XWiki instance on the slash-less form, drop the stray '/' before Joomla's index.php callback, and give BBB its api.url as [base, suffix] | url_join plus the trailing '/' its client appends 'api/' to; bbb.spec.js now requires exactly that form. Co-Authored-By: Claude Opus 5.5 (1M context) --- group_vars/all/11_oidc.yml | 3 +- plugins/lookup/tls.py | 5 +- .../templates/body_filter.lua.j2 | 4 +- .../templates/javascript/oidc.js.j2 | 4 +- roles/web-app-dashboard/vars/main.yml | 5 +- .../templates/playwright.env.j2 | 4 +- .../web-app-erpnext/templates/compose.yml.j2 | 2 +- roles/web-app-friendica/vars/main.yml | 2 +- roles/web-app-gitlab/vars/main.yml | 3 +- roles/web-app-jenkins/templates/casc.yaml.j2 | 2 +- .../meta/addons/plg_system_keycloak.yml | 2 +- .../templates/logout-panel.js.j2 | 2 +- roles/web-app-keycloak/vars/main.yml | 2 +- roles/web-app-moodle/templates/env.j2 | 2 +- .../files/playwright/addons/bbb.spec.js | 4 +- roles/web-app-nextcloud/meta/addons/bbb.yml | 2 +- .../meta/addons/integration_discourse.yml | 2 +- .../meta/addons/integration_gitlab.yml | 2 +- .../meta/addons/integration_mastodon.yml | 2 +- .../meta/addons/integration_matrix.yml | 2 +- .../meta/addons/integration_mattermost.yml | 2 +- .../meta/addons/integration_moodle.yml | 2 +- .../meta/addons/integration_openai.yml | 2 +- .../meta/addons/integration_suitecrm.yml | 2 +- .../meta/addons/integration_zammad.yml | 2 +- .../meta/addons/richdocuments.yml | 2 +- roles/web-app-nextcloud/meta/addons/xwiki.yml | 2 +- .../tasks/addons/integration_gitlab.yml | 2 +- .../tasks/addons/integration_mattermost.yml | 2 +- .../tasks/addons/integration_suitecrm.yml | 2 +- .../tasks/addons/integration_zammad.yml | 4 +- roles/web-app-nextcloud/vars/main.yml | 2 +- roles/web-app-openbao/vars/main.yml | 2 +- roles/web-app-opencloud/vars/main.yml | 2 +- roles/web-app-opentalk/vars/main.yml | 2 +- roles/web-app-semaphore/templates/env.j2 | 2 +- roles/web-app-semaphore/vars/main.yml | 2 +- roles/web-app-snipe-it/templates/env.j2 | 2 +- roles/web-app-stalwart/meta/csp.yml | 2 +- .../templates/keycloak/clients.json.j2 | 2 +- .../templates/logout-proxy.conf.j2 | 2 +- .../jinja/test_url_base_no_double_slash.py | 225 ++++++++++++++++++ tests/unit/python/plugins/lookup/test_tls.py | 10 + .../test_logout_status_panel.py | 12 +- 44 files changed, 291 insertions(+), 56 deletions(-) create mode 100644 tests/lint/ansible/jinja/test_url_base_no_double_slash.py diff --git a/group_vars/all/11_oidc.yml b/group_vars/all/11_oidc.yml index a6274d461b..3fd81c544a 100644 --- a/group_vars/all/11_oidc.yml +++ b/group_vars/all/11_oidc.yml @@ -14,8 +14,7 @@ # Helper Variables: _oidc_client_realm: "{{ OIDC_OVERRIDE.CLIENT.REALM if OIDC_OVERRIDE is defined and OIDC_OVERRIDE.CLIENT is defined and OIDC_OVERRIDE.CLIENT.REALM is defined else SOFTWARE_DOMAIN }}" -_oidc_url: "{{ ( OIDC_OVERRIDE.URL if (OIDC_OVERRIDE is defined and OIDC_OVERRIDE.URL is defined) else (lookup('tls', 'web-app-keycloak', 'url.base')) ).rstrip('/') - }}" +_oidc_url: "{{ OIDC_OVERRIDE.URL.rstrip('/') if (OIDC_OVERRIDE is defined and OIDC_OVERRIDE.URL is defined) else lookup('tls', 'web-app-keycloak', 'url.origin') }}" _oidc_client_issuer_url: "{{ _oidc_url ~ '/realms/' ~ _oidc_client_realm }}" _oidc_client_id: "{{ OIDC_OVERRIDE.CLIENT.ID if OIDC_OVERRIDE is defined and OIDC_OVERRIDE.CLIENT is defined and OIDC_OVERRIDE.CLIENT.ID is defined else SOFTWARE_DOMAIN }}" diff --git a/plugins/lookup/tls.py b/plugins/lookup/tls.py index 24d8f2a6bd..06f21d2b1c 100644 --- a/plugins/lookup/tls.py +++ b/plugins/lookup/tls.py @@ -101,7 +101,8 @@ class LookupModule(LookupBase): web_protocol = "https" if enabled else "http" websocket_protocol = "wss" if enabled else "ws" web_port = 443 if enabled else 80 - base_url = f"{web_protocol}://{primary_domain}/" + origin = f"{web_protocol}://{primary_domain}" + base_url = f"{origin}/" resolved: dict[str, Any] = { "application_id": app_id, @@ -111,7 +112,7 @@ class LookupModule(LookupBase): "domains": {"primary": primary_domain, "all": all_domains}, "protocols": {"web": web_protocol, "websocket": websocket_protocol}, "ports": {"web": web_port}, - "url": {"base": base_url}, + "url": {"base": base_url, "origin": origin}, } if want: diff --git a/roles/sys-front-inj-all/templates/body_filter.lua.j2 b/roles/sys-front-inj-all/templates/body_filter.lua.j2 index 479872e5bb..44d66c2c10 100644 --- a/roles/sys-front-inj-all/templates/body_filter.lua.j2 +++ b/roles/sys-front-inj-all/templates/body_filter.lua.j2 @@ -34,7 +34,7 @@ ngx.ctx.buf = nil {% if _mirror_tor_enabled %} {% set _mirror_origins = lookup('applications') | external_asset_origins(application_id, DOMAIN_PRIMARY) %} {% set _mirror_tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %} -{% set _mirror_onion = lookup('tls', 'web-svc-mirror', 'url.base') | regex_replace('/+$', '') | to_onion_url(_mirror_tor_node, DOMAIN_PRIMARY) %} +{% set _mirror_onion = lookup('tls', 'web-svc-mirror', 'url.origin') | to_onion_url(_mirror_tor_node, DOMAIN_PRIMARY) %} {% if _mirror_origins %} -- Onion sessions: rewrite external asset origins to the local mirror (privacy proxy) do @@ -54,7 +54,7 @@ if ngx.ctx.is_css then ngx.arg[1] = whole return end -{% set _cdn_base = lookup('tls', 'web-svc-cdn', 'url.base') | regex_replace('/+$', '') %} +{% set _cdn_base = lookup('tls', 'web-svc-cdn', 'url.origin') %} {% set _tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %} {% set _cdn_onion = _cdn_base | to_onion_url(_tor_node, DOMAIN_PRIMARY) %} local function onionize(s) diff --git a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 index 52b28a94a4..763839d13e 100644 --- a/roles/web-app-dashboard/templates/javascript/oidc.js.j2 +++ b/roles/web-app-dashboard/templates/javascript/oidc.js.j2 @@ -12,13 +12,13 @@ const KC_CONFIG = { realm: "{{ OIDC.CLIENT.REALM }}", // e.g. cymais.cloud clientId: "{{ OIDC.CLIENT.ID }}", // e.g. cymais.cloud redirectUri: "{{ DASHBOARD_APP_BASE_URL }}", // canonical app URL for login/logout round-trips - silentCheckSsoRedirectUri: "{{ (DASHBOARD_APP_BASE_URL | regex_replace('/+$', '')) ~ DASHBOARD_LOCATION_SILENT_CHECK }}" + silentCheckSsoRedirectUri: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.origin') ~ DASHBOARD_LOCATION_SILENT_CHECK }}" }; {% set _oidc_tor_enabled = lookup('config', application_id, 'services.tor.enabled', False) | bool %} {% if _oidc_tor_enabled %} {% set _oidc_tor_node = lookup('config', 'svc-net-tor', 'services.tor.node', '') %} -{% set _oidc_mirror_onion = lookup('tls', 'web-svc-mirror', 'url.base') | regex_replace('/+$', '') | to_onion_url(_oidc_tor_node, DOMAIN_PRIMARY) %} +{% set _oidc_mirror_onion = lookup('tls', 'web-svc-mirror', 'url.origin') | to_onion_url(_oidc_tor_node, DOMAIN_PRIMARY) %} const KC_MIRROR_BASE = "{{ _oidc_mirror_onion }}"; {% else %} const KC_MIRROR_BASE = null; diff --git a/roles/web-app-dashboard/vars/main.yml b/roles/web-app-dashboard/vars/main.yml index dff195ad08..793b4eb431 100644 --- a/roles/web-app-dashboard/vars/main.yml +++ b/roles/web-app-dashboard/vars/main.yml @@ -14,13 +14,12 @@ DASHBOARD_SIMPLEICONS_PROBE_URLS: >- | map('regex_replace', '^', DASHBOARD_SIMPLEICONS_SYNC_URL_BASE) | map('regex_replace', '$', '.svg'))) }} -DASHBOARD_JS_CDN_URL: "{{ lookup('tls', 'web-svc-cdn', 'url.base') }}" DASHBOARD_JS_FILES: >- {{ ['iframe.js'] + (['oidc.js'] if (DASHBOARD_OIDC_ENABLED | bool) else []) }} DASHBOARD_JS_BASE_PATH: "{{ DASHBOARD_APPLICATION_ID | get_entity_name }}/js" DASHBOARD_JS_SERVER_DIR: "{{ [ lookup('nginx','directories.data.cdn'), DASHBOARD_JS_BASE_PATH ] | path_join }}" -DASHBOARD_JS_BASE_URL: "{{ (DASHBOARD_JS_CDN_URL | trim('/')) ~ '/' ~ (DASHBOARD_JS_BASE_PATH | trim('/')) }}" +DASHBOARD_JS_BASE_URL: "{{ lookup('tls', 'web-svc-cdn', 'url.origin') ~ '/' ~ (DASHBOARD_JS_BASE_PATH | trim('/')) }}" DASHBOARD_LOCATION_SILENT_CHECK: "/silent-check-sso.html" @@ -28,7 +27,7 @@ DASHBOARD_CONFIG_YML_SRC_FILE: "config.yaml.j2" DASHBOARD_CONFIG_INV_PATH: "{{ [ inventory_dir, 'files', inventory_hostname ,'docker', DASHBOARD_APPLICATION_ID, DASHBOARD_CONFIG_YML_SRC_FILE] | path_join }}" DASHBOARD_CONFIG_YML_DEST: "{{ [ lookup('container', DASHBOARD_APPLICATION_ID, 'directories.volumes'), 'config.yaml' ] | path_join }}" -DASHBOARD_APP_BASE_URL: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.base') | regex_replace('/+$', '') }}/" +DASHBOARD_APP_BASE_URL: "{{ lookup('tls', DASHBOARD_APPLICATION_ID, 'url.base') }}" DASHBOARD_KEYCLOAK_LOGIN_URL: >- {{ OIDC.CLIENT.AUTHORIZE_URL ~ '?client_id=' ~ OIDC.CLIENT.ID diff --git a/roles/web-app-decidim/templates/playwright.env.j2 b/roles/web-app-decidim/templates/playwright.env.j2 index d5e5402e78..30957687f4 100644 --- a/roles/web-app-decidim/templates/playwright.env.j2 +++ b/roles/web-app-decidim/templates/playwright.env.j2 @@ -2,7 +2,7 @@ ADMIN_EMAIL={{ lookup('users', 'administrator').email | dotenv_quote }} ADMIN_PASSWORD={{ lookup('users', 'administrator').password | dotenv_quote }} -APP_BASE_URL={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') | dotenv_quote }} +APP_BASE_URL={{ lookup('tls', application_id, 'url.origin') | dotenv_quote }} BIBER_EMAIL={{ lookup('users', 'biber').email | dotenv_quote }} @@ -12,7 +12,7 @@ BIBER_USERNAME={{ lookup('users', 'biber').username | dotenv_quote }} CANONICAL_DOMAIN={{ lookup('domain', application_id) | dotenv_quote }} -DECIDIM_BASE_URL={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') | dotenv_quote }} +DECIDIM_BASE_URL={{ lookup('tls', application_id, 'url.origin') | dotenv_quote }} # nocheck: playwright-service-gate — covered by sys-svc-mail integration tests, not the persona surface EMAIL_SERVICE_ENABLED={{ lookup('config', application_id, 'services.email.enabled') | string | lower }} diff --git a/roles/web-app-erpnext/templates/compose.yml.j2 b/roles/web-app-erpnext/templates/compose.yml.j2 index 461a5770c6..63086e94d9 100644 --- a/roles/web-app-erpnext/templates/compose.yml.j2 +++ b/roles/web-app-erpnext/templates/compose.yml.j2 @@ -98,7 +98,7 @@ bench set-config -g redis_queue "{{ ERPNEXT_REDIS_URL }}/1"; bench set-config -g redis_socketio "{{ ERPNEXT_REDIS_URL }}/2"; bench set-config -gp socketio_port {{ ERPNEXT_WEBSOCKET_PORT }}; - bench set-config -g host_name "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}"; + bench set-config -g host_name "{{ lookup('tls', application_id, 'url.origin') }}"; {{ lookup('container_volumes', service_name) | indent(4) }} {{ lookup('container_depends_on', application_id) | indent(4) }} {{ lookup('container_extra_hosts') | indent(4) }} diff --git a/roles/web-app-friendica/vars/main.yml b/roles/web-app-friendica/vars/main.yml index f9cfc12b95..bcb13c596f 100644 --- a/roles/web-app-friendica/vars/main.yml +++ b/roles/web-app-friendica/vars/main.yml @@ -8,7 +8,7 @@ FRIENDICA_IMAGE: "{{ lookup('config', application_id, 'services.' ~ FRIENDICA_SE FRIENDICA_VERSION: "{{ lookup('config', application_id, 'services.' ~ FRIENDICA_SERVICE ~ '.version') }}" FRIENDICA_INIT_GATE_FILE: "friendica-init-gate.sh" FRIENDICA_INIT_GATE_HOST: "{{ [lookup('container', application_id, 'directories.instance'), FRIENDICA_INIT_GATE_FILE] | path_join }}" -FRIENDICA_BASE_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}" +FRIENDICA_BASE_URL: "{{ lookup('tls', application_id, 'url.origin') }}" FRIENDICA_APPLICATION_DIR: "{{ lookup('volume', 'web-app-friendica', 'html').target }}" FRIENDICA_CONFIG_DIR: "{{ [ FRIENDICA_APPLICATION_DIR, 'config' ] | path_join }}" diff --git a/roles/web-app-gitlab/vars/main.yml b/roles/web-app-gitlab/vars/main.yml index 3be1797111..e05d817b63 100644 --- a/roles/web-app-gitlab/vars/main.yml +++ b/roles/web-app-gitlab/vars/main.yml @@ -4,7 +4,6 @@ entity_name: "{{ application_id | get_entity_name }}" swarm_stack_converge_retries: 600 -GITLAB_URL: "{{ lookup('tls', application_id, 'url.base') }}" GITLAB_HOSTNAME: "{{ lookup('container_hostname', application_id) }}" GITLAB_TLS_ENABLED: "{{ lookup('tls', application_id, 'enabled') | bool }}" GITLAB_WEB_PORT: "{{ lookup('tls', application_id, 'ports.web') }}" @@ -27,7 +26,7 @@ GITLAB_OIDC_UID_FIELD: "{{ OIDC.ATTRIBUTES.USERNAME }}" GITLAB_OIDC_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" GITLAB_OIDC_CLIENT_SECRET: "{{ OIDC.CLIENT.SECRET }}" GITLAB_OIDC_ISSUER: "{{ OIDC.CLIENT.ISSUER_URL }}" -GITLAB_OIDC_REDIRECT_URI: "{{ (GITLAB_URL | regex_replace('/$', '')) ~ '/users/auth/openid_connect/callback' }}" +GITLAB_OIDC_REDIRECT_URI: "{{ lookup('tls', application_id, 'url.origin') ~ '/users/auth/openid_connect/callback' }}" GITLAB_INIT_ROOT_PASSWORD: "{{ lookup('users', 'administrator').password }}" diff --git a/roles/web-app-jenkins/templates/casc.yaml.j2 b/roles/web-app-jenkins/templates/casc.yaml.j2 index e66a916a60..6a122e6c7c 100644 --- a/roles/web-app-jenkins/templates/casc.yaml.j2 +++ b/roles/web-app-jenkins/templates/casc.yaml.j2 @@ -53,4 +53,4 @@ jenkins: - group: "authenticated" unclassified: location: - url: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}/" + url: "{{ lookup('tls', application_id, 'url.base') }}" diff --git a/roles/web-app-joomla/meta/addons/plg_system_keycloak.yml b/roles/web-app-joomla/meta/addons/plg_system_keycloak.yml index 217db5e036..803b04af25 100644 --- a/roles/web-app-joomla/meta/addons/plg_system_keycloak.yml +++ b/roles/web-app-joomla/meta/addons/plg_system_keycloak.yml @@ -11,7 +11,7 @@ config: issuer_url: "{{ OIDC.CLIENT.ISSUER_URL }}" client_id: "{{ OIDC.CLIENT.ID }}" client_secret: "{{ OIDC.CLIENT.SECRET }}" - redirect_uri: "{{ lookup('tls', 'web-app-joomla', 'url.base') }}/index.php?option=keycloak&task=callback" + redirect_uri: "{{ lookup('tls', 'web-app-joomla', 'url.base') }}index.php?option=keycloak&task=callback" end_session_url: "{{ OIDC.CLIENT.LOGOUT_URL }}" fallback_enabled: "{{ lookup('config', 'web-app-joomla', 'services.sso.oidc.fallback_enabled') | string | lower }}" group_admin: "/{{ lookup('rbac_group_path', application_id='web-app-joomla', role='administrator') }}" diff --git a/roles/web-app-keycloak/templates/logout-panel.js.j2 b/roles/web-app-keycloak/templates/logout-panel.js.j2 index b9abe88f79..d78e09d00b 100644 --- a/roles/web-app-keycloak/templates/logout-panel.js.j2 +++ b/roles/web-app-keycloak/templates/logout-panel.js.j2 @@ -1,5 +1,5 @@ window.__INFINITO_LOGOUT__ = { - origin: {{ lookup('tls', 'web-svc-logout', 'url.base') | regex_replace('/$', '') | to_json }}, + origin: {{ lookup('tls', 'web-svc-logout', 'url.origin') | to_json }}, i18n: {{ lookup('file', application_id | abs_role_path_by_application_id ~ '/files/logout_i18n.yml') | from_yaml | to_json }} }; {{ lookup('file', application_id | abs_role_path_by_application_id ~ '/files/javascript/logout-panel.js') }} diff --git a/roles/web-app-keycloak/vars/main.yml b/roles/web-app-keycloak/vars/main.yml index acec94f56a..c999d9ad96 100644 --- a/roles/web-app-keycloak/vars/main.yml +++ b/roles/web-app-keycloak/vars/main.yml @@ -6,7 +6,7 @@ domain: "{{ lookup('domain',application_id) }}" KEYCLOAK_CONTAINER_HOSTNAME: "{{ entity_name }}" KEYCLOAK_DOMAIN: "{{ domain }}" KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}" -KEYCLOAK_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}" +KEYCLOAK_URL: "{{ lookup('tls', application_id, 'url.origin') }}" KEYCLOAK_REALM_URL: "{{ KEYCLOAK_URL }}/realms/{{ KEYCLOAK_REALM }}" KEYCLOAK_DEBUG_ENABLED: "{{ MODE_DEBUG }}" KEYCLOAK_CLIENT_ID: "{{ OIDC.CLIENT.ID }}" diff --git a/roles/web-app-moodle/templates/env.j2 b/roles/web-app-moodle/templates/env.j2 index c01bf6a83e..13a5564bdb 100644 --- a/roles/web-app-moodle/templates/env.j2 +++ b/roles/web-app-moodle/templates/env.j2 @@ -1,6 +1,6 @@ {% include 'roles/sys-svc-container/templates/env.j2' %} -MOODLE_WWWROOT={{ lookup('tls', application_id, 'url.base') | regex_replace('/$', '') }} +MOODLE_WWWROOT={{ lookup('tls', application_id, 'url.origin') }} MOODLE_INTERNAL_HOST={{ MOODLE_MCP_SERVICE_KEY }} MOODLE_DATAROOT={{ MOODLE_DATA_DIR }} MOODLE_LOCALCACHEDIR={{ MOODLE_LOCAL_CACHE_DIR }} diff --git a/roles/web-app-nextcloud/files/playwright/addons/bbb.spec.js b/roles/web-app-nextcloud/files/playwright/addons/bbb.spec.js index 4fa76378af..4239f5e629 100644 --- a/roles/web-app-nextcloud/files/playwright/addons/bbb.spec.js +++ b/roles/web-app-nextcloud/files/playwright/addons/bbb.spec.js @@ -63,8 +63,8 @@ test("bbb addon: cloud_bbb app route renders its own UI and is coupled to the pa expect( configuredUrl, - "the bbb api.url must be a valid https URL pointing at the BigBlueButton API mount (the partner base URL + '/bigbluebutton/' API suffix)" - ).toMatch(/^https:\/\/.+\/bigbluebutton\/?$/); + "the bbb api.url must be the partner base URL + '/bigbluebutton/' with exactly one slash on each side; the BigBlueButton client appends 'api/' directly, so a missing trailing slash yields '/bigbluebuttonapi/'" + ).toMatch(/^https:\/\/[^/]+\/bigbluebutton\/$/); const apiSecretField = bbbSection.locator("#bbb-api input[name='api.secret'], input[name='api.secret']"); await expect( diff --git a/roles/web-app-nextcloud/meta/addons/bbb.yml b/roles/web-app-nextcloud/meta/addons/bbb.yml index cf8956fa91..6cf6253514 100644 --- a/roles/web-app-nextcloud/meta/addons/bbb.yml +++ b/roles/web-app-nextcloud/meta/addons/bbb.yml @@ -16,4 +16,4 @@ config: configvalue: "{{ lookup('config','web-app-bigbluebutton', 'secrets.credentials.shared_secret', '') }}" - appid: "bbb" configkey: "api.url" - configvalue: "{{ lookup('tls', 'web-app-bigbluebutton', 'url.base') }}{{ lookup('config', 'web-app-bigbluebutton', 'services.bigbluebutton.api_suffix','') }}" + configvalue: "{{ ([lookup('tls', 'web-app-bigbluebutton', 'url.base'), lookup('config', 'web-app-bigbluebutton', 'services.bigbluebutton.api_suffix', '')] | url_join) ~ '/' }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_discourse.yml b/roles/web-app-nextcloud/meta/addons/integration_discourse.yml index f9f5bf5f2a..b551a19148 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_discourse.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_discourse.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_discourse" configkey: "url" - configvalue: "{{ lookup('tls', 'web-app-discourse', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-discourse', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_gitlab.yml b/roles/web-app-nextcloud/meta/addons/integration_gitlab.yml index 39bde68818..1f12b37537 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_gitlab.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_gitlab.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_gitlab" configkey: "oauth_instance_url" - configvalue: "{{ lookup('tls', 'web-app-gitlab', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-gitlab', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_mastodon.yml b/roles/web-app-nextcloud/meta/addons/integration_mastodon.yml index 50aa6a16f1..531babaff0 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_mastodon.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_mastodon.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_mastodon" configkey: "oauth_instance_url" - configvalue: "{{ lookup('tls', 'web-app-mastodon', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-mastodon', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_matrix.yml b/roles/web-app-nextcloud/meta/addons/integration_matrix.yml index 9db127135a..30b3c6eaa4 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_matrix.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_matrix.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_matrix" configkey: "oauth_instance_url" - configvalue: "{{ lookup('tls', 'web-app-matrix', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-matrix', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_mattermost.yml b/roles/web-app-nextcloud/meta/addons/integration_mattermost.yml index a48a906c21..d640499771 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_mattermost.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_mattermost.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_mattermost" configkey: "url" - configvalue: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-mattermost', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_moodle.yml b/roles/web-app-nextcloud/meta/addons/integration_moodle.yml index 3d9f258de4..b8af1249e3 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_moodle.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_moodle.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_moodle" configkey: "url" - configvalue: "{{ lookup('tls', 'web-app-moodle', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-moodle', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_openai.yml b/roles/web-app-nextcloud/meta/addons/integration_openai.yml index 7b4c23de84..ef112912f9 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_openai.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_openai.yml @@ -11,5 +11,5 @@ config: plugin_configuration: - appid: "integration_openai" configkey: "url" - configvalue: "{{ (lookup('tls', 'web-app-openwebui', 'url.base') | regex_replace('/+$', '') ~ '/api') if ('web-app-openwebui' in group_names) else (LITELLM_BASE_LOCAL_URL + configvalue: "{{ (lookup('tls', 'web-app-openwebui', 'url.origin') ~ '/api') if ('web-app-openwebui' in group_names) else (LITELLM_BASE_LOCAL_URL ~ '/v1') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_suitecrm.yml b/roles/web-app-nextcloud/meta/addons/integration_suitecrm.yml index 3c0f4ad3db..f768daed2d 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_suitecrm.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_suitecrm.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_suitecrm" configkey: "url" - configvalue: "{{ lookup('tls', 'web-app-suitecrm', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-suitecrm', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/integration_zammad.yml b/roles/web-app-nextcloud/meta/addons/integration_zammad.yml index d28691d0cf..368cf43a26 100644 --- a/roles/web-app-nextcloud/meta/addons/integration_zammad.yml +++ b/roles/web-app-nextcloud/meta/addons/integration_zammad.yml @@ -9,4 +9,4 @@ config: plugin_configuration: - appid: "integration_zammad" configkey: "oauth_instance_url" - configvalue: "{{ lookup('tls', 'web-app-zammad', 'url.base') }}" + configvalue: "{{ lookup('tls', 'web-app-zammad', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/meta/addons/richdocuments.yml b/roles/web-app-nextcloud/meta/addons/richdocuments.yml index 8e13fb1f24..9bc079f5a8 100644 --- a/roles/web-app-nextcloud/meta/addons/richdocuments.yml +++ b/roles/web-app-nextcloud/meta/addons/richdocuments.yml @@ -16,7 +16,7 @@ config: configvalue: "{{ NEXTCLOUD_COLLABORA_URL }}" - appid: "richdocuments" configkey: "public_wopi_url" - configvalue: "{{ NEXTCLOUD_COLLABORA_URL | regex_replace('/$', '') }}" + configvalue: "{{ lookup('tls', 'web-svc-collabora', 'url.origin') }}" - appid: "richdocuments" configkey: "disable_certificate_verification" configvalue: 0 diff --git a/roles/web-app-nextcloud/meta/addons/xwiki.yml b/roles/web-app-nextcloud/meta/addons/xwiki.yml index d72abe33e3..efd7dbf572 100644 --- a/roles/web-app-nextcloud/meta/addons/xwiki.yml +++ b/roles/web-app-nextcloud/meta/addons/xwiki.yml @@ -15,4 +15,4 @@ config: configkey: "instances" configvalue: - name: "XWiki" - url: "{{ lookup('tls', 'web-app-xwiki', 'url.base') }}" + url: "{{ lookup('tls', 'web-app-xwiki', 'url.origin') }}" diff --git a/roles/web-app-nextcloud/tasks/addons/integration_gitlab.yml b/roles/web-app-nextcloud/tasks/addons/integration_gitlab.yml index 3bec48c98f..fbb1e629d8 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_gitlab.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_gitlab.yml @@ -1,7 +1,7 @@ --- - name: 🔍 Resolve GitLab integration provisioning facts set_fact: - nextcloud_gl_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.base') | regex_replace('/$', '') }}" + nextcloud_gl_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.origin') }}" changed_when: false - name: 📖 Read persisted integration_gitlab OAuth client id diff --git a/roles/web-app-nextcloud/tasks/addons/integration_mattermost.yml b/roles/web-app-nextcloud/tasks/addons/integration_mattermost.yml index f2a0e33004..e962bca60f 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_mattermost.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_mattermost.yml @@ -5,7 +5,7 @@ # partner and wires the returned credentials into Nextcloud. - name: 🔍 Resolve Mattermost partner coupling facts set_fact: - nextcloud_mm_base_url: "{{ lookup('tls', 'web-app-mattermost', 'url.base') }}" + nextcloud_mm_base_url: "{{ lookup('tls', 'web-app-mattermost', 'url.origin') }}" nextcloud_mm_local_url: "http://{{ DOCKER_REACH_HOST }}:{{ lookup('config', 'web-app-mattermost', 'services.mattermost.ports.local.http') }}" nextcloud_mm_redirect_path: "/apps/integration_mattermost/oauth-redirect" nextcloud_mm_exec_chdir: "{{ lookup('container', 'web-app-mattermost', 'directories.instance') if DEPLOYMENT_MODE == 'compose' else omit }}" diff --git a/roles/web-app-nextcloud/tasks/addons/integration_suitecrm.yml b/roles/web-app-nextcloud/tasks/addons/integration_suitecrm.yml index 8e3fb62dff..048371864f 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_suitecrm.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_suitecrm.yml @@ -9,7 +9,7 @@ set_fact: nextcloud_crm_container: "{{ lookup('config', 'web-app-suitecrm', 'services.suitecrm.name') }}" nextcloud_crm_instance_dir: "{{ lookup('container', 'web-app-suitecrm', 'directories.instance') }}" - nextcloud_crm_base_url: "{{ lookup('tls', 'web-app-suitecrm', 'url.base') | trim }}" + nextcloud_crm_base_url: "{{ lookup('tls', 'web-app-suitecrm', 'url.origin') }}" nextcloud_crm_client_name: "Nextcloud ({{ lookup('domain', 'web-app-nextcloud') }})" nextcloud_crm_client_secret: "{{ ('integration_suitecrm' ~ lookup('users', 'administrator').password) | hash('sha256') }}" no_log: "{{ MASK_CREDENTIALS_IN_LOGS | bool }}" diff --git a/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml b/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml index da66d43ec1..e8257cc7ec 100644 --- a/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml +++ b/roles/web-app-nextcloud/tasks/addons/integration_zammad.yml @@ -6,8 +6,8 @@ # integration_gitlab coupling does. - name: 🔍 Resolve Zammad integration provisioning facts set_fact: - nextcloud_zammad_base_url: "{{ lookup('tls', 'web-app-zammad', 'url.base') | regex_replace('/$', '') }}" - nextcloud_zammad_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.base') | regex_replace('/$', '') }}" + nextcloud_zammad_base_url: "{{ lookup('tls', 'web-app-zammad', 'url.origin') }}" + nextcloud_zammad_nc_base_url: "{{ lookup('tls', 'web-app-nextcloud', 'url.origin') }}" changed_when: false - name: 📖 Read persisted integration_zammad OAuth client id diff --git a/roles/web-app-nextcloud/vars/main.yml b/roles/web-app-nextcloud/vars/main.yml index 1e1c4ed2ee..3bf653bdec 100644 --- a/roles/web-app-nextcloud/vars/main.yml +++ b/roles/web-app-nextcloud/vars/main.yml @@ -256,7 +256,7 @@ NEXTCLOUD_COLLABORA_URL: "{{ lookup('tls', 'web-svc-collabora', 'url.base') }}" NEXTCLOUD_ONLYOFFICE_URL: "{{ lookup('tls', 'web-svc-onlyoffice', 'url.base') }}" NEXTCLOUD_ONLYOFFICE_ENABLED: "{{ lookup('config', application_id, 'addons.onlyoffice.enabled') }}" -NEXTCLOUD_OPENPROJECT_URL: "{{ lookup('tls', 'web-app-openproject', 'url.base') | regex_replace('/$', '') }}" +NEXTCLOUD_OPENPROJECT_URL: "{{ lookup('tls', 'web-app-openproject', 'url.origin') }}" NEXTCLOUD_OPENPROJECT_CLIENT_NAME: "OpenProject client" NEXTCLOUD_DOCKER_USER_ID: 82 diff --git a/roles/web-app-openbao/vars/main.yml b/roles/web-app-openbao/vars/main.yml index 999b7e6d66..3f9285a34a 100644 --- a/roles/web-app-openbao/vars/main.yml +++ b/roles/web-app-openbao/vars/main.yml @@ -17,7 +17,7 @@ OPENBAO_LDAP_ENABLED: "{{ lookup('config', application_id, 'services.ldap.enable OPENBAO_PKI_ENABLED: "{{ lookup('config', application_id, 'services.openbao.pki.enabled') }}" OPENBAO_LOG_LEVEL: "{{ lookup('config', application_id, 'services.openbao.log_level') }}" -OPENBAO_BASE_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}" +OPENBAO_BASE_URL: "{{ lookup('tls', application_id, 'url.origin') }}" OPENBAO_CONFIG_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'openbao.hcl'] | path_join }}" OPENBAO_PLUGINS_CONFIG_HOST: "{{ [lookup('container', application_id, 'directories.config'), 'plugins.hcl'] | path_join }}" diff --git a/roles/web-app-opencloud/vars/main.yml b/roles/web-app-opencloud/vars/main.yml index a6247575c7..b51fd0bce3 100644 --- a/roles/web-app-opencloud/vars/main.yml +++ b/roles/web-app-opencloud/vars/main.yml @@ -6,7 +6,7 @@ proxy_x_forwarded_proto: "https" OPENCLOUD_CONTAINER: "{{ lookup('config', application_id, 'services.opencloud.name') }}" -OPENCLOUD_URL: "{{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }}" +OPENCLOUD_URL: "{{ lookup('tls', application_id, 'url.origin') }}" OPENCLOUD_DOMAIN: "{{ lookup('domain', application_id) }}" OPENCLOUD_OIDC_ENABLED: "{{ lookup('config', application_id, 'services.sso.enabled') }}" diff --git a/roles/web-app-opentalk/vars/main.yml b/roles/web-app-opentalk/vars/main.yml index 4a9e652102..431f1cc80c 100644 --- a/roles/web-app-opentalk/vars/main.yml +++ b/roles/web-app-opentalk/vars/main.yml @@ -42,7 +42,7 @@ OPENTALK_RECORDER_HTTP_PORT: "{{ lookup('config', application_id, 'services.reco OPENTALK_RECORDER_API_KEY_ID: "recorder" OPENTALK_RECORDER_API_SECRET: "{{ lookup('config', application_id, 'secrets.credentials.recorder_api_secret') }}" -OPENTALK_KEYCLOAK_BASE_URL: "{{ lookup('tls', 'web-app-keycloak', 'url.base') | regex_replace('/+$','') }}" +OPENTALK_KEYCLOAK_BASE_URL: "{{ lookup('tls', 'web-app-keycloak', 'url.origin') }}" OPENTALK_KEYCLOAK_CONTAINER_ADDRESS: "{{ lookup('container_address', 'web-app-keycloak', 'keycloak') }}" OPENTALK_KEYCLOAK_REALM: "{{ OIDC.CLIENT.REALM }}" diff --git a/roles/web-app-semaphore/templates/env.j2 b/roles/web-app-semaphore/templates/env.j2 index 47c702d576..3177ae1886 100644 --- a/roles/web-app-semaphore/templates/env.j2 +++ b/roles/web-app-semaphore/templates/env.j2 @@ -1,7 +1,7 @@ {% include 'roles/sys-svc-container/templates/env.j2' %} SEMAPHORE_PORT=:{{ container_port }} -SEMAPHORE_WEB_ROOT={{ lookup('tls', application_id, 'url.base') | regex_replace('/+$', '') }} +SEMAPHORE_WEB_ROOT={{ lookup('tls', application_id, 'url.origin') }} SEMAPHORE_DB_DIALECT=postgres SEMAPHORE_DB_HOST={{ lookup('database', application_id, 'host') }} diff --git a/roles/web-app-semaphore/vars/main.yml b/roles/web-app-semaphore/vars/main.yml index b547c03a41..6bcd242f0a 100644 --- a/roles/web-app-semaphore/vars/main.yml +++ b/roles/web-app-semaphore/vars/main.yml @@ -27,7 +27,7 @@ SEMAPHORE_OIDC_PROVIDERS_JSON: >- "provider_url": OIDC.CLIENT.ISSUER_URL, "client_id": OIDC.CLIENT.ID, "client_secret": OIDC.CLIENT.SECRET, - "redirect_url": (lookup('tls', application_id, 'url.base') | regex_replace('/+$', '')) ~ "/api/auth/oidc/keycloak/redirect", + "redirect_url": lookup('tls', application_id, 'url.origin') ~ "/api/auth/oidc/keycloak/redirect", "username_claim": "preferred_username", "email_claim": "email", "name_claim": "name", diff --git a/roles/web-app-snipe-it/templates/env.j2 b/roles/web-app-snipe-it/templates/env.j2 index 63d28cec03..3ff3976b7b 100644 --- a/roles/web-app-snipe-it/templates/env.j2 +++ b/roles/web-app-snipe-it/templates/env.j2 @@ -4,7 +4,7 @@ APP_ENV={{ ENVIRONMENT | lower }} APP_DEBUG={{MODE_DEBUG | string | lower }} APP_KEY={{ lookup('config', application_id, 'secrets.credentials.app_key')}} -APP_URL={{ SNIPE_IT_URL | regex_replace('/$', '') }} +APP_URL={{ lookup('tls', application_id, 'url.origin') }} APP_TIMEZONE={{ HOST_TIMEZONE | dotenv_quote }} APP_LOCALE={{ HOST_LL_CC }} MAX_RESULTS=500 diff --git a/roles/web-app-stalwart/meta/csp.yml b/roles/web-app-stalwart/meta/csp.yml index fdf8f27457..178a954acb 100644 --- a/roles/web-app-stalwart/meta/csp.yml +++ b/roles/web-app-stalwart/meta/csp.yml @@ -9,4 +9,4 @@ flags: unsafe-eval: true # nocheck: csp-unsafe Reason: the WebAdmin bundle builds functions dynamically at runtime; blocked, the SPA aborts during boot with an EvalError before it renders. whitelist: connect-src: - - "{{ lookup('tls', 'web-app-keycloak', 'url.base') | regex_replace('/+$', '') }}" + - "{{ lookup('tls', 'web-app-keycloak', 'url.origin') }}" diff --git a/roles/web-app-stalwart/templates/keycloak/clients.json.j2 b/roles/web-app-stalwart/templates/keycloak/clients.json.j2 index 1a6a5fce47..b095e5c960 100644 --- a/roles/web-app-stalwart/templates/keycloak/clients.json.j2 +++ b/roles/web-app-stalwart/templates/keycloak/clients.json.j2 @@ -16,7 +16,7 @@ 255-char column. #} {% set stalwart_kc_webmail_base = lookup('canonical_url', 'web-app-stalwart', 'webmail') %} -{% set stalwart_kc_webui_base = lookup('tls', 'web-app-stalwart', 'url.base') | regex_replace('/+$', '') %} +{% set stalwart_kc_webui_base = lookup('tls', 'web-app-stalwart', 'url.origin') %} { "clientId": "{{ lookup('config', 'web-app-stalwart', 'services.sso.oidc.clients.webui') }}", "name": "Stalwart WebAdmin", diff --git a/roles/web-svc-logout/templates/logout-proxy.conf.j2 b/roles/web-svc-logout/templates/logout-proxy.conf.j2 index 107fd67c4a..3b8dd76587 100644 --- a/roles/web-svc-logout/templates/logout-proxy.conf.j2 +++ b/roles/web-svc-logout/templates/logout-proxy.conf.j2 @@ -8,7 +8,7 @@ location = /logout { {% include 'roles/sys-svc-proxy/templates/headers/upstream_strips.conf.j2' %} - {%- set aca_origin = (lookup('tls', 'web-svc-logout', 'url.base') | regex_replace('/$', '')) ~ " always" -%} + {%- set aca_origin = lookup('tls', 'web-svc-logout', 'url.origin') ~ " always" -%} {%- set aca_credentials = "'true' always" -%} {%- set aca_methods = "'GET, OPTIONS' always" -%} {%- set aca_headers = "'Accept, Authorization' always" -%} diff --git a/tests/lint/ansible/jinja/test_url_base_no_double_slash.py b/tests/lint/ansible/jinja/test_url_base_no_double_slash.py new file mode 100644 index 0000000000..176d85871f --- /dev/null +++ b/tests/lint/ansible/jinja/test_url_base_no_double_slash.py @@ -0,0 +1,225 @@ +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +from utils.annotations.suppress import is_suppressed_at +from utils.cache.files import iter_project_files_with_content + +from . import PROJECT_ROOT + +_RULE = "url-base-double-slash" +_ADDON_RULE = "url-base-addon-value" +_STRIP_RULE = "url-base-strip" + +_SCAN_PREFIXES = ("roles/", "group_vars/") +_ADDON_DIRS = ("/meta/addons/", "/tasks/addons/") +_INSTANCE_KEYS = ("oauth_instance_url", "openproject_instance_url", "url") +_CONFIGKEY = re.compile(r"""configkey:\s*['"]?(?P[A-Za-z0-9_.]+)""") +_URL_BASE = r"""lookup\(\s*['"]tls['"]\s*,[^)]*['"]url\.base['"]\s*\)""" +_DIRECT = re.compile(_URL_BASE + r"""\s*(?:\}\}/|~\s*['"]/)""") +_STRIP = ( + r"""(?:\s*\))*\s*(?:\|\s*(?:regex_replace\(\s*['"]/\+?\$['"]|trim\(\s*['"]/['"])""" + r"""|\.rstrip\(\s*['"]/['"]\s*\))""" +) +_DIRECT_STRIP = re.compile(_URL_BASE + _STRIP) +_BARE_VAR = re.compile( + r"""^\s*(?:-\s+)?(?P[A-Za-z_][A-Za-z0-9_]*):\s*['"]?\{\{\s*""" + + _URL_BASE + + r"""(?:\s*\|\s*trim)?\s*\}\}['"]?\s*$""" +) +_BARE_SET = re.compile( + r"""\{%-?\s*set\s+(?P[A-Za-z_][A-Za-z0-9_]*)\s*=\s*""" + + _URL_BASE + + r"""(?:\s*\|\s*trim)?\s*-?%\}""" +) + + +def _bare_url_base_names(files: list[tuple[str, list[str]]]) -> set[str]: + """Return every variable name defined as a bare url.base lookup. + + Args: + files: (relative path, lines) of every scanned file. + + Returns: + names whose value is ``lookup('tls', ..., 'url.base')`` with at most a + ``trim``, the value that always ends in "/". + """ + names: set[str] = set() + for _, lines in files: + for line in lines: + for pattern in (_BARE_VAR, _BARE_SET): + match = pattern.search(line) + if match: + names.add(match.group("name")) + return names + + +def _double_slash_lines(lines: list[str], names: set[str]) -> list[int]: + """Return the 1-based lines that append "/" to a url.base value. + + Args: + lines: the file's lines. + names: variables that hold a bare url.base. + + Returns: + lines where url.base, directly or through one of ``names``, is followed + by "}}/" or "~ '/". + """ + via_name = ( + re.compile( + r"""\{\{\s*(?:""" + + "|".join(sorted(map(re.escape, names))) + + r""")\s*(?:\}\}/|~\s*['"]/)""" + ) + if names + else None + ) + return [ + index + 1 + for index, line in enumerate(lines) + if _DIRECT.search(line) or (via_name and via_name.search(line)) + ] + + +def _strip_lines(lines: list[str], names: set[str]) -> list[int]: + """Return the 1-based lines that strip the trailing "/" from url.base. + + Args: + lines: the file's lines. + names: variables that hold a bare url.base. + + Returns: + lines where url.base, directly or through one of ``names``, goes through + ``regex_replace('/$', '')``, ``regex_replace('/+$', '')``, ``trim('/')`` + or ``.rstrip('/')``. + """ + via_name = ( + re.compile( + r"""\b(?:""" + "|".join(sorted(map(re.escape, names))) + r""")\b""" + _STRIP + ) + if names + else None + ) + return [ + index + 1 + for index, line in enumerate(lines) + if _DIRECT_STRIP.search(line) or (via_name and via_name.search(line)) + ] + + +def _addon_bare_value_lines(lines: list[str], names: set[str]) -> list[int]: + """Return the 1-based lines that hand an integration app a bare url.base. + + Args: + lines: the addon file's lines. + names: variables that hold a bare url.base. + + Returns: + lines whose whole value is url.base or one of ``names`` and that are a + ``configvalue`` of one of ``_INSTANCE_KEYS``, a ``url`` key, or a + ``*_base_url`` fact. + """ + pattern = re.compile( + r"""^\s*(?:-\s+)?(?P[A-Za-z_][A-Za-z0-9_]*):\s*['"]?\{\{\s*(?:""" + + _URL_BASE + + (("|" + "|".join(sorted(map(re.escape, names)))) if names else "") + + r""")(?:\s*\|\s*trim)?\s*\}\}['"]?\s*$""" + ) + found: list[int] = [] + configkey = "" + for index, line in enumerate(lines): + key = _CONFIGKEY.search(line) + if key: + configkey = key.group("name") + match = pattern.search(line) + if not match: + continue + name = match.group("key") + if ( + (name == "configvalue" and configkey in _INSTANCE_KEYS) + or name == "url" + or name.endswith("_base_url") + ): + found.append(index + 1) + return found + + +def _scanned_files() -> list[tuple[str, list[str]]]: + files: list[tuple[str, list[str]]] = [] + for path_str, content in iter_project_files_with_content( + extensions=(".yml", ".yaml", ".j2"), + exclude_tests=True, + ): + rel = Path(path_str).relative_to(PROJECT_ROOT).as_posix() + if rel.startswith(_SCAN_PREFIXES): + files.append((rel, content.splitlines())) + return files + + +class TestUrlBaseNoDoubleSlash(unittest.TestCase): + def test_addon_settings_hold_partner_urls_without_trailing_slash(self) -> None: + files = _scanned_files() + names = _bare_url_base_names(files) + findings = [ + f"- {rel}:{line_no}: {lines[line_no - 1].strip()}" + for rel, lines in files + if any(part in rel for part in _ADDON_DIRS) + for line_no in _addon_bare_value_lines(lines, names) + if not is_suppressed_at(lines, line_no, _ADDON_RULE, mode="same-or-above") + ] + + self.assertFalse( + findings, + "An addon setting holds lookup('tls', ..., 'url.base') as is, with " + "its trailing '/'. Integration apps append their own paths to it " + "(integration_openproject builds '//oauth/token'), so store " + "lookup('tls', ..., 'url.origin') instead. Mark a setting that needs " + f"the slash with `# nocheck: {_ADDON_RULE}`.\n\nOffenders:\n" + + "\n".join(findings), + ) + + def test_no_path_is_appended_to_url_base_with_a_leading_slash(self) -> None: + files = _scanned_files() + names = _bare_url_base_names(files) + findings = [ + f"- {rel}:{line_no}: {lines[line_no - 1].strip()}" + for rel, lines in files + for line_no in _double_slash_lines(lines, names) + if not is_suppressed_at(lines, line_no, _RULE, mode="same-or-above") + ] + + self.assertFalse( + findings, + "lookup('tls', ..., 'url.base') ends in '/', so appending '/path' " + "yields 'https://host//path', which exact-match OAuth redirect " + "URIs and token endpoints reject. Append the path to " + "lookup('tls', ..., 'url.origin'), join with `[base, 'path'] | " + "url_join`, or append the path without its leading slash. Mark a " + f"deliberate exception with `# nocheck: {_RULE}`.\n\nOffenders:\n" + + "\n".join(findings), + ) + + def test_url_base_is_never_stripped_of_its_trailing_slash(self) -> None: + files = _scanned_files() + names = _bare_url_base_names(files) + findings = [ + f"- {rel}:{line_no}: {lines[line_no - 1].strip()}" + for rel, lines in files + for line_no in _strip_lines(lines, names) + if not is_suppressed_at(lines, line_no, _STRIP_RULE, mode="same-or-above") + ] + + self.assertFalse( + findings, + "url.base is stripped of its trailing '/'. The tls lookup already " + "returns that value as lookup('tls', ..., 'url.origin'), so read it " + "there instead of rebuilding it per call site. Mark a deliberate " + f"exception with `# nocheck: {_STRIP_RULE}`.\n\nOffenders:\n" + + "\n".join(findings), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/unit/python/plugins/lookup/test_tls.py b/tests/unit/python/plugins/lookup/test_tls.py index af91c312e7..0cd80fcbb7 100644 --- a/tests/unit/python/plugins/lookup/test_tls.py +++ b/tests/unit/python/plugins/lookup/test_tls.py @@ -85,6 +85,16 @@ class TestTlsResolveLookup(unittest.TestCase): )[0] self.assertEqual(val, "https://a.example/") + def test_url_origin_is_the_base_url_without_its_trailing_slash(self): + origin, base = ( + self.lookup.run(["web-app-a", want], variables=self.base_vars, mode="app")[ + 0 + ] + for want in ("url.origin", "url.base") + ) + self.assertEqual(origin, "https://a.example") + self.assertEqual(f"{origin}/", base) + def test_protocols_web_path_is_http_for_an_onion_only_app(self): onion = dict(self.base_vars) onion["domains"] = {**self.domains, "web-app-onion": "abc123.onion"} diff --git a/tests/unit/python/roles/web-app-keycloak/test_logout_status_panel.py b/tests/unit/python/roles/web-app-keycloak/test_logout_status_panel.py index 1395cb4900..a2dcf16c41 100644 --- a/tests/unit/python/roles/web-app-keycloak/test_logout_status_panel.py +++ b/tests/unit/python/roles/web-app-keycloak/test_logout_status_panel.py @@ -48,7 +48,8 @@ PANEL = ROLE / "files" / "javascript" / "logout-panel.js" ASSEMBLY = ROLE / "templates" / "logout-panel.js.j2" CATALOGUE_FILE = ROLE / "files" / "logout_i18n.yml" VARS = ROLE / ROLE_FILE_VARS_MAIN -FRAMED_ORIGIN_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.base')" +FRAMED_URL_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.base')" +PANEL_ORIGIN_LOOKUP = "lookup('tls', 'web-svc-logout', 'url.origin')" ORIGIN = "https://logout.example.test" DOMAINS = ("https://shop.example.test", "https://cloud.example.test") @@ -356,15 +357,16 @@ class TestPanelTrustsTheFramedOrigin(unittest.TestCase): declaration, f"KEYCLOAK_FRONTCHANNEL_LOGOUT_URL missing from {VARS}" ) self.assertIn( - FRAMED_ORIGIN_LOOKUP, + FRAMED_URL_LOOKUP, declaration, "the front-channel logout URL decides which origin ends up framed", ) self.assertIn( - FRAMED_ORIGIN_LOOKUP, + PANEL_ORIGIN_LOOKUP, read_text(str(ASSEMBLY)), - "the panel discards messages from any other origin, so its constant and" - " the framed URL must come from one expression or the panel goes silent", + "the panel discards messages from any other origin, so its constant must" + " be the origin of the same tls resolution that builds the framed URL," + " or the panel goes silent", )