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/<call>' to; bbb.spec.js now requires exactly that form.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-10-28 17:21:53 +01:00
1 parent 199d7fdcbf
commit 44aaa68924
44 files changed
+291 -56

No files matched your search

+1 -2
View File
@@ -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
}}"
+3 -2
View File
@@ -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:
@@ -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)
@@ -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;
+2 -3
View File
@@ -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
@@ -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 }}
@@ -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) }}
+1 -1
View File
@@ -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 }}"
+1 -2
View File
@@ -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 }}"
+1 -1
View File
@@ -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') }}"
@@ -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') }}"
@@ -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') }}
+1 -1
View File
@@ -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 }}"
+1 -1
View File
@@ -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 }}
@@ -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/<call>' 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(
+1 -1
View File
@@ -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) ~ '/' }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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') }}"
@@ -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
@@ -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') }}"
@@ -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
@@ -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 }}"
@@ -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 }}"
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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 }}"
+1 -1
View File
@@ -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') }}"
+1 -1
View File
@@ -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 }}"
+1 -1
View File
@@ -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') }}
+1 -1
View File
@@ -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",
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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') }}"
@@ -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",
@@ -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" -%}
@@ -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<name>[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<name>[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<name>[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<key>[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 '<url>//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()
@@ -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"}
@@ -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",
)