From 899ef37b1e23a306932618b0a18d113d7f2b8232 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 10 Aug 2026 09:24:58 +0200 Subject: [PATCH 1/9] UNOMI-972: require an explicit admin and health-check password at startup Reported issue 1. users.properties resolved the shipped karaf and health accounts via ${...:-karaf} / ${...:-health}, so a deployment that set nothing authenticated with a known password. Removing the fallback alone is not enough: an unset property expands to the empty string, which Karaf's PropertiesLoginModule still accepts, so the accounts would simply have accepted an empty password instead. bin/setenv and the Docker entrypoint therefore refuse to start without the passwords, and AuthenticationFilter rejects a blank Basic credential wherever one is consumed - the launchers cannot cover every way the JVM is started (notably karaf.bat, whose inability to halt startup is documented in setenv itself). Co-Authored-By: Claude Opus 5 (1M context) --- clear-elasticsearch.sh | 2 + clear-opensearch.sh | 2 + docker/README.md | 18 +- .../main/docker/docker-compose-build-es.yml | 2 + .../main/docker/docker-compose-build-os.yml | 2 + .../main/docker/docker-compose-cluster.yml | 4 + docker/src/main/docker/docker-compose-es.yml | 3 + docker/src/main/docker/docker-compose-os.yml | 3 + docker/src/main/docker/entrypoint.sh | 41 ++ .../java/org/apache/unomi/itests/BaseIT.java | 6 + .../java/org/apache/unomi/itests/BasicIT.java | 29 +- .../apache/unomi/itests/HealthCheckIT.java | 11 +- .../org/apache/unomi/itests/TenantIT.java | 8 +- .../unomi/itests/V2CompatibilityModeIT.java | 4 +- .../unomi/itests/graphql/BaseGraphQLIT.java | 2 +- .../src/test/resources/etc/users.properties | 4 +- package/src/main/resources/bin/setenv | 88 ++++ package/src/main/resources/bin/setenv.bat | 61 +++ .../resources/etc/custom.system.properties | 11 +- .../src/main/resources/etc/users.properties | 6 +- .../authentication/AuthenticationFilter.java | 76 +++- ...AuthenticationFilterBlankPasswordTest.java | 243 ++++++++++ .../ShippedAdminPasswordConfigTest.java | 428 ++++++++++++++++++ setup-elasticsearch.sh | 22 +- setup-opensearch.sh | 26 +- setup-utils.sh | 27 ++ 26 files changed, 1077 insertions(+), 52 deletions(-) create mode 100644 rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java create mode 100644 rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh index 0d92e9da23..baa7606479 100755 --- a/clear-elasticsearch.sh +++ b/clear-elasticsearch.sh @@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-elasticsearch.sh / setup-opensearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/clear-opensearch.sh b/clear-opensearch.sh index 61b6c5a121..df4c9425b9 100755 --- a/clear-opensearch.sh +++ b/clear-opensearch.sh @@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES # Also set by setup-opensearch.sh / setup-elasticsearch.sh unset UNOMI_DISTRIBUTION +unset UNOMI_ROOT_PASSWORD +unset UNOMI_HEALTHCHECK_PASSWORD unset _IS_SOURCED diff --git a/docker/README.md b/docker/README.md index c5ab6553d4..a5dc49bde9 100644 --- a/docker/README.md +++ b/docker/README.md @@ -30,9 +30,13 @@ required Unomi tarball. ## Launching docker-compose using Maven project -Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose: +Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose. + +Set admin and health passwords first (required; no known defaults are shipped): ``` +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' mvn docker:start ``` @@ -72,6 +76,8 @@ For Unomi (with ElasticSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -81,6 +87,8 @@ For Unomi (with OpenSearch): ```bash docker pull apache/unomi:3.1.0-SNAPSHOT docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -93,6 +101,8 @@ For ElasticSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \ apache/unomi:3.1.0-SNAPSHOT ``` @@ -101,6 +111,8 @@ For OpenSearch: ```bash docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \ + -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \ -e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \ -e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \ -e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \ @@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s ## Environment Variables ### Common Variables +- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default +- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no known default - `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`) - `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`) @@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints such as `/cxs/context.js ```bash curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{"requestedId":"default","properties":{"name":"Default Tenant"}}' ``` diff --git a/docker/src/main/docker/docker-compose-build-es.yml b/docker/src/main/docker/docker-compose-build-es.yml index 03f265a7b8..34193ad30e 100644 --- a/docker/src/main/docker/docker-compose-build-es.yml +++ b/docker/src/main/docker/docker-compose-build-es.yml @@ -39,6 +39,8 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-build-os.yml b/docker/src/main/docker/docker-compose-build-os.yml index 97d38cfe44..f5c8592d5d 100644 --- a/docker/src/main/docker/docker-compose-build-os.yml +++ b/docker/src/main/docker/docker-compose-build-os.yml @@ -100,6 +100,8 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-cluster.yml b/docker/src/main/docker/docker-compose-cluster.yml index 75aed46288..fa1a3069e9 100644 --- a/docker/src/main/docker/docker-compose-cluster.yml +++ b/docker/src/main/docker/docker-compose-cluster.yml @@ -36,6 +36,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-1 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -58,6 +60,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_CLUSTER_NODEID=unomi-3-node-2 + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} ports: - 8182:8181 - 9444:9443 diff --git a/docker/src/main/docker/docker-compose-es.yml b/docker/src/main/docker/docker-compose-es.yml index edfada5911..a3d2946721 100644 --- a/docker/src/main/docker/docker-compose-es.yml +++ b/docker/src/main/docker/docker-compose-es.yml @@ -45,6 +45,9 @@ services: - UNOMI_AUTO_START=true - UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/docker-compose-os.yml b/docker/src/main/docker/docker-compose-os.yml index 579d3e2bc5..a37babf5bb 100644 --- a/docker/src/main/docker/docker-compose-os.yml +++ b/docker/src/main/docker/docker-compose-os.yml @@ -84,6 +84,9 @@ services: - UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200 - UNOMI_OPENSEARCH_USERNAME=admin - UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD} + # Required admin password (no known default is shipped). Override via .env / shell. + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD} # Debug settings - KARAF_DEBUG=${DEBUG:-false} - KARAF_DEBUG_PORT=${DEBUG_PORT:-5005} diff --git a/docker/src/main/docker/entrypoint.sh b/docker/src/main/docker/entrypoint.sh index 1cfc684564..e805537b4b 100755 --- a/docker/src/main/docker/entrypoint.sh +++ b/docker/src/main/docker/entrypoint.sh @@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${ echo "KARAF_OPTS: $KARAF_OPTS" +# Refuse to start without admin/health passwords. An unset password is not "no account": it +# expands to the empty string, which Karaf's PropertiesLoginModule accepts as a valid password. +# This is the gate for container launches: exiting here means the container fails to start rather +# than booting with an administrator account that accepts an empty password. +check_required_password() { + # $1 env var name, $2 property name, $3 skip flag name + eval _value=\"\${$1}\" + eval _skip=\"\${$3}\" + + [ -n "${_value}" ] && return 0 + + if [ "${_skip}" = "true" ]; then + cat >&2 <&2 < profileService.load(profileIdVisitor1), + p -> FIRST_NAME_VISITOR_1.equals(p.getProperty(FIRST_NAME)), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Trusted private key may switch the browsing profile to VISITOR_2. HttpPost requestLoginVisitor2 = new HttpPost(getFullUrl("/cxs/context.json")); requestLoginVisitor2.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); - requestLoginVisitor2.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + requestLoginVisitor2.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString( + (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes())); requestLoginVisitor2.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), ContentType.create("application/json"))); - TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4); + TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4, -1, false); // We should have a new profile id so the session should have been moved from VISITOR_1 to VISITOR_2 String profileIdVisitor2 = requestResponseLoginVisitor2.getContextResponse().getProfileId(); Assert.assertNotEquals("Context profile id should not be the same", profileIdVisitor1, diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java index 305c2f38e8..f7179a7f42 100644 --- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java @@ -54,8 +54,6 @@ public class HealthCheckIT extends BaseIT { private final static Logger LOGGER = LoggerFactory.getLogger(HealthCheckIT.class); - protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; - protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final String HEALTHCHECK_ENDPOINT = "/health/check"; @Test @@ -132,6 +130,15 @@ private void assertHealthCheckLive(List response) { } } + @Test + public void testHealthCheck_wrongPasswordRejected() throws Exception { + final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT)); + try (CloseableHttpResponse response = executeHttpRequest( + httpGet, AuthType.CUSTOM_BASIC, HEALTHCHECK_AUTH_USER_NAME, "wrong-password")) { + Assert.assertEquals(401, response.getStatusLine().getStatusCode()); + } + } + protected T get(final String url, TypeReference typeReference) { CloseableHttpResponse response = null; try { diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java index a9ef9a5952..9cce847074 100644 --- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java @@ -190,7 +190,7 @@ public void testTenantEndpointAuthentication() throws Exception { // Create test tenant for API key tests BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build()) { Map requestBody = new HashMap<>(); @@ -273,7 +273,7 @@ public void testPublicEndpointAuthentication() throws Exception { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying X-Unomi-Api-Key from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) { Assert.assertEquals("JAAS auth should grant access to public endpoints", 200, response.getStatusLine().getStatusCode()); @@ -325,7 +325,7 @@ public void testPrivateEndpointAuthentication() throws Exception { // Test with JAAS auth (should succeed) — use a fresh request to avoid carrying Authorization from previous step BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider(); - adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build(); CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/cxs/profiles/count")))) { Assert.assertEquals("JAAS auth should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); @@ -378,7 +378,7 @@ public void testApiKeyAuthentication() throws Exception { // Test with JAAS authentication (should succeed) getRequest = new HttpGet(getFullUrl("/cxs/profiles/count")); - getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(("karaf:karaf").getBytes())); + getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((BASIC_AUTH_USER_NAME + ":" + BASIC_AUTH_PASSWORD).getBytes())); try (CloseableHttpResponse response = executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) { Assert.assertEquals("JAAS authentication should grant access to private endpoints", 200, response.getStatusLine().getStatusCode()); } diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java index cce6c54847..f27089368c 100644 --- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java @@ -225,7 +225,7 @@ private void testV3ModeBehavior() throws Exception { request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) @@ -283,7 +283,7 @@ private void testV2ModeBehavior() throws Exception { HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + TEST_PROFILE_ID)); BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java index 1ff2201092..34db0381e0 100644 --- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java @@ -72,7 +72,7 @@ protected CloseableHttpResponse postAnonymous(final String resource) throws Exce } /** - * Performs a GraphQL POST request with JAAS admin authentication (karaf:karaf). + * Performs a GraphQL POST request with JAAS admin authentication ({@link #BASIC_AUTH_USER_NAME}/{@link #BASIC_AUTH_PASSWORD}). * This is equivalent to AuthType.JAAS_ADMIN. * * @param resource The resource path to the GraphQL query/mutation diff --git a/itests/src/test/resources/etc/users.properties b/itests/src/test/resources/etc/users.properties index 377fe6a160..538906c38c 100644 --- a/itests/src/test/resources/etc/users.properties +++ b/itests/src/test/resources/etc/users.properties @@ -29,6 +29,6 @@ # and modifiable via the JAAS command group. These users reside in a JAAS domain # with the name "karaf". # -karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup -health = ${org.apache.unomi.healthcheck.password:-health},health +karaf = ${org.apache.unomi.security.root.password},_g_:admingroup +health = ${org.apache.unomi.healthcheck.password},health _g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_USER,ROLE_UNOMI_TENANT_ADMIN diff --git a/package/src/main/resources/bin/setenv b/package/src/main/resources/bin/setenv index e48561de4c..66fa7157de 100755 --- a/package/src/main/resources/bin/setenv +++ b/package/src/main/resources/bin/setenv @@ -50,6 +50,94 @@ MY_DIRNAME=`dirname $0` MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd` +# Fail fast when starting without admin/health passwords (no known defaults are shipped). +# +# An unset password does NOT resolve to "no account": ${env:...} with no value expands to the +# empty string, which Karaf's PropertiesLoginModule accepts as a valid (empty) password for the +# admin account. So "unset" and "blank" are the same failure, and both must be refused. +# +# This script is the gate for shell-launched Karaf; the Docker entrypoint covers containers. Any +# launcher that starts the JVM directly without sourcing this file (systemd units, Kubernetes +# command overrides, PaxExam) bypasses the check, so those deployments must set the passwords +# themselves. +_unomi_etc="${KARAF_ETC:-${MY_KARAF_HOME}/etc}" + +# Print the configured value of a property, searching custom.system.properties and the optional +# overlay it includes. Last assignment wins, comments ignored. Empty output means "not configured". +_unomi_configured_property() { + for _unomi_file in "${_unomi_etc}/custom.system.properties" "${_unomi_etc}/unomi.custom.system.properties"; do + [ -f "${_unomi_file}" ] || continue + sed -n "s|^[[:space:]]*$1[[:space:]]*=[[:space:]]*\(.*\)$|\1|p" "${_unomi_file}" + done | sed -e 's/[[:space:]]*$//' | grep -v '^$' | tail -n 1 +} + +# _unomi_require_password ENV_VAR_NAME PROPERTY_NAME SKIP_FLAG_NAME +# Returns 0 when a non-blank password is available, 1 when startup must be refused. +_unomi_require_password() { + eval _unomi_value=\"\${$1}\" + eval _unomi_skip=\"\${$3}\" + + [ -n "${_unomi_value}" ] && return 0 + + _unomi_configured=`_unomi_configured_property "$2"` + case "${_unomi_configured}" in + # An unresolved ${env:...} placeholder is not a configured password — it expands to empty. + *'${'*) _unomi_configured='' ;; + esac + [ -n "${_unomi_configured}" ] && return 0 + + if [ "${_unomi_skip}" = "true" ]; then + cat >&2 <&2 <before it reached JAAS. Asserting on the response status alone proves + * nothing here — every refusal path in this class ends in the same 401. + */ + AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig, + TenantService tenantService, + SecurityService securityService, + ExecutionContextManager executionContextManager, + JAASAuthenticationFilter jaasAuthenticationFilter) { this.restAuthenticationConfig = restAuthenticationConfig; this.tenantService = tenantService; this.securityService = securityService; this.executionContextManager = executionContextManager; + this.jaasAuthenticationFilter = jaasAuthenticationFilter; + } - // Build wrapped jaas filter - jaasAuthenticationFilter = new JAASAuthenticationFilter(); - jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER); - jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); - jaasAuthenticationFilter.setContextName(CONTEXT_NAME); - jaasAuthenticationFilter.setRealmName(REALM_NAME); + private static JAASAuthenticationFilter buildJaasFilter() { + JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter(); + jaasFilter.setRoleClassifier(ROLE_CLASSIFIER); + jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE); + jaasFilter.setContextName(CONTEXT_NAME); + jaasFilter.setRealmName(REALM_NAME); + return jaasFilter; } @Override @@ -133,6 +149,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException { unauthorized(requestContext); return; } + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); @@ -192,6 +211,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException { // For all other cases, try tenant private key first, then fall back to JAAS String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } // Try tenant private key authentication first String[] credentials = extractBasicAuthCredentials(authHeader); if (credentials != null && credentials.length == 2) { @@ -299,6 +321,9 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S // For private endpoints, require system administrator authentication (like V2) String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION); if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) { + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); // JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally). @@ -352,6 +377,45 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S unauthorized(requestContext); } + /** + * Rejects the request when the Basic credential about to be used carries an empty password. + *

+ * Defence in depth against a blank administrator password (UNOMI-972). If + * {@code org.apache.unomi.security.root.password} is unset, Karaf resolves it to the empty + * string and the shipped JAAS account authenticates with an empty password. {@code bin/karaf} + * and the Docker entrypoint refuse to start in that state, but they cannot cover every launcher + * (notably {@code karaf.bat}), so an empty credential is never accepted over REST either. + *

+ * Deliberately called at each point where a Basic credential is actually consumed rather than + * once at the top of {@link #filter}: anonymous traffic on public paths — and every path in V2 + * compatibility mode — ignores {@code Authorization} entirely, so rejecting up front would turn + * a stray or stale Basic header (a cached browser credential, an injecting proxy) into a 401 on + * a request that is supposed to succeed without authentication. + * + * @return {@code true} when the request has been aborted and the caller must return + */ + private boolean rejectBlankBasicAuthPassword(ContainerRequestContext requestContext, String authHeader) { + if (!hasBlankBasicAuthPassword(authHeader)) { + return false; + } + logger.warn("Rejecting Basic authentication with an empty password"); + unauthorized(requestContext); + return true; + } + + /** + * Whether a Basic {@code Authorization} header carries an empty password. A missing, malformed + * or non-Basic header is not treated as blank here — those are rejected by the normal + * authentication paths instead. + */ + boolean hasBlankBasicAuthPassword(String authHeader) { + if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) { + return false; + } + String[] credentials = extractBasicAuthCredentials(authHeader); + return credentials != null && credentials.length == 2 && credentials[1].isEmpty(); + } + private String[] extractBasicAuthCredentials(String authHeader) { try { String base64Credentials = authHeader.substring(BASIC_AUTH_PREFIX.length()).trim(); diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java new file mode 100644 index 0000000000..a219885c30 --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java @@ -0,0 +1,243 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.rest.authentication; + +import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter; +import org.apache.unomi.api.security.SecurityService; +import org.apache.unomi.api.services.ExecutionContextManager; +import org.apache.unomi.api.tenants.ApiKey; +import org.apache.unomi.api.tenants.TenantService; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.ArgumentCaptor; + +import javax.ws.rs.container.ContainerRequestContext; +import javax.ws.rs.core.HttpHeaders; +import javax.ws.rs.core.Response; +import javax.ws.rs.core.UriInfo; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.regex.Pattern; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * A blank {@code UNOMI_ROOT_PASSWORD} leaves the shipped JAAS administrator with an empty password + * that {@code PropertiesLoginModule} accepts. {@code bin/karaf} and the Docker entrypoint refuse to + * start in that state, but they cannot cover every launcher, so the REST layer must never accept an + * empty credential either. + *

+ * Covers both the predicate and its wiring into {@link AuthenticationFilter#filter}: the check is + * applied where a Basic credential is consumed, and must NOT reject anonymous traffic on public + * paths that ignores {@code Authorization} altogether. + */ +class AuthenticationFilterBlankPasswordTest { + + private RestAuthenticationConfig restAuthenticationConfig; + private TenantService tenantService; + private JAASAuthenticationFilter jaasAuthenticationFilter; + private AuthenticationFilter filter; + + @BeforeEach + void setUp() { + restAuthenticationConfig = mock(RestAuthenticationConfig.class); + tenantService = mock(TenantService.class); + // Stubbed so the tests below can assert the credential never reached JAAS. Every refusal + // path in the filter answers 401, so the status alone cannot tell "refused for a blank + // password" apart from "JAAS rejected it" — only this can. + jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class); + filter = new AuthenticationFilter( + restAuthenticationConfig, + tenantService, + mock(SecurityService.class), + mock(ExecutionContextManager.class), + jaasAuthenticationFilter); + } + + @Test + void emptyPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic("karaf:"))); + } + + @Test + void emptyUserAndPasswordIsRejected() { + assertTrue(filter.hasBlankBasicAuthPassword(basic(":"))); + } + + @Test + void realPasswordIsAccepted() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf:a-strong-password"))); + } + + /** + * A password consisting of spaces is a real (if terrible) password, not the blank-resolution + * failure this guard exists for — leave it to the realm. + */ + @Test + void whitespacePasswordIsNotTreatedAsBlank() { + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf: "))); + } + + @Test + void missingOrNonBasicHeadersAreLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword(null)); + assertFalse(filter.hasBlankBasicAuthPassword("Bearer some-token")); + } + + @Test + void malformedHeaderIsLeftToTheNormalPaths() { + assertFalse(filter.hasBlankBasicAuthPassword("Basic not-base64!!")); + // No colon at all: cannot be split into user and password. + assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf"))); + } + + // ------------------------------------------------------------------ filter() wiring + + /** + * The predicate being correct is not enough: if the call site were dropped or moved after an + * earlier {@code return}, only this test would notice. + */ + @Test + void filterRejectsBlankPasswordOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control: a non-blank credential on the same path must still be handed to JAAS to judge. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws IOException { + ContainerRequestContext requestContext = request("tenants", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * V2 compatibility mode routes every request through {@link AuthenticationFilter}'s own + * private-endpoint branch, which consumes the Basic credential at a third, separate call site. + * Without this test that call site is unreachable from the suite: the other tests leave + * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting + * the guard there would leave every test green. + */ + @Test + void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:")); + + filter.filter(requestContext); + + assertUnauthorizedWithoutReachingJaas(requestContext); + } + + /** Control for the V2 branch: a non-blank credential must still reach JAAS there too. */ + @Test + void filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList()); + ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password")); + + filter.filter(requestContext); + + verify(jaasAuthenticationFilter).filter(requestContext); + } + + /** + * A public path in V2 compatibility mode authenticates by default tenant, ignoring + * {@code Authorization} entirely — so a stray blank Basic header must not turn it into a 401. + */ + @Test + void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() throws IOException { + when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true); + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default"); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenant("default"); + } + + private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext requestContext) throws IOException { + ArgumentCaptor aborted = ArgumentCaptor.forClass(Response.class); + verify(requestContext).abortWith(aborted.capture()); + assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), aborted.getValue().getStatus()); + verify(jaasAuthenticationFilter, never()).filter(any()); + } + + /** + * Regression guard for the placement bug: the check used to run at the top of {@code filter()}, + * so anonymous traffic carrying a stray Basic header — a stale cached browser credential, an + * injecting proxy — was rejected before the public-path branch could authenticate it by API key. + *

+ * Asserted by observing that the public-path branch is still reached (the API key is looked up) + * rather than by asserting no abort: a public path whose API key does not resolve legitimately + * falls through and is refused for that unrelated reason, and authenticating one successfully + * needs a live CXF exchange, which is out of scope for a unit test. + */ + @Test + void filterConsultsThePublicPathBranchDespiteAStrayBlankBasicHeader() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", basic("someone:")); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + /** A public path with no Authorization header at all must equally reach the API-key lookup. */ + @Test + void filterConsultsThePublicPathBranchForAnonymousRequests() throws IOException { + when(restAuthenticationConfig.getPublicPathPatterns()) + .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json"))); + ContainerRequestContext requestContext = request("context.json", null); + + filter.filter(requestContext); + + verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC)); + } + + private ContainerRequestContext request(String path, String authHeader) { + ContainerRequestContext requestContext = mock(ContainerRequestContext.class); + UriInfo uriInfo = mock(UriInfo.class); + when(uriInfo.getPath()).thenReturn(path); + when(requestContext.getUriInfo()).thenReturn(uriInfo); + when(requestContext.getMethod()).thenReturn("POST"); + when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authHeader); + return requestContext; + } + + private static String basic(String credentials) { + return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8)); + } +} diff --git a/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java new file mode 100644 index 0000000000..b44e9fea0c --- /dev/null +++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java @@ -0,0 +1,428 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.rest.config; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.io.TempDir; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.Paths; +import java.util.ArrayList; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.stream.Collectors; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +/** + * Ensures the distribution cannot start with a known or blank admin/health password. + *

+ * The launcher guards are executed here rather than grepped: a check whose text is present + * but whose condition never matches would otherwise pass silently, which is exactly how the Windows + * {@code KARAF_SCRIPT} quoting bug survived review. + */ +class ShippedAdminPasswordConfigTest { + + private static final String ROOT_PASSWORD_PROPERTY = "org.apache.unomi.security.root.password"; + private static final String HEALTHCHECK_PASSWORD_PROPERTY = "org.apache.unomi.healthcheck.password"; + + /** + * The two files this test executes. They double as the fingerprint of the repository root (see + * {@link #locateRepoRoot()}): a directory containing both is the Unomi checkout, not some nested + * copy or a same-named directory further up the filesystem. + */ + private static final String SETENV_PATH = "package/src/main/resources/bin/setenv"; + private static final String ENTRYPOINT_PATH = "docker/src/main/docker/entrypoint.sh"; + + /** First line of the guard to copy out of entrypoint.sh. */ + private static final String ENTRYPOINT_GUARD_START = "check_required_password()"; + /** Last line of the guard; the slice is meaningless unless this is actually reached. */ + private static final String ENTRYPOINT_GUARD_END = "UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1"; + + /** Default stub launcher name, mirroring {@code bin/karaf}. */ + private static final String DEFAULT_KARAF_SCRIPT = "karaf"; + + private static final Path REPO_ROOT = locateRepoRoot(); + + // ---------------------------------------------------------------- shipped configuration + + @Test + void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/etc/users.properties")); + + assertFalse(content.contains(":-karaf"), "users.properties must not default the karaf password to 'karaf'"); + assertFalse(content.contains(":-health"), "users.properties must not default the health password to 'health'"); + assertTrue(content.contains("${" + ROOT_PASSWORD_PROPERTY + "}")); + assertTrue(content.contains("${" + HEALTHCHECK_PASSWORD_PROPERTY + "}")); + } + + @Test + void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() throws Exception { + List securityLines = Files.readAllLines(repoFile("package/src/main/resources/etc/custom.system.properties")) + .stream() + .filter(line -> line.contains(ROOT_PASSWORD_PROPERTY) || line.contains(HEALTHCHECK_PASSWORD_PROPERTY)) + .collect(Collectors.toList()); + + assertFalse(securityLines.isEmpty()); + for (String line : securityLines) { + assertFalse(line.contains(":-karaf"), "root password must not fall back to karaf: " + line); + assertFalse(line.contains(":-health"), "health password must not fall back to health: " + line); + } + } + + @Test + void profileCookieHttpOnly_defaultsToTrue() throws Exception { + String webCfg = Files.readString(repoFile("web-servlets/src/main/resources/org.apache.unomi.web.cfg")); + assertTrue(webCfg.matches("(?s).*profileIdCookieHttpOnly=\\$\\{[^}]*:-true}.*"), + "profileId cookie HttpOnly should default to true"); + + String systemProps = Files.readString(repoFile("package/src/main/resources/etc/custom.system.properties")); + assertTrue(systemProps.matches("(?s).*org\\.apache\\.unomi\\.profile\\.cookie\\.httpOnly=\\$\\{[^}]*:-true}.*"), + "custom.system.properties must default profile cookie HttpOnly to true"); + } + + // ---------------------------------------------------------------- bin/setenv, executed + + @Test + void setenv_refusesToStartWhenPasswordsMissing(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(1, missing.exitCode, "setenv must refuse to start without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached"); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The outer {@code case "${KARAF_SCRIPT}"} in bin/setenv lists every shipped script that boots a + * server: {@code bin/karaf}, {@code bin/start} and {@code bin/karaf server} all reach + * {@code org.apache.karaf.main.Main}. Only {@code karaf} used to be exercised here, so narrowing + * that list to a single entry would have gone unnoticed - now each entry has its own test case. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"karaf", "start", "server"}) + void setenv_refusesToStartForEveryServerStartingScript(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result missing = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(1, missing.exitCode, + "bin/" + karafScript + " starts a server, so it must refuse to run without passwords:\n" + missing.output); + assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached for KARAF_SCRIPT=" + karafScript); + assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output); + } + + /** + * The dedicated {@code bin/stop}, {@code bin/status}, {@code bin/client} and {@code bin/shell} + * scripts set {@code KARAF_SCRIPT} to their own name. None of them starts a server - they talk to + * an already running one - so the outer case must keep excluding them, otherwise stopping an + * instance would require the passwords used to start it. + */ + @ParameterizedTest(name = "KARAF_SCRIPT={0}") + @ValueSource(strings = {"stop", "status", "client", "shell"}) + void setenv_doesNotGuardScriptsThatOnlyTalkToARunningServer(String karafScript, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(karafScript)); + installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, karafScript, new HashMap<>()); + assertEquals(0, result.exitCode, "bin/" + karafScript + " must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + @Test + void setenv_startsWhenPasswordsProvided(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + + Result provided = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("LAUNCHED"), provided.output); + } + + /** + * The escape hatch only suppresses the environment-variable check. Configuring the property + * directly is a supported way to start, and must not be reported as an error. + */ + @Test + void setenv_acceptsPasswordsConfiguredInPropertiesFile(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "configured-root", "configured-health"); + + Result configured = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>()); + assertEquals(0, configured.exitCode, configured.output); + assertTrue(configured.output.contains("LAUNCHED"), configured.output); + } + + /** + * Claiming the password is set elsewhere, while leaving it blank everywhere, is the dangerous + * case: it must warn rather than pass silently. + */ + @Test + void setenv_warnsWhenSkipFlagHidesABlankPassword(@TempDir Path karafHome) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Map env = new HashMap<>(); + env.put("UNOMI_SKIP_ROOT_PASSWORD_CHECK", "true"); + env.put("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK", "true"); + + Result skipped = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env); + assertEquals(0, skipped.exitCode, skipped.output); + assertTrue(skipped.output.contains("WARNING"), "a bypassed check must still warn:\n" + skipped.output); + } + + /** + * {@code bin/karaf stop} and {@code bin/karaf status} keep {@code KARAF_SCRIPT=karaf} but replace + * the main class with {@code Main.Stop} / {@code Main.Status}: no server is started, so no + * password is needed. Requiring one would make an instance impossible to shut down cleanly from a + * shell that no longer has the startup environment. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"stop", "status"}) + void setenv_doesNotBlockSubcommandsThatDoNotStartAServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand)); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(0, result.exitCode, "'karaf " + subcommand + "' must not require the passwords:\n" + result.output); + assertTrue(result.output.contains("LAUNCHED"), result.output); + } + + /** + * Regression guard for a real bug: the inner skip list once read {@code stop|status|client|shell}, + * which silently disabled the password check for {@code bin/karaf client} and + * {@code bin/karaf shell}. Those subcommands are not the {@code bin/client} / + * {@code bin/shell} remote consoles - the karaf script does not special-case them, so they fall + * through to {@code org.apache.karaf.main.Main} and boot a complete server, admin account + * included. The same is true of any argument the script does not recognise. Only {@code stop} and + * {@code status} may skip the check; everything else here must still be refused. + */ + @ParameterizedTest(name = "bin/karaf {0}") + @ValueSource(strings = {"client", "shell", "console", "--an-argument-karaf-does-not-know"}) + void setenv_stillGuardsSubcommandsThatBootAFullServer(String subcommand, @TempDir Path tempDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path karafHome = Files.createDirectories(tempDir.resolve(subcommand.replace("-", "_"))); + installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}"); + + Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand); + assertEquals(1, result.exitCode, + "'karaf " + subcommand + "' starts a full server, so it must refuse to run without passwords:\n" + result.output); + assertFalse(result.output.contains("LAUNCHED"), "the launcher must not be reached for 'karaf " + subcommand + "'"); + assertTrue(result.output.contains("UNOMI_ROOT_PASSWORD is not set"), result.output); + } + + // ---------------------------------------------------------------- docker entrypoint, executed + + @Test + void entrypoint_refusesToStartWhenPasswordsMissing(@TempDir Path workDir) throws Exception { + assumeTrue(hasPosixShell(), "requires a POSIX shell"); + Path guard = extractEntrypointPasswordGuard(workDir); + + assertEquals(1, runShell(guard, workDir, new HashMap<>()).exitCode, + "the Docker entrypoint must exit non-zero without passwords"); + + Map env = new HashMap<>(); + env.put("UNOMI_ROOT_PASSWORD", "a-strong-password"); + env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password"); + Result provided = runShell(guard, workDir, env); + assertEquals(0, provided.exitCode, provided.output); + assertTrue(provided.output.contains("GUARD-PASSED"), provided.output); + } + + // ---------------------------------------------------------------- setenv.bat regression guard + + /** + * {@code karaf.bat} sets {@code KARAF_SCRIPT} with the quotes included in the value + * ({@code SET KARAF_SCRIPT="karaf.bat"}), so comparing {@code "%KARAF_SCRIPT%"} against + * {@code "karaf.bat"} never matches and the whole check is skipped. The quotes must be stripped + * before comparing. This cannot be executed on a POSIX CI machine, so assert the shape instead. + */ + @Test + void setenvBat_stripsQuotesBeforeComparingKarafScript() throws Exception { + String content = Files.readString(repoFile("package/src/main/resources/bin/setenv.bat")); + + assertTrue(content.contains("%KARAF_SCRIPT:\"=%"), + "setenv.bat must strip the quotes karaf.bat embeds in KARAF_SCRIPT"); + assertFalse(content.contains("\"%KARAF_SCRIPT%\"==\"karaf.bat\""), + "comparing the raw KARAF_SCRIPT against karaf.bat never matches"); + assertTrue(content.contains("UNOMI_ROOT_PASSWORD") && content.contains("UNOMI_HEALTHCHECK_PASSWORD"), + "setenv.bat must check both passwords"); + } + + // ---------------------------------------------------------------- helpers + + /** + * Finds the repository root by walking up from the working directory until an ancestor holds + * both shipped launcher scripts this test executes. Matching on a single relative path + * would let a nested checkout (or any unrelated directory that happens to contain a + * {@code package/} tree) win, and the test would then silently assert against the wrong sources. + */ + private static Path locateRepoRoot() { + Path start = Paths.get("").toAbsolutePath().normalize(); + for (Path candidate = start; candidate != null; candidate = candidate.getParent()) { + if (Files.isRegularFile(candidate.resolve(SETENV_PATH)) + && Files.isRegularFile(candidate.resolve(ENTRYPOINT_PATH))) { + return candidate; + } + } + throw new IllegalStateException("Could not locate the Unomi repository root from " + start + + " (looked for an ancestor containing both " + SETENV_PATH + " and " + ENTRYPOINT_PATH + ")"); + } + + /** + * Resolves a repository-relative path against the detected repository root, so the test works + * regardless of which module directory the build runs it from. + */ + private static Path repoFile(String relativePath) { + Path resolved = REPO_ROOT.resolve(relativePath); + if (!Files.exists(resolved)) { + throw new IllegalStateException("Could not locate " + relativePath + " under repository root " + REPO_ROOT); + } + return resolved; + } + + private static boolean hasPosixShell() { + return !System.getProperty("os.name", "").toLowerCase().contains("win"); + } + + /** + * Builds a throwaway Karaf layout containing the real {@code bin/setenv} plus a stub launcher + * that mimics how the shipped scripts source it. {@code karafScript} names the stub and the value + * it exports as {@code KARAF_SCRIPT}, so tests can reproduce {@code bin/karaf}, {@code bin/start}, + * {@code bin/stop}, ... rather than only ever exercising {@code karaf}. + */ + private static void installFakeKarafHome(Path karafHome, String karafScript, String rootPassword, String healthPassword) + throws IOException { + Path bin = Files.createDirectories(karafHome.resolve("bin")); + Path etc = Files.createDirectories(karafHome.resolve("etc")); + + Files.copy(repoFile(SETENV_PATH), bin.resolve("setenv")); + Files.write(etc.resolve("custom.system.properties"), + (ROOT_PASSWORD_PROPERTY + "=" + rootPassword + "\n" + + HEALTHCHECK_PASSWORD_PROPERTY + "=" + healthPassword + "\n").getBytes(StandardCharsets.UTF_8)); + + // Mirrors apache-karaf/bin/ + + + +

+

Login integration sample

+ +
+ +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ +
+ +

+ To test merge: login once, note profileId, clear the context-profile-id cookie + (or use a private window), login again with the same email — you should get the same master profile. +

+ +
+
How this sample works
+
+
    +
  1. This page posts the form to a server-side servlet + (/login/authenticate) — not to /cxs/context.json.
  2. +
  3. The servlet checks the demo password setup.sh generated and stored as + demoPassword, then calls Unomi with trusted Basic + credentials (a tenant private key).
  4. +
  5. The bundled exampleLogin rule merges on email + mergeProfilesOnPropertyAction (trusted callers only, UNOMI-972).
  6. +
+
+
+
+ + diff --git a/samples/login-integration/src/main/resources/static/javascript/login-example.js b/samples/login-integration/src/main/resources/static/javascript/login-example.js new file mode 100644 index 0000000000..bbc82fac2d --- /dev/null +++ b/samples/login-integration/src/main/resources/static/javascript/login-example.js @@ -0,0 +1,58 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +(function () { + // No session id is generated or sent from the browser. /login/authenticate calls Unomi with + // trusted credentials, and a trusted caller is allowed to adopt the profile that owns the + // session id it passes, so the id must come from server-side state only (the servlet derives + // it from its own HttpSession). Sending a client-chosen id here would let anyone rebind + // another visitor's profile. + + function show(ok, message) { + var cls = ok ? "alert-success" : "alert-danger"; + $("#alert_placeholder").html( + '' + ); + $("#alert_placeholder .alert span").text(message); + } + + $(function () { + $("#loginForm").on("submit", function (event) { + event.preventDefault(); + $.ajax({ + url: "/login/authenticate", + type: "POST", + data: { + firstName: $("#firstname").val(), + lastName: $("#lastname").val(), + email: $("#email").val(), + password: $("#password").val() + }, + dataType: "json" + }).done(function (body) { + var email = body.profileProperties && body.profileProperties.email; + show(true, "OK — profileId=" + body.profileId + + (email ? (", email=" + email) : "") + + ". Clear context-profile-id and login again with the same email to verify merge."); + }).fail(function (xhr) { + var body = xhr.responseJSON || {}; + var msg = body.error || body.errorMessage || xhr.responseText || ("HTTP " + xhr.status); + show(false, msg); + }); + return false; + }); + }); +})(); diff --git a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java new file mode 100644 index 0000000000..c575e93958 --- /dev/null +++ b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java @@ -0,0 +1,353 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.samples.login; + +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpSession; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.HashMap; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertNotEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertNull; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.atMostOnce; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.times; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +/** + * Unit tests for the security-relevant helpers of {@link LoginServlet}. + *

+ * The methods under test are package-private (rather than private) purely so these tests can call + * them directly instead of reaching through reflection; they are not part of any public API. + */ +class LoginServletTest { + + // ------------------------------------------------------------------------------------------ + // resolveSessionId — the trust boundary. A client-supplied session id must never be honoured. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("regression guard: a client-supplied sessionId parameter is ignored entirely") + void clientSuppliedSessionIdParameterIsIgnored() { + String attackerSuppliedId = "victim-session-id-we-want-to-hijack"; + HttpSession session = statefulSession(); + HttpServletRequest req = requestWithSession(session); + // Simulate every channel an attacker controls: query/form parameters and headers. + when(req.getParameter(anyString())).thenReturn(attackerSuppliedId); + when(req.getHeader(anyString())).thenReturn(attackerSuppliedId); + + String resolved = LoginServlet.resolveSessionId(req); + + assertNotEquals(attackerSuppliedId, resolved, + "the servlet must not adopt a session id supplied by the caller"); + // Stronger than comparing values: prove the request's attacker-controlled surface is never + // even consulted, so no future refactor can quietly reintroduce the vulnerability. + verify(req, never()).getParameter(anyString()); + verify(req, never()).getParameterValues(anyString()); + verify(req, never()).getHeader(anyString()); + verify(req, never()).getCookies(); + } + + @Test + @DisplayName("the generated session id is a server-side random UUID stored on the container session") + void generatedSessionIdIsARandomUuidStoredOnTheSession() { + HttpSession session = statefulSession(); + + String resolved = LoginServlet.resolveSessionId(requestWithSession(session)); + + assertNotNull(resolved); + assertDoesNotThrow(() -> UUID.fromString(resolved), "expected a random UUID, got: " + resolved); + assertEquals(resolved, session.getAttribute("org.apache.unomi.samples.login.unomiSessionId"), + "the resolved id must be the one persisted on the container session"); + } + + @Test + @DisplayName("the same browser session yields a stable session id across calls") + void sameSessionYieldsStableSessionId() { + HttpSession session = statefulSession(); + + String first = LoginServlet.resolveSessionId(requestWithSession(session)); + String second = LoginServlet.resolveSessionId(requestWithSession(session)); + String third = LoginServlet.resolveSessionId(requestWithSession(session)); + + assertEquals(first, second); + assertEquals(first, third); + } + + @Test + @DisplayName("two different browser sessions yield different session ids") + void differentSessionsYieldDifferentSessionIds() { + String first = LoginServlet.resolveSessionId(requestWithSession(statefulSession())); + String second = LoginServlet.resolveSessionId(requestWithSession(statefulSession())); + + assertNotEquals(first, second); + } + + @Test + @DisplayName("sessions created by this servlet get a short idle timeout so they cannot accumulate") + void createdSessionsAreGivenAShortIdleTimeout() { + HttpSession session = statefulSession(); + + LoginServlet.resolveSessionId(requestWithSession(session)); + + verify(session).setMaxInactiveInterval(intThatIsAShortTimeout()); + } + + @Test + @DisplayName("the idle timeout is applied only when the id is first created, not on every request") + void idleTimeoutIsAppliedOnlyOnFirstUse() { + HttpSession session = statefulSession(); + + LoginServlet.resolveSessionId(requestWithSession(session)); + LoginServlet.resolveSessionId(requestWithSession(session)); + LoginServlet.resolveSessionId(requestWithSession(session)); + + verify(session, atMostOnce()).setMaxInactiveInterval(anyInt()); + } + + @Test + @DisplayName("an existing container session is reused rather than replaced") + void existingSessionIsReused() { + HttpSession session = statefulSession(); + HttpServletRequest req = requestWithSession(session); + + LoginServlet.resolveSessionId(req); + + // getSession(true) is correct: the servlet needs a session to exist. What must not happen is + // the servlet inventing a second identity source. + verify(req, times(1)).getSession(anyBoolean()); + } + + // ------------------------------------------------------------------------------------------ + // isSameOrigin — lightweight CSRF defence. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("a matching origin is accepted") + void sameOriginIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("an http origin with no explicit port matches port 80") + void defaultHttpPortIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com", "http", "example.com", 80))); + } + + @Test + @DisplayName("an https origin with no explicit port matches port 443") + void defaultHttpsPortIsAccepted() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("https://example.com", "https", "example.com", 443))); + } + + @Test + @DisplayName("origin comparison is case-insensitive on scheme and host") + void originComparisonIsCaseInsensitive() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin("HTTP://Example.COM:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different host is rejected") + void differentHostIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://evil.example.net:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different port is rejected") + void differentPortIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com:9090", "http", "example.com", 8181))); + } + + @Test + @DisplayName("an implicit default port that does not match the served port is rejected") + void implicitDefaultPortMismatchIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://example.com", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a different scheme is rejected") + void differentSchemeIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("https://example.com:8181", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a missing Origin header is tolerated") + void missingOriginIsTolerated() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin(null, "http", "example.com", 8181))); + } + + @Test + @DisplayName("a blank Origin header is tolerated") + void blankOriginIsTolerated() { + assertTrue(LoginServlet.isSameOrigin( + requestWithOrigin(" ", "http", "example.com", 8181))); + } + + @Test + @DisplayName("the opaque \"null\" origin sent by sandboxed frames is rejected") + void opaqueNullOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("null", "http", "example.com", 8181)), + "the literal string \"null\" is an opaque origin, not a missing header"); + } + + @Test + @DisplayName("an unparsable Origin header is rejected") + void unparsableOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("http://exa mple.com", "http", "example.com", 8181))); + } + + @Test + @DisplayName("a syntactically valid but host-less Origin is rejected") + void hostlessOriginIsRejected() { + assertFalse(LoginServlet.isSameOrigin( + requestWithOrigin("file:///etc/passwd", "http", "example.com", 8181))); + } + + // ------------------------------------------------------------------------------------------ + // headerValues — multi-value, case-insensitive Set-Cookie forwarding. + // ------------------------------------------------------------------------------------------ + + @Test + @DisplayName("headerValues returns every value of a repeated header") + void headerValuesReturnsAllValues() throws Exception { + Map> headers = new LinkedHashMap<>(); + headers.put("Set-Cookie", Arrays.asList("context-profile-id=p1; Path=/", "context-session-id=s1; Path=/")); + + List values = LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie"); + + assertEquals(Arrays.asList("context-profile-id=p1; Path=/", "context-session-id=s1; Path=/"), values); + } + + @Test + @DisplayName("headerValues matches the header name case-insensitively") + void headerValuesMatchesNameCaseInsensitively() throws Exception { + Map> headers = new LinkedHashMap<>(); + // Servers are free to use any casing; HttpURLConnection preserves what came off the wire. + headers.put("set-cookie", new ArrayList<>(Arrays.asList("a=1", "b=2"))); + + List values = LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie"); + + assertEquals(Arrays.asList("a=1", "b=2"), values); + } + + @Test + @DisplayName("headerValues tolerates the null status-line key and returns null for an absent header") + void headerValuesReturnsNullWhenAbsent() throws Exception { + Map> headers = new LinkedHashMap<>(); + // HttpURLConnection.getHeaderFields() maps the HTTP status line under a null key. + headers.put(null, Arrays.asList("HTTP/1.1 200 OK")); + headers.put("Content-Type", Arrays.asList("application/json")); + + assertNull(LoginServlet.headerValues(connectionWithHeaders(headers), "Set-Cookie")); + } + + // ------------------------------------------------------------------------------------------ + // Fakes / helpers + // ------------------------------------------------------------------------------------------ + + /** A mock {@link HttpSession} with real attribute storage, so id stability can be observed. */ + private static HttpSession statefulSession() { + HttpSession session = mock(HttpSession.class); + Map attributes = new HashMap<>(); + when(session.getAttribute(anyString())).thenAnswer(inv -> attributes.get(inv.getArgument(0))); + doAnswer(inv -> { + attributes.put(inv.getArgument(0), inv.getArgument(1)); + return null; + }).when(session).setAttribute(anyString(), any()); + return session; + } + + private static HttpServletRequest requestWithSession(HttpSession session) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getSession(anyBoolean())).thenReturn(session); + return req; + } + + private static HttpServletRequest requestWithOrigin(String origin, String scheme, String serverName, int port) { + HttpServletRequest req = mock(HttpServletRequest.class); + when(req.getHeader("Origin")).thenReturn(origin); + when(req.getScheme()).thenReturn(scheme); + when(req.getServerName()).thenReturn(serverName); + when(req.getServerPort()).thenReturn(port); + return req; + } + + private static HttpURLConnection connectionWithHeaders(Map> headers) throws Exception { + return new HttpURLConnection(new URL("http://localhost:8181/cxs/context.json")) { + @Override + public Map> getHeaderFields() { + return headers; + } + + @Override + public void connect() { + // never actually connects + } + + @Override + public void disconnect() { + // nothing to release + } + + @Override + public boolean usingProxy() { + return false; + } + }; + } + + /** + * Matches any timeout that is positive and no longer than ten minutes: the exact value is a + * tuning detail, but "short and bounded" is the security property we care about. + */ + private static int intThatIsAShortTimeout() { + return org.mockito.ArgumentMatchers.intThat(seconds -> seconds > 0 && seconds <= 600); + } +} From cc7728a53a9b20cb6c16a4ba0dd4c34076b3b533 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 10 Aug 2026 09:27:28 +0200 Subject: [PATCH 7/9] UNOMI-972: prove the hardening end to end, and pin what it must not break Adds the integration coverage for the fixes above, plus a before/after baseline for the two public client endpoints, which is the part that answers whether existing clients still work. ContextEndpointBaselineIT is written to compile and run against both master and this branch and is split into two groups with opposite expectations. Run on both, it gives: compat_* (7) master pass / branch pass hardened_publicBodyProfileIdIsIgnored master FAIL / branch pass hardened_publicCallerCannotAdoptAForeign* master FAIL / branch pass On master the first hardened test returns the victim's profile identifier and properties to a caller holding only the public API key - the reported issue reproduced end to end - and the branch answers 400. The compat group deliberately covers the client entry points that had no coverage at all: the GET forms carrying ?payload=, which is how a script tag or image beacon tracks and which route through the same binding code as POST. Also pins the areas a future ownership check is most likely to break, none of which was covered anywhere: all four branches of the anonymous-browsing handling, and persona binding. Personas short-circuit binding entirely and profileOverrides only apply to a Persona, so both are structurally isolated from these changes - now asserted rather than assumed. RestEndpointRoleSecurityIT covers the Groovy and router role gates over real HTTP; ProfileMergeIT and PropertiesUpdateActionIT cover the action gates. Every new IT is registered in AllITs and CorePersistenceITs, without which failsafe silently never runs them. Co-Authored-By: Claude Opus 5 (1M context) --- .../java/org/apache/unomi/itests/AllITs.java | 3 + .../itests/ContextEndpointBaselineIT.java | 289 +++++++++ .../apache/unomi/itests/ContextServletIT.java | 606 +++++++++++++++++- .../unomi/itests/CorePersistenceITs.java | 3 + .../apache/unomi/itests/ProfileMergeIT.java | 65 ++ .../itests/PropertiesUpdateActionIT.java | 76 +++ .../itests/RestEndpointRoleSecurityIT.java | 177 +++++ 7 files changed, 1199 insertions(+), 20 deletions(-) create mode 100644 itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java create mode 100644 itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java b/itests/src/test/java/org/apache/unomi/itests/AllITs.java index 41351e5b02..a17afe29a1 100644 --- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java @@ -56,11 +56,14 @@ ModifyConsentIT.class, PatchIT.class, ContextServletIT.class, + ContextEndpointBaselineIT.class, SecurityIT.class, RuleServiceIT.class, PrivacyServiceIT.class, GroovyActionsServiceIT.class, + RestEndpointRoleSecurityIT.class, GraphQLEventIT.class, + GraphQLServletSecurityIT.class, GraphQLListIT.class, GraphQLProfileIT.class, GraphQLProfilePropertiesIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java new file mode 100644 index 0000000000..52da5035fc --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java @@ -0,0 +1,289 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.itests; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.apache.http.util.EntityUtils; +import org.apache.unomi.api.ContextRequest; +import org.apache.unomi.api.Event; +import org.apache.unomi.api.EventsCollectorRequest; +import org.apache.unomi.api.CustomItem; +import org.apache.unomi.api.Profile; +import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerSuite; + +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; +import java.util.Collections; +import java.util.Objects; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * Before/after behavioural baseline for the two public client endpoints, {@code /cxs/context.json} + * (plus its {@code /cxs/context.js} sibling) and {@code /cxs/eventcollector}. + *

+ * This class is deliberately written to compile and run against both the pre-hardening + * baseline and the hardened branch, so the same suite can be executed on each and the results + * diffed. It is split into two groups with opposite expectations: + *

    + *
  • compat_* — legacy client behaviour that MUST be identical before and after. A + * failure here on the hardened branch is a compatibility regression, full stop.
  • + *
  • hardened_* — behaviour the hardening intentionally changes. These are expected to + * FAIL on the pre-hardening baseline and PASS after; that contrast is the evidence the + * security fix actually does something.
  • + *
+ * The compat group covers the client entry points that had no coverage at all: the {@code GET} + * forms carrying a {@code ?payload=} query parameter, which is how a script tag or image beacon + * tracks, and which route through exactly the same binding code as the POST forms. + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerSuite.class) +public class ContextEndpointBaselineIT extends BaseIT { + + private static final String UNOMI_API_KEY_HTTP_HEADER_KEY = "X-Unomi-Api-Key"; + private static final String CONTEXT_JSON_URL = "/cxs/context.json"; + private static final String CONTEXT_JS_URL = "/cxs/context.js"; + private static final String EVENT_COLLECTOR_URL = "/cxs/eventcollector"; + private static final String TEST_SCOPE = "baseline-scope"; + + // ------------------------------------------------------------------ compatibility group + + /** A brand new visitor with no cookie and no session must still be issued a profile. */ + @Test + public void compat_firstVisitIssuesAProfileAndCookie() throws Exception { + String sessionId = "baseline-first-" + System.currentTimeMillis(); + TestUtils.RequestResponse response = postContextJson(newContextRequest(sessionId), null, sessionId); + + assertEquals(200, response.getStatusCode()); + assertNotNull("a first visit must be issued a profile id", response.getContextResponse().getProfileId()); + assertNotNull("a first visit must be issued the profile cookie", response.getCookieHeaderValue()); + } + + /** A returning visitor presenting the cookie must be recognised as the same profile. */ + @Test + public void compat_returningVisitorKeepsItsProfile() throws Exception { + String sessionId = "baseline-returning-" + System.currentTimeMillis(); + TestUtils.RequestResponse first = postContextJson(newContextRequest(sessionId), null, sessionId); + String profileId = first.getContextResponse().getProfileId(); + + TestUtils.RequestResponse second = postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), sessionId); + + assertEquals(200, second.getStatusCode()); + assertEquals("a returning visitor must keep its profile", profileId, second.getContextResponse().getProfileId()); + assertEquals("and its session", sessionId, second.getContextResponse().getSessionId()); + } + + /** The GET form with ?payload= must behave like the POST form. This entry point had no coverage. */ + @Test + public void compat_getWithPayloadBehavesLikePost() throws Exception { + String sessionId = "baseline-get-" + System.currentTimeMillis(); + TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId); + String profileId = established.getContextResponse().getProfileId(); + + HttpGet get = new HttpGet(getFullUrl(CONTEXT_JSON_URL) + "?payload=" + encode(newContextRequest(sessionId))); + get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + get.addHeader("Cookie", established.getCookieHeaderValue()); + TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(get, sessionId, getObjectMapper()); + + assertEquals(200, response.getStatusCode()); + assertEquals("GET ?payload= must resolve the same profile as POST", profileId, + response.getContextResponse().getProfileId()); + } + + /** /cxs/context.js must keep serving JavaScript to script-tag clients. */ + @Test + public void compat_contextJsServesJavaScript() throws Exception { + String sessionId = "baseline-js-" + System.currentTimeMillis(); + HttpGet get = new HttpGet(getFullUrl(CONTEXT_JS_URL) + "?sessionId=" + sessionId); + get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + + try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(get)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + String body = EntityUtils.toString(response.getEntity()); + // Same marker BasicIT asserts on: context.js emits the digitalData bootstrap that + // script-tag clients rely on. Asserting the marker, not just a 200, so an empty or + // error body cannot pass as success. + assertTrue("context.js must return the tracker javascript, got: " + + body.substring(0, Math.min(200, body.length())), + body.contains("window.digitalData")); + } + } + + /** Event collection over POST must keep working and report the event as processed. */ + @Test + public void compat_eventCollectorAcceptsEvents() throws Exception { + String sessionId = "baseline-ec-" + System.currentTimeMillis(); + TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId); + + HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL)); + post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + post.addHeader("Cookie", established.getCookieHeaderValue()); + post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(newEventsRequest(sessionId)), + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(post)) { + assertEquals("the eventcollector must keep accepting events from a cookie-bearing client", + 200, response.getStatusLine().getStatusCode()); + } + } + + /** The eventcollector GET form with ?payload= — another entry point that had no coverage. */ + @Test + public void compat_eventCollectorGetWithPayload() throws Exception { + String sessionId = "baseline-ecget-" + System.currentTimeMillis(); + TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId); + + HttpGet get = new HttpGet(getFullUrl(EVENT_COLLECTOR_URL) + "?payload=" + encode(newEventsRequest(sessionId))); + get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + get.addHeader("Cookie", established.getCookieHeaderValue()); + + try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(get)) { + assertEquals(200, response.getStatusLine().getStatusCode()); + } + } + + /** A client may continue its own session across requests without re-establishing it. */ + @Test + public void compat_sessionContinuityAcrossRequests() throws Exception { + String sessionId = "baseline-cont-" + System.currentTimeMillis(); + TestUtils.RequestResponse first = postContextJson(newContextRequest(sessionId), null, sessionId); + + for (int i = 0; i < 3; i++) { + TestUtils.RequestResponse next = postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), sessionId); + assertEquals(200, next.getStatusCode()); + assertEquals("the client's own session must never be refused", sessionId, + next.getContextResponse().getSessionId()); + } + } + + // ------------------------------------------------------------------ hardened group + // Expected to FAIL on the pre-hardening baseline and PASS after. That contrast is the point. + + /** + * A public caller must not be able to read another visitor's profile by naming it in the body. + *

+ * The attack request carries ONLY the body profileId - no cookie and no session - because that is + * what makes this discriminating. An earlier version of this test also sent a session owned by the + * caller, and on the pre-hardening baseline the session-recovery logic switched the profile back + * to the session owner, masking the body profileId entirely and making the test pass on both + * sides. Asserting on the victim's actual data rather than on an echoed id keeps it honest. + */ + @Test + public void hardened_publicBodyProfileIdIsIgnored() throws Exception { + String victimProfileId = "baseline-victim-" + System.currentTimeMillis(); + String victimSecret = "baseline-secret-" + System.currentTimeMillis(); + Profile victim = new Profile(victimProfileId); + victim.setProperty("baselineSecret", victimSecret); + profileService.save(victim); + keepTrying("Victim profile should be saved", () -> profileService.load(victimProfileId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + ContextRequest claim = new ContextRequest(); + claim.setProfileId(victimProfileId); + claim.setRequiredProfileProperties(Collections.singletonList("*")); + CustomItem source = new CustomItem("baseline-page", "page"); + source.setScope(TEST_SCOPE); + claim.setSource(source); + + HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL)); + post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(claim), ContentType.APPLICATION_JSON)); + + // Plain client, not HttpClientThatWaitsForUnomi: the hardened branch answers 400 here + // (nothing left to bind once the body profileId is ignored), and that helper retries + // non-2xx and then throws, which would mask the very behaviour under test. + try (CloseableHttpResponse response = httpClient.execute(post)) { + String body = response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity()); + assertFalse("a public caller must not receive the victim's profile properties, got: " + + body.substring(0, Math.min(300, body.length())), + body.contains(victimSecret)); + assertFalse("a public caller must not be bound to the victim's profile id", + body.contains(victimProfileId)); + } + } finally { + profileService.delete(victimProfileId, false); + } + } + + /** A public caller must not be able to adopt a session belonging to someone else. */ + @Test + public void hardened_publicCallerCannotAdoptAForeignSession() throws Exception { + String victimSessionId = "baseline-victim-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse victim = postContextJson(newContextRequest(victimSessionId), null, victimSessionId); + String victimProfileId = victim.getContextResponse().getProfileId(); + + String attackerSessionId = "baseline-attacker-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse attacker = postContextJson(newContextRequest(attackerSessionId), null, attackerSessionId); + + // Attacker presents the victim's session id with its own cookie. + TestUtils.RequestResponse hijack = postContextJson(newContextRequest(victimSessionId), + attacker.getCookieHeaderValue(), victimSessionId); + + assertEquals(200, hijack.getStatusCode()); + assertTrue("the attacker must not end up on the victim's profile", + !victimProfileId.equals(hijack.getContextResponse().getProfileId())); + } + + // ------------------------------------------------------------------ helpers + + private ContextRequest newContextRequest(String sessionId) { + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setSessionId(sessionId); + CustomItem source = new CustomItem("baseline-page", "page"); + source.setScope(TEST_SCOPE); + contextRequest.setSource(source); + return contextRequest; + } + + private EventsCollectorRequest newEventsRequest(String sessionId) { + Event event = new Event(); + event.setEventType("view"); + event.setScope(TEST_SCOPE); + EventsCollectorRequest eventsRequest = new EventsCollectorRequest(); + eventsRequest.setSessionId(sessionId); + eventsRequest.setEvents(Collections.singletonList(event)); + return eventsRequest; + } + + private TestUtils.RequestResponse postContextJson(ContextRequest contextRequest, String cookie, String sessionId) + throws Exception { + HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL)); + post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + if (cookie != null) { + post.addHeader("Cookie", cookie); + } + post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + return TestUtils.executeContextJSONRequest(post, sessionId, getObjectMapper()); + } + + private String encode(Object payload) throws Exception { + return URLEncoder.encode(getObjectMapper().writeValueAsString(payload), StandardCharsets.UTF_8.name()); + } +} diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java index e714ac218d..3e8d6e8f9a 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java @@ -11,7 +11,7 @@ * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language gtestCreateEventWithPropertiesValidation_Successoverning permissions and + * See the License for the specific language governing permissions and * limitations under the License */ @@ -34,6 +34,8 @@ import org.apache.http.client.config.RequestConfig; import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; import org.apache.unomi.api.segments.Scoring; import org.apache.unomi.api.segments.Segment; import org.apache.unomi.api.tenants.ApiKey; @@ -48,6 +50,8 @@ import org.ops4j.pax.exam.junit.PaxExam; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.net.URI; @@ -66,6 +70,8 @@ @RunWith(PaxExam.class) @ExamReactorStrategy(PerSuite.class) public class ContextServletIT extends BaseIT { + private final static Logger LOGGER = LoggerFactory.getLogger(ContextServletIT.class); + private final static String CONTEXT_URL = "/cxs/context.json"; private final static String UNOMI_API_KEY_HTTP_HEADER_KEY = "X-Unomi-Api-Key"; @@ -123,6 +129,22 @@ public void setUp() throws InterruptedException { @After public void tearDown() throws InterruptedException { + // The login-merge tests register this rule and remove it on their happy path, but an + // assertion failing earlier would leave it behind. The suite shares one Karaf container + // (PerSuite), so a stray rule reacting to every login event would leak into later tests. + // + // Guarded: if this threw, it would abort tearDown before the event/session cleanup below, + // silently polluting the shared container for every later test with a failure that looks + // unrelated. A rule that cannot be removed is worth reporting, not worth losing the rest + // of the cleanup over. + try { + if (rulesService.getRule("testLogin") != null) { + rulesService.removeRule("testLogin"); + } + } catch (RuntimeException e) { + LOGGER.warn("Could not remove the testLogin rule during tearDown; later tests in this " + + "suite may see it", e); + } persistenceService.refresh(); TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager); TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager); @@ -419,6 +441,511 @@ public void testCreateEventWithTimestampParam_futureEvent_profileIsNotAddedToSeg DEFAULT_SHOULDBETRUE_TRIES); } + @Test + public void testPublicCaller_mismatchedBodyProfileId_ignored() throws Exception { + String sessionId = "mismatch-session-" + System.currentTimeMillis(); + + ContextRequest firstRequest = new ContextRequest(); + firstRequest.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(firstRequest), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + assertEquals(200, established.getStatusCode()); + String cookieProfileId = established.getContextResponse().getProfileId(); + assertNotNull(cookieProfileId); + assertNotNull(established.getCookieHeaderValue()); + + String attackerProfileId = "attacker-body-profile-" + System.currentTimeMillis(); + ContextRequest mismatchRequest = new ContextRequest(); + mismatchRequest.setSessionId(sessionId); + mismatchRequest.setProfileId(attackerProfileId); + HttpPost mismatch = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(mismatch); + mismatch.addHeader("Cookie", established.getCookieHeaderValue()); + mismatch.setEntity(new StringEntity(getObjectMapper().writeValueAsString(mismatchRequest), ContentType.APPLICATION_JSON)); + RequestResponse mismatched = executeContextJSONRequest(mismatch, sessionId); + + assertEquals(200, mismatched.getStatusCode()); + assertEquals("Public caller must keep cookie profile when body profileId differs", + cookieProfileId, mismatched.getContextResponse().getProfileId()); + assertNull("Attacker-supplied profileId must not be created", profileService.load(attackerProfileId)); + } + + /** + * End-to-end guard for anonymous browsing. The session-ownership check added for public callers + * deliberately skips anonymous profiles today; any future tightening of it must not detach the + * session of a visitor who is legitimately browsing anonymously. That failure would be invisible + * at unit level in the endpoint wiring, hence this IT: it asserts the visitor's own session id is + * still echoed back (a refused session is suppressed from the response) after anonymisation. + */ + @Test + public void testAnonymousBrowsing_visitorKeepsItsOwnSession() throws Exception { + String sessionId = "anon-browsing-session-" + System.currentTimeMillis(); + + ContextRequest firstRequest = new ContextRequest(); + firstRequest.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(firstRequest), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + assertEquals(200, established.getStatusCode()); + String profileId = established.getContextResponse().getProfileId(); + assertNotNull(profileId); + assertNotNull(established.getCookieHeaderValue()); + + // Turn on anonymous browsing for this visitor, exactly as the privacy endpoint would. + privacyService.setRequireAnonymousBrowsing(profileId, true, TEST_SCOPE); + keepTrying("Anonymous browsing should be enabled for the profile", + () -> privacyService.isRequireAnonymousBrowsing(profileId), + Boolean.TRUE::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + // Same visitor, same cookie, same session: must still be served, and the session kept. + ContextRequest secondRequest = new ContextRequest(); + secondRequest.setSessionId(sessionId); + HttpPost anonymous = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(anonymous); + anonymous.addHeader("Cookie", established.getCookieHeaderValue()); + anonymous.setEntity(new StringEntity(getObjectMapper().writeValueAsString(secondRequest), ContentType.APPLICATION_JSON)); + RequestResponse anonymousResponse = executeContextJSONRequest(anonymous, sessionId); + + assertEquals(200, anonymousResponse.getStatusCode()); + assertNotNull("An anonymous visitor's own session must not be refused", + anonymousResponse.getContextResponse().getSessionId()); + assertEquals(sessionId, anonymousResponse.getContextResponse().getSessionId()); + + // And turning anonymity back off must keep working too (the de-anonymising branch). + privacyService.setRequireAnonymousBrowsing(profileId, false, TEST_SCOPE); + keepTrying("Anonymous browsing should be disabled again", + () -> privacyService.isRequireAnonymousBrowsing(profileId), + Boolean.FALSE::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + ContextRequest thirdRequest = new ContextRequest(); + thirdRequest.setSessionId(sessionId); + HttpPost deanonymised = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(deanonymised); + deanonymised.addHeader("Cookie", established.getCookieHeaderValue()); + deanonymised.setEntity(new StringEntity(getObjectMapper().writeValueAsString(thirdRequest), ContentType.APPLICATION_JSON)); + RequestResponse deanonymisedResponse = executeContextJSONRequest(deanonymised, sessionId); + + assertEquals(200, deanonymisedResponse.getStatusCode()); + assertNotNull("Leaving anonymous browsing must not refuse the visitor's own session", + deanonymisedResponse.getContextResponse().getSessionId()); + } finally { + privacyService.setRequireAnonymousBrowsing(profileId, false, TEST_SCOPE); + } + } + + /** + * Personas short-circuit profile binding entirely: the profile and session both come from the + * persona and none of the cookie/body binding logic runs. Nothing covered that path end to end, + * so a change to the binding code could silently break persona preview. + */ + @Test + public void testPersona_contextJsonBindsToThePersona() throws Exception { + String personaId = "it-persona-" + System.currentTimeMillis(); + profileService.createPersona(personaId); + keepTrying("Persona should be created", () -> profileService.loadPersonaWithSessions(personaId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + ContextRequest contextRequest = new ContextRequest(); + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + "?personaId=" + personaId); + addPublicTenantAuth(request); + request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + RequestResponse response = executeContextJSONRequest(request, null); + + assertEquals(200, response.getStatusCode()); + assertEquals("The context response must be bound to the persona, not a live profile", + personaId, response.getContextResponse().getProfileId()); + } finally { + profileService.delete(personaId, true); + } + } + + /** + * profileOverrides / sessionPropertiesOverrides are the preview-UI feature that lets a caller + * temporarily substitute segments, scores and properties. They had no coverage at all, and they + * are only honoured when the active profile is a Persona ({@code ContextJsonEndpoint#processOverrides}), + * which is exactly what keeps a public caller from overriding a real profile. Pin both halves: + * the override applies for a persona, and the persona path is unaffected by the binding rules. + */ + @Test + public void testPersona_profileOverridesAreApplied() throws Exception { + String personaId = "it-persona-overrides-" + System.currentTimeMillis(); + profileService.createPersona(personaId); + keepTrying("Persona should be created", () -> profileService.loadPersonaWithSessions(personaId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + Profile overrides = new Profile(); + overrides.setSegments(new HashSet<>(Arrays.asList("override-segment-a", "override-segment-b"))); + + ContextRequest contextRequest = new ContextRequest(); + contextRequest.setRequireSegments(true); + contextRequest.setProfileOverrides(overrides); + + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + "?personaId=" + personaId); + addPublicTenantAuth(request); + request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); + RequestResponse response = executeContextJSONRequest(request, null); + + assertEquals(200, response.getStatusCode()); + assertEquals(personaId, response.getContextResponse().getProfileId()); + assertNotNull("requireSegments must return the segment set", response.getContextResponse().getProfileSegments()); + assertTrue("profileOverrides segments must be reflected for a persona", + response.getContextResponse().getProfileSegments().contains("override-segment-a")); + } finally { + profileService.delete(personaId, true); + } + } + + @Test + public void testPublicCaller_sessionProfileSwitchWithoutMatchingCookie_refused() throws Exception { + String sessionOwnerId = "session-owner-" + System.currentTimeMillis(); + String sessionId = "hijack-session-" + System.currentTimeMillis(); + Profile sessionOwner = new Profile(sessionOwnerId); + profileService.save(sessionOwner); + Session foreignSession = new Session(sessionId, sessionOwner, new Date(), TEST_SCOPE); + profileService.saveSession(foreignSession); + keepTrying("Session owner not found", () -> profileService.load(sessionOwnerId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + keepTrying("Foreign session not found", () -> profileService.loadSession(sessionId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + ContextRequest cookieEstablish = new ContextRequest(); + cookieEstablish.setSessionId("cookie-session-" + System.currentTimeMillis()); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(cookieEstablish), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, cookieEstablish.getSessionId()); + String cookieProfileId = established.getContextResponse().getProfileId(); + assertNotEquals(sessionOwnerId, cookieProfileId); + + ContextRequest hijack = new ContextRequest(); + hijack.setSessionId(sessionId); + hijack.setProfileId(cookieProfileId); + HttpPost hijackRequest = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(hijackRequest); + hijackRequest.addHeader("Cookie", established.getCookieHeaderValue()); + hijackRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(hijack), ContentType.APPLICATION_JSON)); + RequestResponse hijacked = executeContextJSONRequest(hijackRequest, sessionId); + + assertEquals("Public caller must not adopt a foreign session profile", + cookieProfileId, hijacked.getContextResponse().getProfileId()); + Session reloaded = profileService.loadSession(sessionId); + assertEquals("Foreign session ownership must remain unchanged", + sessionOwnerId, reloaded.getProfileId()); + } + + @Test + public void testTrustedPrivateKey_mayOverrideBodyProfileId() throws Exception { + String cookieSessionId = "trusted-cookie-session-" + System.currentTimeMillis(); + ContextRequest first = new ContextRequest(); + first.setSessionId(cookieSessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(first), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, cookieSessionId); + assertNotNull(established.getCookieHeaderValue()); + + String overrideProfileId = "admin-chosen-profile-" + System.currentTimeMillis(); + Profile overrideProfile = new Profile(overrideProfileId); + profileService.save(overrideProfile); + keepTrying("Override profile not found", () -> profileService.load(overrideProfileId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + ContextRequest override = new ContextRequest(); + override.setSessionId(cookieSessionId); + override.setProfileId(overrideProfileId); + HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue); + trusted.addHeader("Cookie", established.getCookieHeaderValue()); + trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(override), ContentType.APPLICATION_JSON)); + RequestResponse overridden = executeContextJSONRequest(trusted, cookieSessionId, -1, false); + + assertEquals(200, overridden.getStatusCode()); + assertEquals("Trusted private key may select body profileId over cookie", + overrideProfileId, overridden.getContextResponse().getProfileId()); + } + + @Test + public void testPublicHttp_updateProperties_cannotUpdateAnotherProfile() throws Exception { + String victimId = "update-victim-" + System.currentTimeMillis(); + Profile victim = new Profile(victimId); + profileService.save(victim); + keepTrying("Victim profile not found", () -> profileService.load(victimId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "update-attacker-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + + Event updateEvent = new Event(); + updateEvent.setEventType("updateProperties"); + updateEvent.setScope(TEST_SCOPE); + Map props = new HashMap<>(); + props.put("targetId", victimId); + props.put("targetType", "profile"); + Map toUpdate = new HashMap<>(); + toUpdate.put("properties.firstName", "PWNED"); + props.put("update", toUpdate); + updateEvent.setProperties(props); + + ContextRequest attack = new ContextRequest(); + attack.setSessionId(sessionId); + attack.setEvents(Collections.singletonList(updateEvent)); + HttpPost attackRequest = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(attackRequest); + attackRequest.addHeader("Cookie", established.getCookieHeaderValue()); + attackRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attack), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(attackRequest, sessionId); + + shouldBeTrueUntilEnd("Victim profile must not be updated by public updateProperties", + () -> profileService.load(victimId), + p -> p.getProperty("firstName") == null, + DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES); + } + + @Test + public void testPrivateKeyHttp_updateProperties_canUpdateAnotherProfile() throws Exception { + String victimId = "trusted-update-victim-" + System.currentTimeMillis(); + Profile victim = new Profile(victimId); + profileService.save(victim); + keepTrying("Victim profile not found", () -> profileService.load(victimId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "trusted-update-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + + Event updateEvent = new Event(); + updateEvent.setEventType("updateProperties"); + updateEvent.setScope(TEST_SCOPE); + Map props = new HashMap<>(); + props.put("targetId", victimId); + props.put("targetType", "profile"); + Map toUpdate = new HashMap<>(); + toUpdate.put("properties.firstName", "TRUSTED_HTTP"); + props.put("update", toUpdate); + updateEvent.setProperties(props); + + ContextRequest update = new ContextRequest(); + update.setSessionId(sessionId); + update.setEvents(Collections.singletonList(updateEvent)); + HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue); + trusted.addHeader("Cookie", established.getCookieHeaderValue()); + trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(update), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(trusted, sessionId, -1, false); + + waitForProfileProperty(victimId, "firstName", "TRUSTED_HTTP"); + } + + @Test + public void testPublicHttpLogin_cannotMergeIntoExistingVictimProfile() throws Exception { + ConditionType conditionType = getObjectMapper().readValue( + new File("data/tmp/testLoginEventCondition.json").toURI().toURL(), ConditionType.class); + definitionsService.setConditionType(conditionType); + keepTrying("loginEventCondition not registered", + () -> definitionsService.getConditionType("loginEventCondition"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + Rule rule = getObjectMapper().readValue(new File("data/tmp/testLogin.json").toURI().toURL(), Rule.class); + createAndWaitForRule(rule); + + String victimEmail = "victim-takeover-" + System.currentTimeMillis() + "@example.com"; + String victimId = "victim-merge-" + System.currentTimeMillis(); + Profile victim = new Profile(victimId); + victim.setProperty("email", victimEmail); + victim.setSystemProperty("mergeIdentifier", victimEmail); + profileService.save(victim); + keepTrying("Victim not found", () -> profileService.load(victimId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String sessionId = "attacker-merge-session-" + System.currentTimeMillis(); + ContextRequest pageView = new ContextRequest(); + pageView.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(pageView), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + String attackerId = established.getContextResponse().getProfileId(); + assertNotEquals(victimId, attackerId); + + CustomItem loginTarget = new CustomItem(victimEmail, "visitor"); + Map loginProps = new HashMap<>(); + loginProps.put("email", victimEmail); + loginTarget.setProperties(loginProps); + Event login = new Event(); + login.setEventType("login"); + login.setScope(TEST_SCOPE); + login.setTarget(loginTarget); + login.setTimeStamp(new Date()); + + ContextRequest loginRequest = new ContextRequest(); + loginRequest.setSessionId(sessionId); + loginRequest.setEvents(Collections.singletonList(login)); + HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(attack); + attack.addHeader("Cookie", established.getCookieHeaderValue()); + attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON)); + RequestResponse afterLogin = executeContextJSONRequest(attack, sessionId); + + assertEquals("Public login must not take over the victim profile", + attackerId, afterLogin.getContextResponse().getProfileId()); + assertNotNull(profileService.load(victimId)); + rulesService.removeRule("testLogin"); + } + + /** + * Counterpart to {@link #testPublicHttpLogin_cannotMergeIntoExistingVictimProfile()}: the merge + * must still work end to end for a trusted caller, over real HTTP through the auth filter and + * the rules engine, not just when the subject is set programmatically. + */ + @Test + public void testPrivateKeyHttpLogin_canMergeIntoExistingProfile() throws Exception { + ConditionType conditionType = getObjectMapper().readValue( + new File("data/tmp/testLoginEventCondition.json").toURI().toURL(), ConditionType.class); + definitionsService.setConditionType(conditionType); + keepTrying("loginEventCondition not registered", + () -> definitionsService.getConditionType("loginEventCondition"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + Rule rule = getObjectMapper().readValue(new File("data/tmp/testLogin.json").toURI().toURL(), Rule.class); + createAndWaitForRule(rule); + + String knownEmail = "trusted-merge-" + System.currentTimeMillis() + "@example.com"; + String knownProfileId = "trusted-merge-known-" + System.currentTimeMillis(); + Profile known = new Profile(knownProfileId); + known.setProperty("email", knownEmail); + known.setSystemProperty("mergeIdentifier", knownEmail); + profileService.save(known); + keepTrying("Known profile not found", () -> profileService.load(knownProfileId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Anonymous browsing first, exactly as a visitor would before logging in. + String sessionId = "trusted-merge-session-" + System.currentTimeMillis(); + ContextRequest pageView = new ContextRequest(); + pageView.setSessionId(sessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(pageView), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, sessionId); + String anonymousId = established.getContextResponse().getProfileId(); + assertNotEquals(knownProfileId, anonymousId); + + // The login event is then emitted by a trusted server-side caller after authentication. + CustomItem loginTarget = new CustomItem(knownEmail, "visitor"); + Map loginProps = new HashMap<>(); + loginProps.put("email", knownEmail); + loginTarget.setProperties(loginProps); + Event login = new Event(); + login.setEventType("login"); + login.setScope(TEST_SCOPE); + login.setTarget(loginTarget); + login.setTimeStamp(new Date()); + + ContextRequest loginRequest = new ContextRequest(); + loginRequest.setSessionId(sessionId); + loginRequest.setEvents(Collections.singletonList(login)); + HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue); + trusted.addHeader("Cookie", established.getCookieHeaderValue()); + trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(trusted, sessionId, -1, false); + + keepTrying("Trusted login should merge the anonymous profile into the known one", + () -> profileService.load(anonymousId), + p -> p != null && knownEmail.equals(p.getProperty("email")), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + rulesService.removeRule("testLogin"); + } + + /** + * invalidateSession replaces the session bound to the supplied id, so it must not be usable as a + * way around the cookie-ownership rule. + */ + @Test + public void testPublicCaller_invalidateSessionCannotStealForeignSession() throws Exception { + String ownerId = "invalidate-owner-" + System.currentTimeMillis(); + String foreignSessionId = "invalidate-foreign-session-" + System.currentTimeMillis(); + Profile owner = new Profile(ownerId); + profileService.save(owner); + Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE); + profileService.saveSession(foreignSession); + keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // The attacker establishes their own cookie against an unrelated session. + String ownSessionId = "invalidate-attacker-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(ownSessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, ownSessionId); + String attackerId = established.getContextResponse().getProfileId(); + assertNotEquals(ownerId, attackerId); + + ContextRequest steal = new ContextRequest(); + steal.setSessionId(foreignSessionId); + HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL) + "?invalidateSession=true"); + addPublicTenantAuth(attack); + attack.addHeader("Cookie", established.getCookieHeaderValue()); + attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(steal), ContentType.APPLICATION_JSON)); + executeContextJSONRequest(attack, foreignSessionId); + + shouldBeTrueUntilEnd("Foreign session ownership must survive invalidateSession from a public caller", + () -> profileService.loadSession(foreignSessionId), + s -> s != null && ownerId.equals(s.getProfileId()), + DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES); + } + + /** + * A refused session is never created, so echoing the requested id back would tell the client its + * session is live and make it replay the same rejected id forever. + */ + @Test + public void testPublicCaller_refusedSessionIsNotEchoedInResponse() throws Exception { + String ownerId = "echo-owner-" + System.currentTimeMillis(); + String foreignSessionId = "echo-foreign-session-" + System.currentTimeMillis(); + Profile owner = new Profile(ownerId); + profileService.save(owner); + Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE); + profileService.saveSession(foreignSession); + keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + String ownSessionId = "echo-attacker-session-" + System.currentTimeMillis(); + ContextRequest establishReq = new ContextRequest(); + establishReq.setSessionId(ownSessionId); + HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(establish); + establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON)); + RequestResponse established = executeContextJSONRequest(establish, ownSessionId); + + ContextRequest hijack = new ContextRequest(); + hijack.setSessionId(foreignSessionId); + HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(attack); + attack.addHeader("Cookie", established.getCookieHeaderValue()); + attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(hijack), ContentType.APPLICATION_JSON)); + RequestResponse refused = executeContextJSONRequest(attack, foreignSessionId); + + assertEquals(200, refused.getStatusCode()); + assertNull("A refused session id must not be echoed back to the client", + refused.getContextResponse().getSessionId()); + } + @Test public void testCreateEventWithProfileId_Success() throws Exception { //Arrange @@ -432,16 +959,36 @@ public void testCreateEventWithProfileId_Success() throws Exception { contextRequest.setProfileId(TEST_PROFILE_ID); contextRequest.setEvents(Arrays.asList(event)); - //Act + //Act — body profileId binding for a chosen id requires a trusted caller HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); - addPublicTenantAuth(request); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - executeContextJSONRequest(request); + executeContextJSONRequest(request, null, -1, false); keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time", () -> profileService.load(TEST_PROFILE_ID), Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); } + @Test + public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws Exception { + String victimId = "body-only-victim-" + System.currentTimeMillis(); + Profile victim = new Profile(victimId); + victim.setProperty("email", "victim-body-only@example.com"); + profileService.save(victim); + keepTrying("Victim not found", () -> profileService.load(victimId), Objects::nonNull, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + ContextRequest attack = new ContextRequest(); + attack.setProfileId(victimId); + attack.setRequiredProfileProperties(Collections.singletonList("*")); + HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(request); + request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attack), ContentType.APPLICATION_JSON)); + // Body profileId is ignored for public callers; with no cookie/session → 400, not victim data + RequestResponse response = executeContextJSONRequest(request, null, 400, false); + assertEquals(400, response.getStatusCode()); + } + @Test public void testCreateEventWithPropertiesValidation_Success() throws Exception { //Arrange @@ -489,11 +1036,11 @@ public void testCreateEventWithPropertyValueValidation_Failure() throws Exceptio contextRequest.setProfileId(profileId); contextRequest.setEvents(Arrays.asList(event)); - //Act + //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - executeContextJSONRequest(request); + executeContextJSONRequest(request, null, -1, false); //Assert shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT, @@ -516,11 +1063,11 @@ public void testCreateEventWithPropertyNameValidation_Failure() throws Exception contextRequest.setProfileId(profileId); contextRequest.setEvents(Arrays.asList(event)); - //Act + //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - executeContextJSONRequest(request); + executeContextJSONRequest(request, null, -1, false); //Assert shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT, @@ -581,9 +1128,11 @@ public void testPersonalization() throws Exception { @Test public void testScorePersonalizationStrategy_Interests() throws Exception { // Test request before adding interests to current profile. + // JSON binds profileId in the body — requires trusted caller (public ignores body profileId). HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON)); - TestUtils.RequestResponse response = executeContextJSONRequest(request); + TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false); ContextResponse contextResponse = response.getContextResponse(); List variants = contextResponse.getPersonalizations().get("perso-by-interest"); assertEquals("Invalid response code", 200, response.getStatusCode()); @@ -600,8 +1149,9 @@ public void testScorePersonalizationStrategy_Interests() throws Exception { // check results of the perso now request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON)); - response = executeContextJSONRequest(request); + response = executeContextJSONRequest(request, null, -1, false); contextResponse = response.getContextResponse(); variants = contextResponse.getPersonalizations().get("perso-by-interest"); assertEquals("Invalid response code", 200, response.getStatusCode()); @@ -637,8 +1187,9 @@ public void testScorePersonalizationStrategy_Interests() throws Exception { // re test now that profiles has interests request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON)); - response = executeContextJSONRequest(request); + response = executeContextJSONRequest(request, null, -1, false); contextResponse = response.getContextResponse(); variants = contextResponse.getPersonalizations().get("perso-by-interest"); assertEquals("Invalid response code", 200, response.getStatusCode()); @@ -675,8 +1226,9 @@ public void testRequireScoring() throws Exception { // first let's make sure everything works without the requireScoring parameter parameters = new HashMap<>(); HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getValidatedBundleJSON("withoutRequireScores.json", parameters), ContentType.APPLICATION_JSON)); - TestUtils.RequestResponse response = executeContextJSONRequest(request); + TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false); assertEquals("Invalid response code", 200, response.getStatusCode()); assertNotNull("Context response should not be null", response.getContextResponse()); @@ -686,8 +1238,9 @@ public void testRequireScoring() throws Exception { // now let's test adding it. parameters = new HashMap<>(); request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getValidatedBundleJSON("withRequireScores.json", parameters), ContentType.APPLICATION_JSON)); - response = executeContextJSONRequest(request); + response = executeContextJSONRequest(request, null, -1, false); assertEquals("Invalid response code", 200, response.getStatusCode()); assertNotNull("Context response should not be null", response.getContextResponse()); @@ -874,7 +1427,8 @@ public void testContextEndpointAuthentication() throws Exception { // Test with JAAS authentication (should succeed) BasicCredentialsProvider credsProvider = new BasicCredentialsProvider(); - credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf")); + credsProvider.setCredentials(AuthScope.ANY, + new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD)); RequestConfig requestConfig = RequestConfig.custom() .setAuthenticationEnabled(true) @@ -922,13 +1476,15 @@ private void performPersonalizationWithControlGroup(Map controlG // Test normal personalization should not have control group info in response HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + // JSON fixtures bind profileId in the body — requires trusted caller (public ignores body profileId). + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); if (controlGroupConfig != null) { request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-control-group.json", controlGroupConfig), ContentType.APPLICATION_JSON)); } else { request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-no-control-group.json", null), ContentType.APPLICATION_JSON)); } - TestUtils.RequestResponse response = executeContextJSONRequest(request); + TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false); ContextResponse contextResponse = response.getContextResponse(); // Check variants @@ -978,23 +1534,31 @@ public void testConcealedProperties() throws Exception { contextRequest.setProfileId(profile.getItemId()); contextRequest.setSessionId(sessionId); HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL)); + // Body profileId requires trusted caller (public ignores body profileId). + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); + assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); // set the property as concealed customPropertyType.getMetadata().getSystemTags().add("concealed"); profileService.deletePropertyType(customPropertyType.getItemId()); profileService.setPropertyType(customPropertyType); // Not in all properties + request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - assertNull(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty")); + assertNull(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty")); // Got it explicitly contextRequest.setRequiredProfileProperties(Arrays.asList("customProperty")); + request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); + assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); // Got it with all contextRequest.setRequiredProfileProperties(Arrays.asList("*", "customProperty")); + request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); + assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); // remove the concealed tag on the property type customPropertyType.getMetadata().getSystemTags().remove("concealed"); @@ -1003,8 +1567,10 @@ public void testConcealedProperties() throws Exception { // Got it from all properties contextRequest.setRequiredProfileProperties(Arrays.asList("*")); + request = new HttpPost(getFullUrl(CONTEXT_URL)); + addPrivateTenantAuth(request, testTenant, testPrivateKeyValue); request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON)); - assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); + assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue")); } @Test diff --git a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java index 6cc692a0d1..23c7b219b2 100644 --- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java +++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java @@ -57,11 +57,14 @@ ModifyConsentIT.class, PatchIT.class, ContextServletIT.class, + ContextEndpointBaselineIT.class, SecurityIT.class, RuleServiceIT.class, PrivacyServiceIT.class, GroovyActionsServiceIT.class, + RestEndpointRoleSecurityIT.class, GraphQLEventIT.class, + GraphQLServletSecurityIT.class, GraphQLListIT.class, GraphQLProfileIT.class, GraphQLProfilePropertiesIT.class, diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java index 20011d21c2..179984d618 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java @@ -176,6 +176,71 @@ public void testProfileMergeOnPropertyAction_sessionReassigned_newProfile() thro * - a new one, if it's the first time we encounter his own mergeIdentifier * - a previous one, if we already have a profile in DB with the same mergeIdentifier. (TESTED in this scenario) */ + /** + * Public / untrusted callers must not merge into an existing victim profile (identity takeover). + * Suite {@code @Before} installs a tenant-admin subject; this test temporarily downgrades it. + */ + @Test + public void testUntrustedCaller_cannotMergeIntoExistingVictimProfile() throws InterruptedException { + createAndWaitForRule(createMergeOnPropertyRule(false, "email")); + + Profile victim = new Profile("victimProfileID"); + victim.setProperty("email", "victim@example.com"); + victim.setSystemProperty("mergeIdentifier", "victim@example.com"); + profileService.save(victim); + + keepTrying("Victim profile not found", () -> profileService.load("victimProfileID"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Profile attacker = new Profile("attackerProfileID"); + attacker.setProperty("email", "victim@example.com"); + Session session = new Session("untrustedMergeSession", attacker, new Date(), null); + Event event = new Event(TEST_EVENT_TYPE, session, attacker, null, null, attacker, new Date()); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + eventService.send(event); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertEquals("attackerProfileID", event.getProfile().getItemId()); + Assert.assertEquals("attackerProfileID", event.getSession().getProfile().getItemId()); + Assert.assertNotNull(profileService.load("victimProfileID")); + } + + @Test + public void testTrustedPrivateKeySubject_canMergeIntoExistingProfile() throws InterruptedException { + createAndWaitForRule(createMergeOnPropertyRule(false, "email")); + + Profile victim = new Profile("trustedVictimProfileID"); + victim.setProperty("email", "trusted-victim@example.com"); + victim.setSystemProperty("mergeIdentifier", "trusted-victim@example.com"); + victim.setProperty("firstVisit", new Date(0)); + profileService.save(victim); + + keepTrying("Victim profile not found", () -> profileService.load("trustedVictimProfileID"), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + Profile caller = new Profile("trustedCallerProfileID"); + caller.setProperty("email", "trusted-victim@example.com"); + caller.setProperty("firstVisit", new Date()); + Session session = new Session("trustedMergeSession", caller, new Date(), null); + Event event = new Event(TEST_EVENT_TYPE, session, caller, null, null, caller, new Date()); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, true)); + eventService.send(event); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertEquals("trustedVictimProfileID", event.getProfile().getItemId()); + Assert.assertEquals("trustedVictimProfileID", event.getSession().getProfile().getItemId()); + } + @Test public void testProfileMergeOnPropertyAction_sessionReassigned_existingProfile() throws InterruptedException { // create rule diff --git a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java index ad2206a6b3..64d65ea4f1 100644 --- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java @@ -20,6 +20,7 @@ import org.apache.unomi.api.Event; import org.apache.unomi.api.Profile; import org.apache.unomi.api.rules.Rule; +import org.apache.unomi.api.services.EventService; import org.apache.unomi.plugins.baseplugin.actions.UpdatePropertiesAction; import org.junit.Assert; import org.junit.Before; @@ -118,6 +119,81 @@ public void testUpdateProperties_NotCurrentProfile() throws InterruptedException waitForProfileProperty(PROFILE_TEST_ID, "firstName", "UPDATED FIRST NAME"); } + @Test + public void testUntrustedCaller_cannotUpdateAnotherProfile() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TARGET_TEST_ID); + Profile other = profileService.load(PROFILE_TEST_ID); + Assert.assertNull(other.getProperty("firstName")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("properties.firstName", "SHOULD_NOT_APPLY"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, PROFILE_TEST_ID); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, "profile"); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + int changes = eventService.send(updateProperties); + Assert.assertEquals(EventService.NO_CHANGE, changes); + } finally { + securityService.setCurrentSubject(previous); + } + + shouldBeTrueUntilEnd("Other profile must remain unchanged", + () -> profileService.load(PROFILE_TEST_ID), + p -> p.getProperty("firstName") == null, + DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES); + } + + @Test + public void testUntrustedCaller_cannotWriteSystemProperties() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TEST_ID); + Assert.assertNull(caller.getSystemProperties().get("mergeIdentifier")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("systemProperties.mergeIdentifier", "stolen"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, false)); + eventService.send(updateProperties); + } finally { + securityService.setCurrentSubject(previous); + } + + Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier")); + } + + @Test + public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws InterruptedException { + Profile caller = profileService.load(PROFILE_TARGET_TEST_ID); + Assert.assertNull(profileService.load(PROFILE_TEST_ID).getProperty("firstName")); + + Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date()); + updateProperties.setPersistent(false); + Map propertyToUpdate = new HashMap<>(); + propertyToUpdate.put("properties.firstName", "TRUSTED UPDATE"); + updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY, PROFILE_TEST_ID); + updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY, "profile"); + + javax.security.auth.Subject previous = securityService.getCurrentSubject(); + try { + securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID, true)); + eventService.send(updateProperties); + } finally { + securityService.setCurrentSubject(previous); + } + + waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE"); + } + @Test public void testUpdateProperties_CurrentProfile_PROPS_TO_ADD() throws InterruptedException { Profile profile = profileService.load(PROFILE_TEST_ID); diff --git a/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java new file mode 100644 index 0000000000..67329b98e2 --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java @@ -0,0 +1,177 @@ +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package org.apache.unomi.itests; + +import org.apache.http.client.methods.CloseableHttpResponse; +import org.apache.http.client.methods.HttpDelete; +import org.apache.http.client.methods.HttpGet; +import org.apache.http.client.methods.HttpPost; +import org.apache.http.entity.ByteArrayEntity; +import org.apache.http.entity.ContentType; +import org.apache.http.entity.StringEntity; +import org.junit.Assert; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.ops4j.pax.exam.junit.PaxExam; +import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; +import org.ops4j.pax.exam.spi.reactors.PerSuite; + +import java.nio.charset.StandardCharsets; + +/** + * HTTP-level checks that system-admin-only REST endpoints reject tenant private keys + * (including multipart upload / oneshot paths). + */ +@RunWith(PaxExam.class) +@ExamReactorStrategy(PerSuite.class) +public class RestEndpointRoleSecurityIT extends BaseIT { + + @Test + public void importConfiguration_requiresSystemAdministrator() throws Exception { + try (CloseableHttpResponse tenantAdmin = executeHttpRequest( + new HttpGet(getFullUrl("/cxs/importConfiguration")), AuthType.PRIVATE_KEY)) { + Assert.assertEquals("Tenant private key must not list import configurations", + 403, tenantAdmin.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse jaasAdmin = executeHttpRequest( + new HttpGet(getFullUrl("/cxs/importConfiguration")), AuthType.JAAS_ADMIN)) { + Assert.assertEquals("JAAS admin should list import configurations", + 200, jaasAdmin.getStatusLine().getStatusCode()); + } + } + + @Test + public void exportConfiguration_requiresSystemAdministrator() throws Exception { + try (CloseableHttpResponse tenantAdmin = executeHttpRequest( + new HttpGet(getFullUrl("/cxs/exportConfiguration")), AuthType.PRIVATE_KEY)) { + Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse jaasAdmin = executeHttpRequest( + new HttpGet(getFullUrl("/cxs/exportConfiguration")), AuthType.JAAS_ADMIN)) { + Assert.assertEquals(200, jaasAdmin.getStatusLine().getStatusCode()); + } + } + + @Test + public void importConfiguration_oneshotUpload_requiresSystemAdministrator() throws Exception { + HttpPost oneshot = multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"), + "----UnomiImportBoundary", + part("importConfigId", "text/plain", "rest-role-security-oneshot"), + filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n")); + + try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, AuthType.PRIVATE_KEY)) { + Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode()); + } + + HttpPost oneshotJaas = multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"), + "----UnomiImportBoundaryJaas", + part("importConfigId", "text/plain", "rest-role-security-oneshot"), + filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n")); + try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, AuthType.JAAS_ADMIN)) { + // Role gate is what we care about; missing config may yield 500 after auth succeeds. + Assert.assertNotEquals(403, jaasAdmin.getStatusLine().getStatusCode()); + Assert.assertNotEquals(401, jaasAdmin.getStatusLine().getStatusCode()); + } + } + + @Test + public void exportConfiguration_oneshot_requiresSystemAdministrator() throws Exception { + String body = "{\"itemId\":\"rest-role-security-export\",\"itemType\":\"exportConfig\"}"; + HttpPost oneshot = new HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot")); + oneshot.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, AuthType.PRIVATE_KEY)) { + Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode()); + } + + HttpPost oneshotJaas = new HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot")); + oneshotJaas.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON)); + try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, AuthType.JAAS_ADMIN)) { + Assert.assertNotEquals(403, jaasAdmin.getStatusLine().getStatusCode()); + Assert.assertNotEquals(401, jaasAdmin.getStatusLine().getStatusCode()); + } + } + + @Test + public void groovyActions_requiresSystemAdministrator() throws Exception { + String path = getFullUrl("/cxs/groovyActions/rest-role-security-it-missing-action"); + + try (CloseableHttpResponse tenantAdmin = executeHttpRequest(new HttpDelete(path), AuthType.PRIVATE_KEY)) { + Assert.assertEquals("Tenant private key must not delete groovy actions", + 403, tenantAdmin.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse jaasAdmin = executeHttpRequest(new HttpDelete(path), AuthType.JAAS_ADMIN)) { + int status = jaasAdmin.getStatusLine().getStatusCode(); + Assert.assertTrue("JAAS admin delete should be allowed (got " + status + ")", + status == 200 || status == 204 || status == 404); + } + } + + @Test + public void groovyActions_upload_requiresSystemAdministrator() throws Exception { + String script = "// RestEndpointRoleSecurityIT probe\nvoid execute() {}\n"; + HttpPost upload = multipartPost(getFullUrl("/cxs/groovyActions/"), + "----UnomiGroovyBoundary", + filePart("file", "RestRoleSecurityITProbe.groovy", "text/plain", script)); + + try (CloseableHttpResponse tenantAdmin = executeHttpRequest(upload, AuthType.PRIVATE_KEY)) { + Assert.assertEquals("Tenant private key must not upload groovy actions", + 403, tenantAdmin.getStatusLine().getStatusCode()); + } + + HttpPost uploadJaas = multipartPost(getFullUrl("/cxs/groovyActions/"), + "----UnomiGroovyBoundaryJaas", + filePart("file", "RestRoleSecurityITProbe.groovy", "text/plain", script)); + try (CloseableHttpResponse jaasAdmin = executeHttpRequest(uploadJaas, AuthType.JAAS_ADMIN)) { + Assert.assertEquals("JAAS admin should be allowed to upload groovy actions", + 200, jaasAdmin.getStatusLine().getStatusCode()); + } + + try (CloseableHttpResponse cleanup = executeHttpRequest( + new HttpDelete(getFullUrl("/cxs/groovyActions/RestRoleSecurityITProbe")), AuthType.JAAS_ADMIN)) { + int status = cleanup.getStatusLine().getStatusCode(); + Assert.assertTrue(status == 200 || status == 204 || status == 404); + } + } + + private static HttpPost multipartPost(String url, String boundary, String... parts) { + HttpPost post = new HttpPost(url); + StringBuilder body = new StringBuilder(); + for (String part : parts) { + body.append("--").append(boundary).append("\r\n").append(part); + } + body.append("--").append(boundary).append("--\r\n"); + post.setHeader("Content-Type", "multipart/form-data; boundary=" + boundary); + post.setEntity(new ByteArrayEntity(body.toString().getBytes(StandardCharsets.UTF_8))); + return post; + } + + private static String part(String name, String contentType, String value) { + return "Content-Disposition: form-data; name=\"" + name + "\"\r\n" + + "Content-Type: " + contentType + "\r\n\r\n" + + value + "\r\n"; + } + + private static String filePart(String name, String filename, String contentType, String value) { + return "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n" + + "Content-Type: " + contentType + "\r\n\r\n" + + value + "\r\n"; + } +} From 532d855d567db184a79434d4cda5e7aa2ce95548 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Mon, 10 Aug 2026 09:27:53 +0200 Subject: [PATCH 8/9] UNOMI-972: document the client-facing changes and the migration impact The hardening changes behaviour that existing clients and operators depend on, so the documentation has to change with it or it teaches something the server now refuses. - a client-facing hardening table in the 3.0-to-3.1 migration guide, cross-referenced from the pages that describe the affected behaviour - public context examples switched from a body profileId to the context-profile-id cookie in request-examples and multitenancy; the remaining profileId occurrences elsewhere are response bodies or already cookie-based - credentials in examples no longer show karaf:karaf, and the quickstart, getting-started, configuration and Docker pages require the operator to choose passwords deliberately - the threat model records what the Groovy action endpoint is (equivalent to shell access, system administrator only, no sandbox planned and why), so a report of "Groovy is not sandboxed" triages consistently rather than being re-litigated Co-Authored-By: Claude Opus 5 (1M context) --- SECURITY.md | 22 ++- THREAT_MODEL.md | 154 ++++++++++-------- .../src/main/asciidoc/5-min-quickstart.adoc | 28 +++- .../main/asciidoc/builtin-event-types.adoc | 8 +- manual/src/main/asciidoc/configuration.adoc | 62 ++++--- .../connectors/salesforce-connector.adoc | 11 +- manual/src/main/asciidoc/getting-started.adoc | 25 ++- .../src/main/asciidoc/graphql-examples.adoc | 9 +- .../asciidoc/how-profile-tracking-works.adoc | 68 +++++--- .../asciidoc/javascript-tracker-guide.adoc | 88 +++++----- .../asciidoc/jsonSchema/json-schema-api.adoc | 2 +- .../migrations/migrate-3.0-to-3.1.adoc | 112 +++++++++++-- .../main/asciidoc/migrations/migrations.adoc | 2 +- .../migrations/v2-compatibility-mode.adoc | 6 +- .../migrations/v2-v3-compatibility.adoc | 24 +-- manual/src/main/asciidoc/multitenancy.adoc | 36 ++-- manual/src/main/asciidoc/privacy.adoc | 2 +- manual/src/main/asciidoc/recipes.adoc | 17 +- .../src/main/asciidoc/request-examples.adoc | 20 ++- manual/src/main/asciidoc/scheduler.adoc | 16 +- manual/src/main/asciidoc/security.adoc | 4 +- manual/src/main/asciidoc/shell-commands.adoc | 2 +- manual/src/main/asciidoc/tutorial.adoc | 4 +- manual/src/main/asciidoc/whats-new.adoc | 10 +- .../src/main/webapp/WEB-INF/web.xml | 24 --- .../src/main/webapp/index.html | 70 -------- .../main/webapp/javascript/login-example.js | 139 ---------------- 27 files changed, 475 insertions(+), 490 deletions(-) delete mode 100644 samples/login-integration/src/main/webapp/WEB-INF/web.xml delete mode 100644 samples/login-integration/src/main/webapp/index.html delete mode 100644 samples/login-integration/src/main/webapp/javascript/login-example.js diff --git a/SECURITY.md b/SECURITY.md index 4a7e188b70..1fb715e55c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -19,8 +19,26 @@ limitations under the License. ## Reporting a Vulnerability `apache/unomi` follows the [Apache Software Foundation security process](https://www.apache.org/security/). Please report suspected -vulnerabilities privately to `security@apache.org` (the ASF security team routes Unomi reports to the project's private list, `private@unomi.apache.org`); do not open public -GitHub issues or pull requests for security reports. +vulnerabilities privately to `security@apache.org` (the ASF Security Team routes Unomi reports to the project's private PMC list, +`private@unomi.apache.org`); do not open public GitHub issues or pull requests for security reports. + +## How the PMC handles reports + +Unomi follows the default ASF process in +[ASF Project Security for Committers — Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling). +Unomi does not currently maintain a dedicated `security@unomi.apache.org` list, so further private mail about an undisclosed issue +should be copied to `security@apache.org` as that guide requires. + +Summary of the steps the PMC applies: + +1. **Work in private** — no public Jira/GitHub issues; commit messages must not call out the security nature of the fix until announcement. +2. **Acknowledge** — email the reporter (cc `security@apache.org` / `private@unomi.apache.org`). +3. **Investigate** — triage against [THREAT_MODEL.md](./THREAT_MODEL.md); **accept** or **reject** each distinct finding (a multi-issue report may be split). +4. **If rejected** — write to the reporter explaining why (cc security lists). Rejection reasons include out-of-model / by-design findings and issues that affect **only unreleased** development code with no released-line impact (still fix before the next GA when appropriate). +5. **If accepted** — tell the reporter we are working on a fix; request CVE ID(s) via [cveprocess.apache.org](https://cveprocess.apache.org) or `security@apache.org` (ASF Security can advise on splitting/merging CVEs). +6. **Resolve** — agree the fix privately; document on the ASF CVE portal; share fix + draft announcement with the reporter; commit without security references; ship a release that includes the fix. +7. **Announce** — with or after the release announcement (reporter, project lists, `security@apache.org`, `oss-security@lists.openwall.com`). +8. **Complete** — update [unomi.apache.org/security/](https://unomi.apache.org/security/) and CVE references. ## Threat Model diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md index f536032efc..2256af7f72 100644 --- a/THREAT_MODEL.md +++ b/THREAT_MODEL.md @@ -19,160 +19,186 @@ limitations under the License. ## §1 Header - **Project:** Apache Unomi (`apache/unomi`), `master` branch, against which this draft was written. This model covers the **apache/unomi** server; `unomi-tracker` (browser tracking client) and `unomi-site` (website) are in the engagement scope but are treated here as satellites (see §2/§3). -- **Date:** 2026-06-02. **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta threat-model rubric), for PMC ratification. +- **Date:** 2026-06-02; **amended:** 2026-08-06 (PMC triage amendments — role split, GraphQL transports, tenant isolation, closed §14 Q1 / Q8a; default-password: documented today + 3.1 hardening commitment); **amended:** 2026-08-07 (UNOMI-972 — the 3.1 default-password retirement shipped; §5a layer 2, §9, §10, §11, §11a, §12, §13 and §14 Q1 revised from commitment to shipped state). **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta threat-model rubric), for PMC ratification; amendments by Unomi PMC. - **Version binding:** versioned with the project; a report against version *N* is triaged against the model as it stood at *N*. -- **Reporting cross-reference:** §8-property violations → report privately per ASF process (`security@apache.org` → `private@unomi.apache.org`); §3/§9 findings are closed citing this document. +- **ASF vulnerability handling:** the PMC follows [Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling) (see also `SECURITY.md`): work in private → acknowledge → investigate → **accept or reject** (with written reasons) → for accepted issues: CVE via `cveprocess.apache.org` / `security@apache.org` → private fix → release → announce → update project security pages. Unomi has no dedicated `security@unomi.apache.org`; copy private vulnerability mail to `security@apache.org`. +- **Accept vs reject (released vs unreleased):** **Accept** (and continue through CVE/fix/release/announce) findings that are in-model vulnerabilities in **released** Unomi lines (e.g. 2.x, 3.0.x). **Reject** as a security vulnerability in released Apache software — with a clear explanation to the reporter — findings that (a) are `OUT-OF-MODEL` / `BY-DESIGN` / `KNOWN-NON-FINDING` per this document, or (b) exist **only** on unreleased development code (e.g. `master` / `3.1.0-SNAPSHOT` before any 3.1 GA) and do not affect a released line. Rejection under (b) does **not** mean “ignore”: still fix in the development branch before GA, but do not open a CVE or run the embargoed announce path solely for never-shipped code. When one email mixes both, **split the reply**: accept the released-line slice; reject (with fix-before-GA note) the unreleased-only slice. Ask `security@apache.org` if a borderline case needs a CVE anyway. +- **Reporting cross-reference:** suspected §8 violations → `security@apache.org` → `private@unomi.apache.org`; dispositions cite this document. - **Provenance legend:** *(documented)* = Unomi's own docs/repo/CVE advisories; *(maintainer)* = confirmed by an Unomi PMC member through this process; *(inferred)* = reasoned from architecture/history, not yet confirmed — each has a matching §14 open question. -- **Draft confidence:** ~16 documented / 0 maintainer / ~30 inferred. -- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs. It persists to Elasticsearch/OpenSearch. *(documented — README, manual)* +- **Draft confidence:** ~16 documented / several maintainer (Q1, Q2, Q7, Q8a) / remaining inferred. +- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs (HTTP and WebSocket). It persists to Elasticsearch/OpenSearch. *(documented — README, manual)* ## §2 Scope and intended use - **Primary use:** an operator-deployed **context server** that ingests visitor events over the network and serves profile/segmentation data to web properties and back-office tools. *(documented — manual)* - **Caller roles** (network service — the role splits): - - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/context.json` and `/eventcollector` — `EventsCollectorServlet`) **unauthenticated**, from the open internet. The highest-value untrusted surface. *(inferred — confirm the public-endpoint exposure model)* - - **integrator / API client** — calls the REST / GraphQL APIs, authenticated; may author conditions, rules, segments, scopes. **Trusted to its credential's authority.** *(inferred)* - - **operator/admin** — controls config, the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. **Trusted.** *(inferred)* + - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/cxs/context.json` and `/cxs/eventcollector`) with a tenant **public** API key (`X-Unomi-Api-Key`), from the open internet. Highest-value untrusted surface. (Temporary exception: V2 compatibility mode may omit the public key while migrating.) *(documented — `security.adoc`)* + - **tenant administrator** — authenticates with Basic `tenantId:privateApiKey`. Trusted **only within that tenant’s data plane** (profiles, rules, segments, schemas for that tenant). **Not** trusted for host-level side effects (unsandboxed script execution, arbitrary filesystem or unconstrained Camel endpoints) or for other tenants’ data. *(maintainer — §14 Q8a)* + - **system administrator / operator** — JAAS (e.g. `karaf`) and control of the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. Trusted for tenant CRUD, key minting, and host-impacting configuration. *(documented — manual; maintainer)* + - **integrator / API client** — any authenticated REST/GraphQL caller; **trusted only to its credential’s authority** (public key, tenant private key, or system admin). *(maintainer)* - **cluster peer** — another Unomi node. *(inferred)* **Component-family table:** | Family | Entry point | Touches outside process | In model? | | --- | --- | --- | --- | -| Public context ingestion | `/context.json` + `/eventcollector` (`EventsCollectorServlet`, `wab`) | network (public listen) | **In — primary boundary** *(inferred)* | +| Public context ingestion | `/cxs/context.json` + `/cxs/eventcollector` | network (public listen) | **In — primary boundary** *(documented)* | | Rule / condition / segment engine + scripting | `services`, `scripting` (MVEL/OGNL expression eval) | evaluates expressions | **In — historically the RCE surface (§11)** *(documented: CVEs)* | | JSON-Schema event validation | schema validation of incoming events | — | **In — the input-validation defense** *(documented: manual `jsonSchema`)* | -| Admin REST + GraphQL APIs | `rest`, `graphql` | network (authenticated) | **In** *(documented: modules)* | +| Admin REST APIs | `rest` | network (authenticated) | **In** *(documented: modules)* | +| GraphQL HTTP + WebSocket | `/graphql` (queries/mutations over HTTP; subscriptions over WebSocket) | network (authenticated) | **In — auth must apply to every transport, including WebSocket upgrade/subscribe** *(maintainer)* | +| Groovy actions extension | script upload/compile/dispatch | process (script exec) | **In — host-impacting ops require system administrator** *(maintainer — §14 Q8a)* | +| Router import/export | Camel source/destination URIs | filesystem / remote endpoints | **In — path/host confinement required for multi-tenant safety** *(maintainer — §14 Q8a)* | | Persistence | `persistence-elasticsearch` / `persistence-opensearch` | network → ES/OS backend | **In (Unomi's use of it); the backend's own security is operator's** *(inferred)* | | Plugins / extensions / connectors | `plugins`, `extensions`, `connectors` | varies | **In core ones; third-party/`samples` out** *(inferred)* | -| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(inferred)* | -| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3)* | +| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(maintainer — §14 Q2)* | +| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3; maintainer — §14 Q2)* | ## §3 Out of scope (explicit non-goals) - **Attackers who already control the host, the Karaf container, the config, the plugins, or the Elasticsearch/OpenSearch backend.** Operator-trusted. *(inferred)* -- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(inferred)* +- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(maintainer — §14 Q2)* A **core** action or endpoint that is unsafe when an operator follows a documented sample pattern can still be `VALID` / `VALID-HARDENING`; only the sample artifact itself is out of scope. - **Confidentiality of profile data at rest / in the search backend** when the operator has not secured Elasticsearch/OpenSearch and the network — that is deployment hardening, not an Unomi code property, unless Unomi claims otherwise. *(inferred)* -- **Arbitrary expression evaluation by a *trusted admin*** who authors a malicious condition/rule — an authenticated privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input can reach expression evaluation (see §8/§11). *(inferred — confirm)* +- **Arbitrary expression evaluation by a *system administrator*** who authors a malicious condition/rule — an authenticated system-privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input, or a *lower-privilege* credential (e.g. tenant administrator), can reach that power (see §8/§11). *(maintainer — §14 Q7 / Q8a)* ## §4 Trust boundaries and data flow -- **Primary boundary: the public context endpoint.** Event payloads arriving unauthenticated from browsers are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step is the gate. *(inferred; schema validation documented)* -- **Secondary boundary: the authenticated REST/GraphQL admin surface**, where conditions/rules/scopes are defined — trusted to the credential. *(inferred)* +- **Primary boundary: the public context endpoint.** Event payloads arriving from browsers (public API key at most) are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step and the public-event allow-list are the gates. *(documented; schema validation documented)* +- **Secondary boundary: the authenticated REST/GraphQL surface** (HTTP **and** WebSocket), where conditions/rules/scopes are defined and subscriptions are opened — trusted only to the credential’s authority. A transport that skips the auth gate that other transports enforce is an in-model break. *(maintainer)* +- **Tertiary boundary: tenant credential ↔ host / cross-tenant.** A tenant-administrator subject must not escape to host process powers (unsandboxed script compile/exec, absolute filesystem read/write, unconstrained Camel endpoints) or to another tenant’s data. *(maintainer — §14 Q8a)* - **The historical break (load-bearing):** the public surface must **not** allow attacker-controlled input to reach OGNL/MVEL expression evaluation that can instantiate/call arbitrary Java — that was CVE-2020-11975 / CVE-2020-13942 / CVE-2021-31164, fixed by constraining the public surface. The model treats a regression of this kind as `VALID`/critical. *(documented — CVE advisories)* -- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/unauthenticated** input (or from a lower privilege than the operation requires). Expression power available only to a trusted authenticated author is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(inferred)* +- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/untrusted** input **or from a lower privilege than the operation requires** (e.g. tenant admin reaching system-admin-only host power). Expression power available only to a system administrator is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(maintainer)* ## §5 Assumptions about the environment - **Runtime:** JVM; runs in an Apache Karaf / OSGi container. *(documented — kar/package/manual)* - **Backend:** Elasticsearch or OpenSearch, assumed deployed on a trusted network and secured by the operator. *(inferred)* -- **The public endpoint is internet-facing by design** (browsers post events directly); the admin APIs are assumed *not* public. Confirm. *(inferred)* -- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path. *(inferred)* +- **The public endpoint is internet-facing by design** (browsers post events directly); the admin REST/GraphQL APIs are assumed *not* public and are authenticated. *(maintainer — §14 Q1)* +- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path; optional extensions (Groovy, router) can touch the filesystem and remote endpoints. *(inferred)* ## §5a Build-time and configuration variants -Security-relevant configuration knobs *(all inferred — confirm names/defaults against `configuration.adoc`):* +Security-relevant configuration knobs: -- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept)* -- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented)* +- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept; see §14 Q5)* +- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented; see §14 Q4)* - **Expression/scripting allow-list** (post-CVE) — what restricts which classes/methods conditions may reference, and is it on by default? *(inferred; the CVE fixes introduced restrictions)* -- **Authentication on the admin REST/GraphQL APIs** — default credentials? bound to localhost vs all interfaces by default? *(inferred — the insecure-default question; wave 1)* +- **Authentication on the admin REST/GraphQL APIs** — packaged JAAS user `karaf` whose password comes from `org.apache.unomi.security.root.password`, which as of 3.1 resolves to `${env:UNOMI_ROOT_PASSWORD}` **with no fallback value**; `users.properties` carries no default either. The health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. **No known default password ships**. `bin/karaf` and the Docker entrypoint refuse to start when either variable is unset; note that `karaf.bat` ignores the exit code of the `setenv.bat` it calls, so on Windows the check warns but does not block startup, and any launcher that execs the JVM directly (systemd, Kubernetes command overrides) bypasses it. Operators must supply both explicitly. *(documented — `custom.system.properties`, `users.properties`, `configuration.adoc`; maintainer — §14 Q1)* +- **Profile id cookie flags** — `contextserver.profileIdCookieHttpOnly` defaults to `true` unless overridden. *(documented — `org.apache.unomi.web.cfg` / `custom.system.properties`)* +- **Router allowed endpoint schemes** — default allowlist includes `file,ftp,sftp,ftps`. *(documented — `org.apache.unomi.router.cfg`)* -**Insecure-default check:** if any of (public-endpoint protection, schema validation, scripting allow-list, admin auth) ships *off* or with a default credential, a report against that default is `VALID` unless the PMC designates it a documented must-configure (`OUT-OF-MODEL: non-default-build`). This is a wave-1 ruling (§14). +**Insecure-default check (PMC ruling) — two layers:** + +1. **Triage of reports against pre-3.1 builds:** on releases before 3.1, leaving the documented default JAAS password unchanged was a **documented must-configure** operator duty. A report that only shows “the shipped default admin password still works on a fresh pre-3.1 install” is `OUT-OF-MODEL: non-default-build` / `BY-DESIGN: property-disclaimed` (§9). Auth bypasses or missing gates on an admin surface (independent of the password value) remain `VALID`. *(maintainer — §14 Q1)* +2. **Product commitment for the 3.1 release — shipped:** the PMC treated the shipped known default as accepted technical debt to retire in 3.1, and **3.1 retires it**. `org.apache.unomi.security.root.password` now resolves only from `UNOMI_ROOT_PASSWORD` with no fallback, `users.properties` carries no default, and the shell launchers fail fast when the variable is unset (advisory only on Windows, see §5a above); the health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. As defence in depth the REST layer additionally rejects any Basic credential with an empty password. **Because that retirement has shipped**, this §5a ruling now stands in its revised form: on 3.1 and later, a regression that reintroduces a known working default password — or any fallback that lets the admin API accept a credential the operator never configured — is `VALID`, not `VALID-HARDENING`. *(maintainer — §12 / §14 Q1)* ## §6 Assumptions about inputs -Per-surface trust table *(all inferred unless noted):* +Per-surface trust table: | Surface | Input | Attacker-controllable? | Caller/operator must enforce | | --- | --- | --- | --- | -| Public context endpoint | event JSON, profile/session refs, scope | **yes (unauthenticated, public)** | JSON-schema validation on; public-event allow-list; no expression reach | -| REST / GraphQL admin | conditions, rules, segments, queries | **yes, within the authenticated credential's authority** | authn + authz; restrict who may author expressions | -| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; admin: yes-but-trusted** | keep expression authoring on the trusted side | -| Persistence queries | derived from the above | indirectly | backend hardening; query/scope isolation | +| Public context endpoint | event JSON, profile/session refs, scope | **yes (public client)** | JSON-schema validation on; public-event allow-list; no expression reach; profile/session refs treated as **bearer identifiers** (see §8) | +| REST admin | conditions, rules, segments, queries, extension config | **yes, within credential authority** | authn + authz; system admin for host-impacting ops | +| GraphQL HTTP | queries / mutations | **yes, within credential authority** | same auth gate as documented for GraphQL | +| GraphQL WebSocket | subscription init + subscribe payloads | **yes** | **same auth as GraphQL HTTP**; no Subject → reject upgrade/subscribe | +| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; system admin: yes-but-trusted** | keep expression authoring on the system-trusted side | +| Public events → identity actions | merge/update target from event properties | **yes, when event type is public** | do not wire unverified identity claims to merge/update actions (§11) | +| Persistence queries | derived from the above | indirectly | backend hardening; tenant/scope isolation | | Plugins / connectors config | operator-supplied | no — operator-trusted | vet third-party plugins | - **Size/shape/rate:** whether the public endpoint bounds event size / batch count / request rate against a flood is open (see §8 resource line). *(inferred)* ## §7 Adversary model -- **Primary adversary:** an unauthenticated party who can reach the **public context endpoint** from the internet — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, inject events to corrupt segmentation, or exhaust resources. *(documented threat history; framing inferred)* -- **Secondary:** an authenticated API client trying to exceed its authority (read other scopes' data, escalate). *(inferred)* -- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads. **Not** assumed: control of the admin credential, the container, or the backend. *(inferred)* -- **Out of scope:** trusted admins authoring powerful (even dangerous) conditions; attackers with host/backend control. *(inferred)* +- **Primary adversary:** an unauthenticated or public-API-key party who can reach the **public context endpoint** (and any other publicly reachable surface) — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, open privileged GraphQL subscriptions without credentials, inject events to corrupt segmentation or rebind identity, or exhaust resources. *(documented threat history; maintainer framing)* +- **Secondary:** an authenticated **tenant** API client trying to exceed its authority — cross-tenant read/write, host RCE, arbitrary filesystem or internal-network reach via import/export, uploading unsandboxed scripts. *(maintainer — §14 Q8a)* +- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads; attempt WebSocket upgrade without credentials; use a stolen or issued tenant private key within (and beyond) its tenant. **Not** assumed: control of the system-admin credential, the container, or the backend. *(maintainer)* +- **Out of scope:** system administrators authoring powerful (even dangerous) conditions; attackers with host/backend control. *(maintainer)* ## §8 Security properties the project provides -*(All inferred pending PMC confirmation; the CVE-fix posture is documented history.)* +*(CVE-fix posture is documented history; tenant and GraphQL transport lines are maintainer-confirmed.)* -- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated request. *Severity:* security-critical. *(documented that this class was fixed; the standing guarantee is the claim to confirm)* -- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness to confirm)* -- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. *Violation symptom:* cross-profile / cross-scope data access. *Severity:* security-critical (data exposure — PII). *(inferred)* +- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated or public-key-only request. *Severity:* security-critical. *(documented)* +- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness — §14 Q4)* +- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. On the public endpoints, `profileId` / `sessionId` are **bearer identifiers** (typically the `context-profile-id` cookie): possession of the identifier is the authorization model. *Violation symptom:* cross-profile / cross-scope data access **without** possessing that bearer (e.g. body `profileId` accepted when it does not match the cookie bearer; session→profile switch without ownership; unauthenticated GraphQL subscription receiving events). *Severity:* security-critical (data exposure — PII). *(maintainer)* +- **Authentication on all GraphQL transports.** HTTP queries/mutations and WebSocket upgrade/subscribe share the same authentication requirements; subscriptions are not a public operation. *Violation symptom:* unauthenticated client completes upgrade and reaches `graphQL.execute` for a subscription. *Severity:* security-critical → high. *(maintainer)* +- **Tenant isolation.** Credentials for tenant A cannot read or modify tenant B’s profiles, segments, rules, or keys. Unomi enforces this boundary in the data plane. *Violation symptom:* cross-tenant data access. *Severity:* security-critical. *(maintainer — §14 Q8a / UNOMI-139)* +- **No privilege escalation beyond credential class.** Host-impacting operations (unsandboxed script upload/compile/exec, absolute filesystem access, unconstrained remote Camel endpoints) require **system administrator**, not tenant administrator. *Violation symptom:* tenant private key achieves host RCE, arbitrary file read, or equivalent. *Severity:* security-critical. *(maintainer — §14 Q8a)* - **Resource bounds — UNSPECIFIED.** Whether a public event flood or an expensive segment/condition is a bug or expected-and-operator-managed is open. *(inferred)* ## §9 Security properties the project does *not* provide -- **No protection if the admin REST/GraphQL APIs are exposed unauthenticated / with default creds** — keeping the admin surface off the public network + authenticated is the operator's job (pending §5a ruling). *(inferred)* +- **No protection if the admin REST/GraphQL APIs are exposed to the public network** — keeping the admin surface off the public network is the operator's job (§10). On **pre-3.1** builds this disclaimer also covered leaving the documented default JAAS password unchanged (§5a layer 1). It does **not** extend to 3.1 and later: 3.1 ships no known default, so a credential that works without the operator having configured one is a defect there, not a disclaimed property (§5a layer 2). *(maintainer — §14 Q1)* - **No confidentiality/integrity for the ES/OS backend or its network** — Unomi assumes a secured backend; it does not defend an exposed Elasticsearch. *(inferred)* -- **Not a sandbox for admin-authored expressions/plugins** — a trusted author with condition/scripting authority can run server-side logic by design; that power is not contained. *(inferred)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe. +- **Not a sandbox for system-administrator-authored expressions/plugins** — a system admin with condition/scripting authority can run server-side logic by design; that power is not contained. *(maintainer — §14 Q7)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe. **This disclaimer does not cover tenant-administrator script upload or Camel config that reaches host power** — that is a §8 privilege-escalation / tenant-isolation property. +- **No sandbox for uploaded Groovy actions — uploading one is equivalent to shell access on the host.** A Groovy action is compiled and dispatched unrestricted inside the server JVM, with the server's user, classpath and network reach, and it is persisted and re-run. Treat `POST /cxs/groovyActions` as remote code execution *by design*, and the credential that reaches it as a host credential. From 3.1 the endpoint requires the **system** `ADMINISTRATOR` role — a tenant administrator (tenant private key) cannot reach it, and a report showing tenant-admin reach remains a §8 violation (§11a). No sandbox is planned, because none is dependable: the Java `SecurityManager` is deprecated-for-removal and disabled in current JDKs (JEP 411/486), Groovy's `SecureASTCustomizer` is a compile-time syntax restriction that dynamic dispatch routes around, and interceptor-based sandboxes have a sustained escape history. The controls are therefore the role gate, compiling without instantiating so an uploaded script cannot execute at upload time, and a WARN-level audit record of every save/remove with the script's SHA-256. Operators who do not use Groovy actions should uninstall the `unomi-groovy-actions` feature. *(maintainer)* +- **No guarantee that possession of a visitor `profileId` UUID is hard** — the id is a bearer token; confidentiality of the cookie (and flags such as HttpOnly) is largely an operator/frontend concern, though unsafe defaults may be `VALID-HARDENING`. *(maintainer)* - **No guarantee of correctness of analytics/segmentation under adversarial event injection** beyond the access-control boundary. *(inferred)* -- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, and DoS via event floods. *(documented history; framing inferred)* +- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, DoS via event floods, and deploying sample identity-merge rules without verified identity (§11). *(documented history; maintainer framing)* ## §10 Downstream responsibilities (operator/deployer) -*(All inferred — confirm.)* - -- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; change any default credentials. *(inferred)* +- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; on 3.1 and later, **set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` explicitly** before starting the server — there is no shipped default and the shell launchers will not start without them — on Windows, verify the password took effect rather than relying on the check. On pre-3.1 builds, change the documented default JAAS credentials before production use. *(maintainer — §14 Q1)* - Keep JSON-Schema validation and the public-event allow-list **enabled**; register schemas for the events you accept. *(inferred)* - Secure the Elasticsearch/OpenSearch backend + its network. *(inferred)* -- Restrict who holds condition/rule/scripting authoring authority — it is equivalent to server-side code definition. *(inferred)* +- Restrict who holds **system-administrator** condition/rule/scripting and extension-config authority — it is equivalent to server-side code definition. Treat **tenant private keys** as high privilege within a tenant, not as host-admin equivalents. *(maintainer)* +- Do not deploy sample login-merge (or similar) rules that bind public events to identity-merge/update actions without a **verified** identity step. *(maintainer)* +- Prefer `profileId` cookie `HttpOnly=true` (and Secure where appropriate) in production. *(maintainer)* - Put the public endpoint behind rate-limiting / a CDN/WAF appropriate to public exposure. *(inferred)* ## §11 Known misuse patterns -*(Draft one-liners — expand before publishing.)* - -- Exposing the admin REST/GraphQL APIs to the internet (or leaving default creds). *(inferred)* +- Exposing the admin REST/GraphQL APIs to the internet; on pre-3.1 builds, leaving the documented default JAAS credentials unchanged in production; on 3.1 and later, reusing one shared, well-known `UNOMI_ROOT_PASSWORD` across deployments instead of provisioning a per-deployment secret. *(maintainer)* - Disabling JSON-Schema validation or the public-event allow-list "to make integration easier", re-opening the public surface. *(inferred)* - Treating the scripting/expression allow-list as a sandbox for admin-authored conditions. *(inferred)* - Exposing Elasticsearch/OpenSearch alongside Unomi without backend auth. *(inferred)* +- Wiring `mergeProfilesOnPropertyAction` / `updatePropertiesAction` (or equivalents) to **public** event types using unverified `eventProperty::` identity claims in production (the login-integration **sample** demonstrates the correct **server-side trusted** pattern under `/login/authenticate`; do not copy a browser→Unomi login POST). *(maintainer)* +- Granting tenant administrators Groovy upload or router `file`/`ftp`/`sftp` configuration without path/host confinement. *(maintainer)* +- Assuming GraphQL WebSocket is covered by HTTP-only auth checks. *(maintainer)* ## §11a Known non-findings (recurring false positives) -*(Seed list — PMC confirmation here is the highest-leverage scan-suppression input.)* +*(PMC confirmation here is the highest-leverage scan-suppression input.)* -- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **trusted admin-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/unauthenticated** input reaching expression evaluation; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes)* -- "Scripting / reflection present in `scripting` module" — needs the public-reachability test (§4) before it is a finding. *(inferred)* -- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design**; the protection is schema validation + the event allow-list, not authentication. *(inferred)* +- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **system-administrator-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/untrusted** input, or a **lower privilege than required**, reaching expression evaluation / host script power; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes; maintainer — §14 Q7)* +- "Scripting / reflection present in `scripting` module" — needs the public-reachability (or privilege-escalation) test (§4) before it is a finding. *(inferred)* +- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design** (aside from tenant public API key resolution); the protection is schema validation + the event allow-list, not visitor login. *(documented; maintainer)* +- "Public `/context.json` returns profile data for a supplied `profileId`" — **by design** when that id is the caller’s bearer (cookie / equivalent). Not automatically `VALID` as “IDOR” merely because the attacker knows the UUID. `VALID` / `VALID-HARDENING` when the body id is accepted **without** matching the bearer cookie, when session load switches profile without ownership, or when cookie flags make XSS→id theft trivial by unsafe default. *(maintainer)* - "Elasticsearch reachable / no TLS" — operator deployment responsibility (§9/§10). *(inferred)* -- "Admin can run dangerous operation X" — out-of-model: admin is trusted (§7). *(inferred)* +- "System administrator can run dangerous operation X" — out-of-model: system admin is trusted (§7). **Does not apply** to tenant administrator achieving host RCE, arbitrary file read, or cross-tenant access — those are §8 violations. *(maintainer — §14 Q8a)* +- "Uploaded Groovy actions are not sandboxed / run arbitrary commands" — out-of-model against **3.1 and later**: upload requires the system `ADMINISTRATOR` role, and unrestricted execution is the documented, intended property of the feature (§9). `VALID` only if it shows a **lower privilege than system administrator** reaching upload (tenant private key, public key, unauthenticated), execution occurring at **upload/compile time** rather than at dispatch, or the role gate being bypassable. Absence of a sandbox is not itself a finding. *(maintainer)* +- "The shipped default admin password works on a fresh install" — against **pre-3.1** builds: documented must-configure (§5a layer 1); not `VALID` solely on that basis. That class of report was accepted as **`VALID-HARDENING` motivation** for the 3.1 default-password retirement (§5a layer 2), **which shipped in 3.1**. Against **3.1 and later** it is no longer a non-finding: a known working default, or any fallback admitting a credential the operator never configured, is `VALID`. *(maintainer — §14 Q1)* ## §12 Conditions that would change this model - A change to the public-endpoint protection model, the JSON-Schema-validation default, the scripting allow-list, or admin-auth defaults. *(inferred)* -- A new public surface or a new expression/scripting capability reachable from untrusted input. *(inferred)* +- **Shipped:** 3.1 retired the known default JAAS password (§5a layer 2). §5a, §9, §11a and §13 have been revised accordingly — a working known default is no longer `BY-DESIGN` / must-configure on 3.1+, and a regression reintroducing one is `VALID`. Reintroducing *any* implicit credential fallback would require revisiting this model again. *(maintainer)* +- A new public surface or a new expression/scripting capability reachable from untrusted input or from tenant credentials. *(maintainer)* - Promoting a `samples/` or third-party connector into core. *(inferred)* +- A change to the tenant-isolation or privilege-escalation guarantees (§8). *(maintainer)* - A report that cannot be routed to one §13 disposition → revise the model. ## §13 Triage dispositions | Disposition | Meaning | Licensed by | | --- | --- | --- | -| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; pre-auth crash). | §8, §6, §7 | -| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden. | §11 | -| `OUT-OF-MODEL: trusted-input` | Requires admin/authenticated authority (e.g. an admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 | +| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; GraphQL auth bypass on any transport; tenant isolation break; tenant→host privilege escalation; pre-auth crash). On 3.1+, also: reintroduction of a known working default admin credential, or any fallback admitting a credential the operator never configured (§5a layer 2). | §8, §6, §7, §5a | +| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden (e.g. HttpOnly default). Historically covered the 3.1 retirement of the known default JAAS password, which has since shipped and is now `VALID` on regression (§5a layer 2). | §11, §5a | +| `OUT-OF-MODEL: trusted-input` | Requires **system-administrator** authority (e.g. a system-admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 | | `OUT-OF-MODEL: adversary-not-in-scope` | Requires host/container/backend control or another excluded capability. | §7 | -| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors. | §3 | -| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting. | §5a | -| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no admin-expression sandbox, unauthenticated-by-design context endpoint, backend security). | §9 | +| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors (sample-only; core behaviour followed from samples may still be `VALID`). | §3 | +| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting, or solely under an unchanged documented default credential on a **pre-3.1** build (§5a layer 1). | §5a | +| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no system-admin expression sandbox, unauthenticated-by-design context endpoint, backend security, documented default creds on **pre-3.1** builds). | §9 | | `KNOWN-NON-FINDING` | Matches a §11a entry. | §11a | | `MODEL-GAP` | Cannot be cleanly routed — triggers §12. | §12 | ## §14 Open questions for the maintainers **Wave 1 — scope & default posture:** -1. Confirm the trust split: the **context endpoint is public/unauthenticated by design**, while the **REST/GraphQL admin APIs are not public and are authenticated**. What are the defaults (bind address, default credentials)? Is exposing the admin API or leaving a default credential a `VALID` report or a documented must-configure? → §2/§5a/§7. -2. Confirm `unomi-tracker` is modeled as a client-side satellite and `unomi-site`/`samples`/`itests` are out of scope. → §2/§3. +1. ~~Confirm the trust split… default credentials?~~ **Answered (2026-08):** Context endpoint is public (public API key) by design; admin REST/GraphQL are authenticated and not intended to be public. **Triage (pre-3.1):** the packaged default JAAS admin password was a **documented must-configure** → reports that only show the default still works on those lines are `OUT-OF-MODEL: non-default-build` / `BY-DESIGN`. **Product (3.1) — shipped (UNOMI-972):** the known working default has been retired. `org.apache.unomi.security.root.password` binds to `${env:UNOMI_ROOT_PASSWORD}` with no fallback, `users.properties` ships no default, the health user requires `UNOMI_HEALTHCHECK_PASSWORD`, and the shell launchers fail fast when either is unset (advisory only under `karaf.bat`). That work is no longer tracked as `VALID-HARDENING`: on 3.1 and later, a regression reintroducing a known default — or any fallback admitting an unconfigured credential — is `VALID`. Auth bypasses remain `VALID` in all versions. → §2/§5a/§7/§9/§12. +2. ~~Confirm `unomi-tracker`… samples/`itests` out of scope.~~ **Answered (2026-08):** Tracker is a client-side satellite; `unomi-site` / `samples` / `itests` are out of scope (§3). → §2/§3. 3. The model covers the apache/unomi server; should `unomi-tracker` get its own (lighter) model later, or a discoverability pointer to this one? → §1. **Wave 2 — the public boundary & its defenses:** @@ -181,11 +207,11 @@ Per-surface trust table *(all inferred unless noted):* 6. Are there bounds on public event size / batch / rate, or is flood protection the operator's (WAF/rate-limit) concern? → §8/§11a. **Wave 3 — expressions, scopes, backend:** -7. Confirm that OGNL/MVEL expression power is **by-design for trusted admin-authored** conditions and is **not** a sandbox — so a finding is `VALID` only when *public/untrusted* input reaches it. → §9/§11a. -8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8. -8a. **Tenant isolation (UNOMI-139):** can an API client with credentials for tenant A read or modify tenant B's profiles, segments, or rules — and is that boundary enforced by Unomi (`TenantService` / `SecurityService.getTenantEncryptionKey`) or delegated to the integrator? → §8/§10. *(maintainer-flagged — sergehuber)* +7. ~~Confirm that OGNL/MVEL…~~ **Answered (2026-08):** Expression power is by-design for **system-administrator-authored** conditions and is not a sandbox. `VALID` when public/untrusted input reaches it, or when a lower privilege (tenant admin) reaches host script power that should require system admin. → §9/§11a. +8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8. *(partially addressed by bearer-id clarification; scope semantics still open)* +8a. ~~**Tenant isolation (UNOMI-139):**…~~ **Answered (2026-08):** Unomi **enforces** tenant isolation in the data plane. Tenant A credentials must not read/modify tenant B. Tenant administrator must **not** obtain host RCE, arbitrary filesystem access, or unconstrained Camel reach — those require system administrator. Violations are `VALID`. → §8/§10. *(maintainer — sergehuber)* 9. Is the Elasticsearch/OpenSearch backend assumed trusted/secured-by-operator (so backend-exposure reports are out-of-model)? → §3/§9. **Wave 4 — meta & non-findings:** 10. Any other recurring scanner/fuzzer false positives to seed §11a (e.g. the `scripting` module, reflection, OSGi dynamic loading)? → §11a. -11. **Meta:** Unomi has no in-repo `SECURITY.md`/`AGENTS.md` today; this engagement adds `SECURITY.md` + `THREAT_MODEL.md` and wires `AGENTS.md → SECURITY.md → THREAT_MODEL.md`. The website publishes CVE advisories at `unomi.apache.org/security/`. Confirm the in-repo model is canonical and how it should reference the website advisories; confirm revision ownership. → §1. +11. **Meta:** Confirm the in-repo model is canonical and how it should reference website advisories at `unomi.apache.org/security/`; confirm revision ownership. (In-repo `SECURITY.md` / `AGENTS.md` wiring may already exist on current `master` — verify at ratification.) → §1. diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc index c42a9404a7..002e304c31 100644 --- a/manual/src/main/asciidoc/5-min-quickstart.adoc +++ b/manual/src/main/asciidoc/5-min-quickstart.adoc @@ -35,6 +35,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1 + - UNOMI_ROOT_PASSWORD=choose-a-strong-password + - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password ports: - 8181:8181 - 9443:9443 @@ -84,6 +86,8 @@ services: - UNOMI_OPENSEARCH_SSL_ENABLE=true - UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence + - UNOMI_ROOT_PASSWORD=choose-a-strong-password + - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password ports: - 8181:8181 - 9443:9443 @@ -104,7 +108,7 @@ Once Unomi is running, create a tenant, then **regenerate** API keys and save th [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -114,13 +118,13 @@ curl -X POST http://localhost:8181/cxs/tenants \ } }' -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Use the public API key in `X-Unomi-Api-Key` for `/cxs/context.json` requests. See <<_multitenancy,Multi-tenancy>>. -Try accessing https://localhost:9443/cxs/cluster with username/password: karaf/karaf . You might get a certificate warning in your browser, just accept it despite the warning it is safe. +Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. === Quick Start manually @@ -159,6 +163,14 @@ discovery.type: single-node 5) Download Apache Unomi here : https://unomi.apache.org/download.html +5b) Before starting Karaf, export required passwords (Unomi will refuse to start without them): + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + 6) Start it using : `./bin/karaf` 7) Start the Apache Unomi packages using: @@ -173,14 +185,14 @@ which determines which set of features and bundles are installed and started. A 8) Wait for startup to complete -9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf/karaf` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. +9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe. 10) Create a tenant that will own all your data, then regenerate keys and store `plainTextKey`: [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -190,8 +202,8 @@ curl -X POST http://localhost:8181/cxs/tenants \ } }' -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Save the `plainTextKey` values from the key-creation responses — you'll need them for API calls. diff --git a/manual/src/main/asciidoc/builtin-event-types.adoc b/manual/src/main/asciidoc/builtin-event-types.adoc index 800c1b3fed..92c5bf6a61 100644 --- a/manual/src/main/asciidoc/builtin-event-types.adoc +++ b/manual/src/main/asciidoc/builtin-event-types.adoc @@ -23,7 +23,9 @@ This event should be “secured”, meaning that it should not be accepted from Usually, the login event will contain information passed by the authentication server and may include user properties and any additional information. Rules may be set up to copy the information from the event into the profile, but this is not done in the default set of rules provided by Apache Unomi for security reasons. -You can find an example of such a rule here: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json] +You can find an example of such a rule here: +https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[exampleLogin.json] +(see <<_login_sample,Login sample>> — the event must be sent from a trusted server-side caller). ===== Structure overview @@ -240,7 +242,9 @@ image::form-event-type.png[] This event is usually used by user interfaces that make it possible to modify profile properties, for example a form where a user can edit his profile properties, or a management UI to modify. -Note that this event type is a protected event type that is only accepted from configured third-party servers. +Note that this event type is a protected event type that is only accepted from configured third-party servers +(or equivalently from a trusted private-key / administrator caller in 3.1). Cross-profile updates and +`systemProperties.*` writes also require a trusted caller — see <<_client_facing_hardening_3_1,client-facing hardening>>. ===== Structure definition diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc index b76b2abb2e..2ebbbc88c6 100644 --- a/manual/src/main/asciidoc/configuration.adoc +++ b/manual/src/main/asciidoc/configuration.adoc @@ -248,7 +248,10 @@ At the end, you should have about 4 million entries in the geonames index. === REST API Security The Apache Unomi Context Server REST API is protected using JAAS authentication and using Basic or Digest HTTP auth. -By default, the login/password for the REST API full administrative access is "karaf/karaf". +You must set an admin password via `UNOMI_ROOT_PASSWORD` (or `org.apache.unomi.security.root.password`) +and a health-check password via `UNOMI_HEALTHCHECK_PASSWORD` (or `org.apache.unomi.healthcheck.password`); +Unomi does not ship known defaults. +The default JAAS user name is `karaf`. The generated package is also configured with a default SSL certificate. You can change it by following these steps : @@ -267,8 +270,9 @@ org.ops4j.pax.web.ssl.keypassword=${env:UNOMI_SSL_KEYPASSWORD:-changeme} You should now have SSL setup on Karaf with your certificate, and you can test it by trying to access it on port 9443. -Changing the default Karaf password can be done by modifying the `org.apache.unomi.security.root.password` in the -`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file +Changing the Karaf admin password is done by setting `UNOMI_ROOT_PASSWORD` or by modifying +`org.apache.unomi.security.root.password` in the +`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file. Unomi does not ship a known default password. === Tenant Management and API Access @@ -278,14 +282,14 @@ Apache Unomi supports multi-tenancy, allowing multiple organizations to use the IMPORTANT: All tenant management operations (create, list, update, delete, API key management) are restricted to administrators only and require JAAS authentication. These endpoints cannot be accessed using tenant API keys. -To manage tenants, you need administrator access to Unomi (default credentials: karaf/karaf). You can manage tenants using either the REST API or the Karaf shell commands: +To manage tenants, you need administrator access to Unomi (`karaf` / your `UNOMI_ROOT_PASSWORD`). You can manage tenants using either the REST API or the Karaf shell commands: Using REST API (requires admin credentials): [source,bash] ---- # Create a new tenant (JAAS auth required) curl -X POST "http://localhost:8181/cxs/tenants" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "mytenant", @@ -318,17 +322,17 @@ curl -X POST "http://localhost:8181/cxs/tenants" \ # List all tenants (JAAS auth required) curl -X GET "http://localhost:8181/cxs/tenants" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" # Get tenant details (JAAS auth required) curl -X GET "http://localhost:8181/cxs/tenants/mytenant" \ - -u karaf:karaf \ + -u "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" # Delete a tenant (JAAS auth required) curl -X DELETE "http://localhost:8181/cxs/tenants/mytenant" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" ---- Using Karaf shell (requires admin access to Karaf console). See <<_shell_commands,Shell commands>> for full syntax: @@ -362,9 +366,9 @@ unomi:crud read tenant -i mytenant # Obtain plaintext (store immediately; it is not persisted) curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" # Response example: { @@ -382,7 +386,7 @@ To generate new API keys (requires admin access): ---- # Using REST API (JAAS auth required). Replaces any existing key of the same type. curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC&validityDays=30" \ - -u karaf:karaf + -u "karaf:$UNOMI_ROOT_PASSWORD" # Response (HTTP 200 OK) — store plainTextKey immediately: { @@ -442,7 +446,7 @@ curl -X POST "http://localhost:8181/cxs/profiles/search" \ [source,bash] ---- curl -X GET "http://localhost:8181/cxs/tenants" \ - --user "karaf:karaf" \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" ---- @@ -817,10 +821,12 @@ Once the action has been created you need to submit it to Unomi (from the same f [source,bash] ---- curl -X POST 'http://localhost:8181/cxs/groovyActions' \ ---user "TENANT_ID:PRIVATE_KEY" \ +--user "karaf:${UNOMI_ROOT_PASSWORD}" \ --form 'file=@helloWorldGroovyAction.groovy' ---- +NOTE: Groovy Actions REST requires the system `ADMINISTRATOR` role (not a tenant private key). See <<_client_facing_hardening_3_1,client-facing hardening>>. + Important: A bug ( https://issues.apache.org/jira/browse/UNOMI-847[UNOMI-847] ) in Apache Unomi 2.5 and lower requires the filename of a Groovy file being submitted to be the same as the id of the Groovy action (as per the example above). Finally, register a rule to trigger execution of the groovy action: @@ -860,7 +866,7 @@ Once you're done with the Hello World! action, it can be deleted using the follo [source,bash] ---- curl -X DELETE 'http://localhost:8181/cxs/groovyActions/helloWorldGroovyAction' \ ---user "TENANT_ID:PRIVATE_KEY" +--user "karaf:${UNOMI_ROOT_PASSWORD}" ---- And the corresponding rule can be deleted using the following command: @@ -1078,7 +1084,7 @@ The `MergeProfilesOnPropertyAction` supports the following parameters: ==== Security considerations -IMPORTANT: Never trigger profile merges from unauthenticated operations such as form submissions or public-facing APIs. Always verify user identity before performing a merge. +IMPORTANT: Never trigger profile merges from unauthenticated or public-key-only operations such as form submissions or public-facing context events. Merging into another profile (or switching identity after a merge) requires a **trusted** caller — system administrator or tenant administrator (private key). Always verify user identity on your application server before emitting a merge-triggering event. See <<_client_facing_hardening_3_1,client-facing hardening>>. The following diagram highlights key security considerations: @@ -1178,23 +1184,29 @@ documentation for here : * https://karaf.apache.org/manual/latest/#_security_2[https://karaf.apache.org/manual/latest/#_security_2] -The default username/password is +You must set an admin password and a health-check password before using the REST API / health endpoints. +There are **no** known default passwords shipped with Unomi. -[source] +Set the environment variables `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD`, or set +`org.apache.unomi.security.root.password` and `org.apache.unomi.healthcheck.password` in +`$MY_KARAF_HOME/etc/unomi.custom.system.properties` (or `etc/custom.system.properties`). + +Example: + +[source,bash] ---- -karaf/karaf +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ---- -You should really change this default username/password as soon as possible. Changing the default Karaf password can be -done by modifying the `org.apache.unomi.security.root.password` in the `$MY_KARAF_HOME/etc/unomi.custom.system.properties` file - -Or if you want to also change the user name you could modify the following file : +The JAAS admin user name defaults to `karaf`. Authenticate with `karaf:$UNOMI_ROOT_PASSWORD`. +The health-check user name defaults to `health`. Authenticate with `health:$UNOMI_HEALTHCHECK_PASSWORD`. - $MY_KARAF_HOME/etc/users.properties +To change the admin user name, edit `$MY_KARAF_HOME/etc/users.properties` and also set: -But you will also need to change the following property in the $MY_KARAF_HOME/etc/unomi.custom.system.properties : + karaf.local.user = - karaf.local.user = karaf +in `unomi.custom.system.properties`. For your context servers, and for any standalone Elasticsearch nodes you will need to open the following ports for proper node-to-node communication : 9200 (Elasticsearch REST API), 9300 (Elasticsearch TCP transport) diff --git a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc index 9260397472..abe1763842 100644 --- a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc +++ b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc @@ -77,7 +77,7 @@ If this is not the case or you prefer to deploy using a KAR bundle, see the KAR + [source] ---- -ssh -p 8102 karaf@localhost (default password is karaf) +ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD) ---- + . Deploy into Apache Unomi using the following commands from the Apache Karaf shell: @@ -111,8 +111,9 @@ The first URL will give you information about the version of the connectors, so plugin is properly deployed, started and the correct version. The second URL will actually make a request to the Salesforce REST API to retrieve the limits of the Salesforce API. + -Both URLs are password protected by the Apache Unomi (Karaf) password. You can find this user and password information -in the etc/users.properties file. +Both URLs are password protected by the Apache Unomi (Karaf) password: the `karaf` user and the +`UNOMI_ROOT_PASSWORD` you configured at startup. No default password is shipped, and +`etc/users.properties` only references the configured value rather than containing it. You can now use the connectors's defined actions in rules to push or pull data to/from the Salesforce CRM. You can find more information about rules in the <<_data_model_overview,Data Model>> and the <<_getting_started_with_unomi,Getting Started>> pages. @@ -152,7 +153,7 @@ mvn clean install + [source] ---- -ssh -p 8102 karaf@localhost (password by default is karaf) +ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD) ---- + . Execute the following commands in the Karaf shell @@ -171,7 +172,7 @@ feature:install unomi-salesforce-connector-karaf-feature https://localhost:9443/cxs/sfdc/version ---- + -(if asked for a password it's the same karaf/karaf default) +(if asked for credentials, use the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) ==== Using the Salesforce Workbench for testing REST API diff --git a/manual/src/main/asciidoc/getting-started.adoc b/manual/src/main/asciidoc/getting-started.adoc index 223cb38486..1351cbc955 100644 --- a/manual/src/main/asciidoc/getting-started.adoc +++ b/manual/src/main/asciidoc/getting-started.adoc @@ -50,6 +50,21 @@ Note for OpenSearch users: ==== Running Unomi +===== Set the admin and health passwords (required) + +Unomi does not ship known default passwords. Set both *before* starting the server +(otherwise startup fails with a clear error): + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + +Authenticate later with `karaf:$UNOMI_ROOT_PASSWORD`. See the configuration chapter if you prefer to set +`org.apache.unomi.security.root.password` / `org.apache.unomi.healthcheck.password` in +`etc/custom.system.properties` instead. + ===== Start Unomi Start Unomi according to the <<_five_minutes_quickstart,quick start with docker>> or by compiling using the @@ -67,14 +82,14 @@ Initializing profile service endpoint... Initializing cluster service endpoint... ---- -This indicates that all the Unomi services are started and ready to react to requests. +This indicates that all the Unomi services are started and ready to react to requests. -Before you can use the API, you need to create a tenant: +Create a tenant (using the password you set before startup): [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:${UNOMI_ROOT_PASSWORD}" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "default", @@ -89,8 +104,8 @@ The tenant create response includes **masked** API key metadata only. Regenerate [source,bash] ---- -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}" +curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}" ---- Store `plainTextKey` immediately — you'll need the public key for subsequent API calls. See <<_multitenancy,Multi-tenancy>> for authentication details. diff --git a/manual/src/main/asciidoc/graphql-examples.adoc b/manual/src/main/asciidoc/graphql-examples.adoc index 847a7ac99c..57656e60ac 100644 --- a/manual/src/main/asciidoc/graphql-examples.adoc +++ b/manual/src/main/asciidoc/graphql-examples.adoc @@ -171,13 +171,16 @@ To make this query work you need to supply authorization token in the `HTTP head [source,json] ---- { - "authorization": "Basic a2FyYWY6a2FyYWY=" + "authorization": "Basic BASE64_OF_KARAF_AND_ROOT_PASSWORD" } ---- -NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (`karaf:karaf`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication. +Generate the Base64 value from your own credentials, for example with +`printf 'karaf:%s' "$UNOMI_ROOT_PASSWORD" | base64`. -When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user karaf:karaf` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads: +NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication. + +When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user "karaf:$UNOMI_ROOT_PASSWORD"` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads: [source,bash] ---- diff --git a/manual/src/main/asciidoc/how-profile-tracking-works.adoc b/manual/src/main/asciidoc/how-profile-tracking-works.adoc index 587ba0f9b6..3b7c35f4c8 100644 --- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc +++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc @@ -122,7 +122,7 @@ Starting with Apache Unomi 3.1, tenant resolution is mandatory for all requests 3. **Tenant ID Header** (When using JAAS authentication): * Send the `X-Unomi-Tenant-Id` header with the tenant ID - * Used when authenticating via JAAS (e.g., `karaf:karaf`) + * Used when authenticating via JAAS (e.g., the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) * The tenant ID must exist in the system If no tenant can be resolved, the request will fail with an `UNAUTHORIZED` (401) error. This ensures that all data operations are scoped to the correct tenant in multi-tenant deployments. @@ -173,7 +173,7 @@ The tenant is resolved from the Basic Auth credentials (`mytenant` is the tenant [source,bash] ---- curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \ ---user "karaf:karaf" \ +--user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "X-Unomi-Tenant-Id: mytenant" \ -H "Content-Type: application/json" \ -d '{ @@ -232,24 +232,26 @@ end note [IMPORTANT] ==== -At least one of the following must be provided in the request: `sessionId`, `profileId` (as a parameter or in a cookie), or `personaId`. If none of these are provided, the request will fail with a `BadRequestException` (400 error). +At least one of the following must be provided in the request: `sessionId`, a profile cookie (`context-profile-id` by default), a body/query `profileId` **from a trusted caller**, or `personaId`. If none of these are available after public-caller rules below, the request fails with a `BadRequestException` (400). ==== Apache Unomi attempts to identify the visitor's profile through the following process: -1. **Profile ID Resolution**: - * First checks for a `profileId` parameter in the request - * If not found, looks for a cookie named `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`) - * Cookie values are validated against a JSON schema - invalid values (e.g., containing script tags) will cause a `400 Bad Request` error - * The resolved profile ID is used to attempt loading the profile from the database +1. **Profile ID Resolution** (public vs trusted callers): + * **Public callers** (public API key / unauthenticated context): the profile cookie is the **only** profile bearer. A body or query `profileId` is **ignored** (even when no cookie is present). + * **Trusted callers** (system administrator or tenant private-key / `TENANT_ADMINISTRATOR`): an explicit body/query `profileId` is honored and may differ from the cookie. + * Cookie name defaults to `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`). + * Cookie values are validated against a JSON schema — invalid values (for example containing script tags) cause a `400 Bad Request`. + * The resolved profile ID is used to attempt loading the profile from the database. 2. **Session Profile Override** (if session exists): - * If a session is found (see Step 2) and contains a profile ID that differs from the cookie/profileId parameter - * Apache Unomi uses the session's profile ID instead (this handles cases where a user switches accounts) - * The profile is reloaded from the database using the session's profile ID + * If a session is found (see Step 2) and its profile differs from the request profile, Unomi switches to the session profile **only when**: + ** the profile cookie already matches the session owner, **or** + ** the caller is trusted (and is not keeping an explicit trusted body `profileId` override). + * Otherwise the session is **detached** for this request (Unomi does not adopt a foreign session profile for a public caller). 3. **Profile Creation**: If no profile ID is found or the profile doesn't exist: - * If a profile ID was provided (from parameter or cookie) but doesn't exist in the database, creates a new profile with that ID + * If a profile ID was provided (from cookie, or from a trusted body/query `profileId`) but doesn't exist in the database, creates a new profile with that ID * If no profile ID was found, generates a new UUID as the profile ID and creates a new profile * Sets the `firstVisit` property to the current timestamp * Marks the profile for persistence @@ -259,9 +261,20 @@ This ensures that profiles are always available for processing, even for first-t [IMPORTANT] ==== -The profile ID is always server-generated (UUID format). Even if a client sends a custom profile ID in a cookie or parameter, Apache Unomi validates it exists in the database. If it doesn't exist, a new profile is created (potentially with that ID if provided via parameter, or with a newly generated UUID). This makes profile IDs secure and prevents profile ID manipulation. +For **public** callers, do not rely on body/query `profileId` as identity — always send the profile cookie (browsers do this automatically when credentials are included). To act as a specific profile from a backend or admin tool, authenticate as a **trusted** caller (tenant private key or system administrator) and then send `profileId`. See <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> for client migration notes. ==== +====== Why the cookie is safer than a body `profileId` for public callers + +On public `/context.json` and `/eventcollector` endpoints, the profile id is a **bearer** identifier: whoever presents it can continue that visitor's tracking context. Unomi therefore prefers the **HTTP cookie** over a body or query `profileId` for public traffic: + +* **Browser-enforced delivery** — Once `Set-Cookie` has established the profile cookie (with `HttpOnly` and `SameSite` by default), the browser attaches it on later requests to Unomi. Application JavaScript does not need to copy the id into JSON, so routine front-end code is less likely to mishandle or over-share it. +* **Harder for page script to exfiltrate** — With `HttpOnly` (the default), script running in the page cannot read the cookie via `document.cookie`. A body/query `profileId` is application-controlled data: anything that can influence the request payload can try to point at another profile id. +* **Clearer trust boundary** — Public callers prove continuity with the cookie the server previously issued. Selecting an arbitrary id in the body blurred that boundary (knowing or guessing a UUID was enough). Trusted callers (private key / system admin) may still pass `profileId` explicitly when a backend intentionally acts on a chosen profile. +* **Fewer accidental leaks in URLs and logs** — Query-string `profileId` values show up in browser history, proxies, and access logs more often than `Cookie` headers. Prefer the cookie (and read `profileId` from the JSON **response** when your app needs the value in memory). + +Body/query `profileId` remains appropriate for **authenticated** integrations that are supposed to bind a specific profile under operator control — not for anonymous browser trackers. + ===== Example: Profile Identification **Example 1: First-Time Visitor (No Cookie)** @@ -327,13 +340,15 @@ curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \ Apache Unomi loaded the existing profile using the profile ID from the cookie. -**Example 3: Using profileId Parameter** +**Example 3: Trusted caller using body/query `profileId`** + +Public callers must send the cookie (Example 2). A trusted caller (tenant private key) may select a profile explicitly: [source,bash] ---- -# Request with explicit profileId parameter +# Trusted: Basic auth with tenantId:privateKey — body/query profileId is honored curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1b2c3d4-e5f6-7890-abcd-ef1234567890" \ --H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \ +--user "TENANT_ID:PRIVATE_KEY" \ -H "Content-Type: application/json" \ -d '{ "source": { @@ -344,17 +359,16 @@ curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1 }' ---- -The `profileId` parameter takes precedence over the cookie value. +For public callers, a body/query `profileId` without a matching cookie is ignored (it does **not** take precedence over the cookie). [plantuml] ---- @startuml title Profile and Session Identification Flow -RestServiceUtils -> RestServiceUtils: Get profileId from parameter -alt profileId parameter exists - RestServiceUtils -> ProfileService: load(profileId) -else Check cookie +alt trusted caller with body/query profileId + RestServiceUtils -> ProfileService: load(bodyOrQueryProfileId) +else public or no explicit trusted profileId RestServiceUtils -> HttpServletRequest: getCookie("context-profile-id") HttpServletRequest --> RestServiceUtils: profileId from cookie RestServiceUtils -> ProfileService: load(profileId) @@ -374,9 +388,11 @@ alt sessionId provided RestServiceUtils -> ProfileService: loadSession(sessionId) alt Session found RestServiceUtils -> RestServiceUtils: Check session profile - alt Session profile differs + alt Session profile differs and (cookie owns session or trusted) RestServiceUtils -> ProfileService: load(sessionProfileId) RestServiceUtils -> RestServiceUtils: Use session's profile + else public mismatch + RestServiceUtils -> RestServiceUtils: Detach session for this request end else Session not found RestServiceUtils -> RestServiceUtils: Create new Session(sessionId, profile) @@ -393,7 +409,7 @@ end If a `sessionId` is provided in the request: 1. **Session Loading**: Apache Unomi attempts to load the existing session from the database -2. **Profile Association**: If a session is found, it may contain a profile ID that takes precedence over the cookie/profileId parameter +2. **Profile Association**: If a session is found and its profile differs from the request profile, Unomi switches only when the cookie owns that session or the caller is trusted (see Step 1). Public callers cannot adopt a foreign session profile. 3. **Session Creation**: If no session is found or the session is invalidated: * Creates a new session with the provided `sessionId` * Associates the session with the current profile (or anonymous profile if privacy settings require it) @@ -804,7 +820,7 @@ Cookies are only set for regular profiles, not for Personas. If a `personaId` is * `Max-Age=31536000` - Valid for 1 year by default (configurable via `org.apache.unomi.profile.cookie.maxAgeInSeconds`) * `SameSite=Lax` - CSRF protection * `Secure` - Set if the request is over HTTPS (configurable) - * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: false) + * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`) * `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain` This cookie allows the browser to automatically send the profile ID on subsequent requests, enabling Apache Unomi to load the existing profile. @@ -1180,7 +1196,7 @@ The following configuration properties control profile tracking behavior: * `org.apache.unomi.profile.cookie.name` - Cookie name (default: `context-profile-id`) * `org.apache.unomi.profile.cookie.maxAgeInSeconds` - Cookie expiration time (default: `31536000` = 1 year) -* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `false`) +* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response. * `org.apache.unomi.profile.cookie.domain` - Cookie domain (optional) -These can be configured in the `org.apache.unomi.web.cfg` configuration file. +These can be configured in `etc/custom.system.properties` / `org.apache.unomi.web.cfg`. diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc index c1179e909a..0da22ba276 100644 --- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc +++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc @@ -52,8 +52,9 @@ Here's a minimal tracker structure: contextServerUrl: 'http://localhost:8181', apiKey: 'YOUR_PUBLIC_API_KEY', scope: 'mydigital', - sessionCookieName: 'unomi-session-id', - profileCookieName: 'context-profile-id' + sessionCookieName: 'unomi-session-id' + // No profile cookie name here: the profile ID cookie is HttpOnly and + // cannot be read from JavaScript. Use response.profileId instead. }; // Tracker object @@ -121,13 +122,13 @@ The tracker needs to generate and maintain a session ID. If no session ID exists // Generate a UUID v4 // Uses crypto.randomUUID() if available (most secure, modern browsers) // Falls back to crypto.getRandomValues() (secure, widely supported) -// Falls back to Math.random() only for very old browsers (less secure) +// Fails closed if neither is available: session IDs must never be guessable generateUUID: function() { // Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+) if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } - + // Use crypto.getRandomValues() if available (widely supported, secure) if (typeof crypto !== 'undefined' && crypto.getRandomValues) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { @@ -136,14 +137,16 @@ generateUUID: function() { return v.toString(16); }); } - - // Fallback to Math.random() for very old browsers (not cryptographically secure) - // Note: This is less secure and should only be used as a last resort - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + + // No cryptographically secure source available: fail closed. + // Do NOT fall back to Math.random() — its output is predictable and a + // guessable session ID lets an attacker take over a visitor's session. + throw new Error( + 'Unomi tracker: no cryptographically secure random source available ' + + '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' + + 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' + + 'secure contexts) or install a Web Crypto polyfill.' + ); }, // Get or create session ID @@ -843,11 +846,13 @@ getContextWithRetry: function(options, callback, maxRetries) { ===== Profile ID Synchronization -The profile ID is set by Apache Unomi in a cookie. Make sure to read it from the response: +The profile ID is set by Apache Unomi in a cookie. By default the cookie is `HttpOnly`, so browser JavaScript +**cannot** read it via `document.cookie`. Always prefer the `profileId` field in the JSON response. The browser still +**sends** the cookie on subsequent requests when credentials/cookies are included. [source,javascript] ---- -// Enhanced context request that handles profile ID cookie +// Enhanced context request that handles profile ID from the response getContext: function(options, callback) { // ... existing code ... @@ -857,14 +862,10 @@ getContext: function(options, callback) { try { const response = JSON.parse(xhr.responseText); - // Profile ID cookie is automatically set by Apache Unomi - // but we can verify it matches the response - const cookieProfileId = tracker.getCookie(CONFIG.profileCookieName); - if (response.profileId && cookieProfileId !== response.profileId) { - console.warn('Profile ID mismatch:', { - cookie: cookieProfileId, - response: response.profileId - }); + // Authoritative profile id for application logic: + // response.profileId (cookie may be HttpOnly and unreadable from JS) + if (response.profileId) { + // store in memory / your app state if needed — do not rely on document.cookie } if (callback) { @@ -1005,7 +1006,8 @@ Here's a complete, production-ready tracker implementation combining all the con apiKey: 'YOUR_PUBLIC_API_KEY', scope: 'mydigital', sessionCookieName: 'unomi-session-id', - profileCookieName: 'context-profile-id', + // No profile cookie name here: the profile ID cookie is HttpOnly and + // cannot be read from JavaScript. Use response.profileId instead. eventQueueSize: 10, eventQueueInterval: 5000 }; @@ -1034,13 +1036,13 @@ Here's a complete, production-ready tracker implementation combining all the con // UUID generation // Uses crypto.randomUUID() if available (most secure, modern browsers) // Falls back to crypto.getRandomValues() (secure, widely supported) - // Falls back to Math.random() only for very old browsers (less secure) + // Fails closed if neither is available: session IDs must never be guessable generateUUID: function() { // Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+) if (typeof crypto !== 'undefined' && crypto.randomUUID) { return crypto.randomUUID(); } - + // Use crypto.getRandomValues() if available (widely supported, secure) if (typeof crypto !== 'undefined' && crypto.getRandomValues) { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { @@ -1049,14 +1051,16 @@ Here's a complete, production-ready tracker implementation combining all the con return v.toString(16); }); } - - // Fallback to Math.random() for very old browsers (not cryptographically secure) - // Note: This is less secure and should only be used as a last resort - return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) { - const r = Math.random() * 16 | 0; - const v = c === 'x' ? r : (r & 0x3 | 0x8); - return v.toString(16); - }); + + // No cryptographically secure source available: fail closed. + // Do NOT fall back to Math.random() — its output is predictable and a + // guessable session ID lets an attacker take over a visitor's session. + throw new Error( + 'Unomi tracker: no cryptographically secure random source available ' + + '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' + + 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' + + 'secure contexts) or install a Web Crypto polyfill.' + ); }, // Session management @@ -1230,26 +1234,28 @@ Here's a complete, production-ready tracker implementation combining all the con ==== **Security Considerations for UUID Generation** -The tracker uses UUIDs for session IDs, which should be unpredictable to prevent session hijacking. The implementation in this guide uses a secure fallback chain: +Session IDs are security-relevant: a session ID is a bearer identifier, so anyone who can predict one can hijack the corresponding visitor session. The implementation in this guide therefore uses only cryptographically secure sources, and **fails closed** when none is available: 1. **`crypto.randomUUID()`** (Preferred): Available in modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+). This is the most secure option and generates RFC 4122-compliant UUIDs using cryptographically secure random number generation. 2. **`crypto.getRandomValues()`** (Fallback): Available in all modern browsers (IE 11+, Chrome 11+, Firefox 21+, Safari 5.1+). Uses the Web Crypto API to generate cryptographically secure random values. This is secure and widely compatible. -3. **`Math.random()`** (Last Resort): Only used for very old browsers that don't support the Web Crypto API. This is **not cryptographically secure** and can be predictable, making it vulnerable to session hijacking attacks. +3. **No secure source → throw**: if neither Web Crypto entry point exists, `generateUUID()` throws an `Error` instead of returning an identifier. There is deliberately **no `Math.random()` fallback**: `Math.random()` is not cryptographically secure, its output is predictable, and returning such a value would silently hand attackers a guessable session ID. Refusing to track is the safer outcome. **Recommendations:** * For production applications, ensure your minimum browser support includes browsers with `crypto.getRandomValues()` support (essentially all browsers from 2013+) -* If you need to support very old browsers (IE 10 and below), consider using a polyfill or warning users about security limitations -* Never use `Math.random()` alone for security-sensitive identifiers like session IDs or authentication tokens +* Serve your pages over HTTPS: the Web Crypto API is only exposed in secure contexts, so a page served over plain HTTP can hit the fail-closed branch even on a current browser +* If you must support browsers without the Web Crypto API (IE 10 and below), install a Web Crypto polyfill — do not reintroduce a `Math.random()` fallback +* Never use `Math.random()` for security-sensitive identifiers like session IDs or authentication tokens +* Handle the thrown error in your integration (for example, disable tracking and log a warning) rather than letting it break unrelated page scripts * The profile ID is always generated server-side by Apache Unomi using secure UUID generation, so client-side UUID generation is only needed for session IDs **Browser Compatibility:** -* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+) -* `crypto.getRandomValues()`: All modern browsers (2013+) -* `Math.random()`: All browsers (but not secure) +* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+), secure contexts only +* `crypto.getRandomValues()`: All modern browsers (2013+), secure contexts only +* Anything older, or any non-secure context: unsupported — the tracker throws rather than generating a weak session ID -For maximum security and compatibility, the implementation automatically uses the best available method. +The implementation automatically uses the best available secure method, and refuses to generate a session ID when there is none. ==== ==== Next Steps diff --git a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc index 2d4a01440f..2730051554 100644 --- a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc +++ b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc @@ -16,7 +16,7 @@ The JSON schema endpoints are private, so the user has to be authenticated to manage the JSON schema in Unomi. -IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). +IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). ==== List existing schemas diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc index 18b69e2880..a041bf2d2e 100644 --- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc +++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc @@ -34,7 +34,7 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Aspect |Unomi 3.0 |Unomi 3.1 |Authentication Method -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (configured admin password) |Tenant-based API Keys + System Administrator Authentication |Public API Endpoints @@ -46,8 +46,8 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication |Tenant Administration -|System Administrator Authentication (karaf/karaf) -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication +|System Administrator Authentication (configured `UNOMI_ROOT_PASSWORD`) |=== ==== API Key Types (3.1 Only) @@ -68,12 +68,12 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s |Public API Key only |Administrative Operations -|System Admin (karaf/karaf) -|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) +|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) |Tenant Administration (`/cxs/tenants`) -|System Admin (karaf/karaf) -|System Admin (karaf/karaf) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) +|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`) |=== ==== Authentication Flow (3.1) @@ -84,7 +84,7 @@ The AuthenticationFilter in 3.1 follows this resolution order: 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header 3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication: - **Tenant Authentication**: Basic Auth with `tenantId:privateKey` - - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials) + - **System Administrator Authentication**: Basic Auth with `karaf:$UNOMI_ROOT_PASSWORD` (or configured admin credentials) ==== Code Examples @@ -94,7 +94,7 @@ The AuthenticationFilter in 3.1 follows this resolution order: ---- // Global system administrator authentication for all endpoints RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests require no authentication RestAssured.given() @@ -124,14 +124,14 @@ given() // For private endpoints using system administrator authentication given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(payload) .post("/cxs/profiles"); // For tenant administration (system admin only) given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(tenantPayload) .post("/cxs/tenants"); @@ -164,7 +164,7 @@ public class UnomiConfiguration { public void init() { RestAssured.baseURI = baseUrl; RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); } // 3.1 Client @@ -210,10 +210,87 @@ When migrating to 3.1, you need to understand that: - Keep system administrator authentication as fallback for administrative operations - Continue using system administrator authentication for tenant administration -4. **No API Contract Changes** - - All endpoints remain the same - - Request/response payloads are unchanged - - Only authentication mechanism differs +4. **Related client contract changes (3.1 hardening)** + - Public `/context.json` and `/eventcollector` no longer treat body/query `profileId` as identity — see below + - Endpoint paths and most payload shapes remain the same; authentication and public profile-binding rules change + +[#_client_facing_hardening_3_1] +==== Client-facing hardening (profile cookie, passwords, privileged APIs) + +In addition to tenant API keys, Unomi 3.1 hardens several contracts that can break older clients and scripts. See also <<_how_profile_tracking_works,How profile tracking works>> and https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]. + +**Why cookie-only for public profile identity?** On public context endpoints the profile id is a bearer token. Accepting a client-chosen body/query `profileId` let any public caller try to continue another visitor's context if they obtained that UUID. The cookie is issued by Unomi, sent automatically by the browser, and (with default `HttpOnly`) is not readable from page script — so public clients prove continuity with a server-issued bearer instead of an application-supplied id. Trusted callers may still override with body `profileId` when a backend intentionally selects a profile. Full rationale: <<_how_profile_tracking_works,How profile tracking works>> (section _Why the cookie is safer…_). + +[cols="1,2,2", options="header"] +|=== +|Area |Old common pattern |Required in 3.1 + +|Public profile identity +|Body/query `profileId` often selected the profile (sometimes ahead of the cookie) +|Public callers: **cookie only**. Body/query `profileId` is ignored. Trusted callers (tenant private key / system admin) may still override. + +|Session resume without cookie +|Sending only `sessionId` could switch onto that session's profile +|Public callers switch only if the cookie already owns the session; otherwise the session is detached for the request. + +|Profile cookie `HttpOnly` +|Often defaulted to `false` (JS could read `document.cookie`) +|Default **`true`**. Prefer `profileId` from the JSON response. Opt out with `org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the tradeoff. + +|Admin / health passwords +|Known defaults such as `karaf` / `karaf` were common in samples +|Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no shipped known defaults). + +|Groovy Actions / Router import-export REST +|Often callable with tenant private key +|Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator. + +|Startup without a password +|Started with the shipped default +|`bin/karaf` and the Docker entrypoint refuse to start. On Windows startup continues — see the warning below. + +|`mergeProfilesOnProperty` / cross-profile `updateProperties` / `systemProperties.*` +|Sometimes driven from public context events +|Cross-profile merge/update and `systemProperties` writes require a trusted caller (system or tenant admin). Public callers may still update the **current** cookie-bound profile's normal properties when the event type allows it. +|=== + +[WARNING] +==== +**Windows: the startup check cannot stop the launcher.** + +On Linux and macOS, `bin/karaf` sources `bin/setenv` directly, so a missing password aborts startup. +The Docker entrypoint behaves the same way and the container exits. + +On Windows, `karaf.bat` runs `setenv.bat` with `call` and does not test the exit code, so the error +message is printed and **startup continues anyway**. This is a limitation of the Karaf launcher that +Unomi cannot work around without replacing `karaf.bat`. + +This matters because an unset password is not the same as a disabled account. `${env:UNOMI_ROOT_PASSWORD}` +resolves to the *empty string*, so `etc/users.properties` creates the `karaf` administrator with an +empty password that authenticates successfully. + +Windows operators must therefore treat the message as fatal and verify the password took effect +before exposing the instance — for example by confirming that an empty password is rejected: + +[source,bash] +---- +# Must return 401. A 200 means the account has a blank password. +curl -i -u "karaf:" http://localhost:8181/cxs/tenants +---- + +Unomi additionally refuses any REST call presenting Basic authentication with an empty password, so +the admin API is not reachable that way even if the server did start unconfigured. The Karaf SSH +console (port 8102) is not covered by that check. +==== + +===== Migration checklist for client applications + +* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); stop treating body `profileId` as authoritative for public calls. +* Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile. +* Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone. +* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`. +* Windows deployments: confirm the passwords actually took effect after upgrading — the startup check warns but cannot halt `karaf.bat` (see the warning above). +* Automation that uploaded Groovy actions or managed Router import/export with a tenant private key: switch to system administrator credentials. ==== Benefits of Multi-Tenancy in 3.1 @@ -232,6 +309,7 @@ Before starting the migration, please ensure that: - You are currently running Apache Unomi 3.0 (or a later 3.0.x version) - You understand the multi-tenancy impact on your data model - You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 2.x) +- You have reviewed the <<_client_facing_hardening_3_1,client-facing hardening>> notes (cookie-only public profile binding, HttpOnly default, required passwords, privileged REST roles) - You know how to obtain plaintext API keys after upgrade (regenerate via `/cxs/tenants/{id}/apikeys`; create responses expose masked keys only) === Migration Process @@ -293,7 +371,7 @@ The fundamental difference between Unomi 3.0 and 3.1 is the introduction of **co - **3.0**: Single-tenant architecture with system administrator authentication for all operations - **3.1**: Multi-tenant architecture with complete data isolation and tenant-specific authentication -- **API Endpoints**: Identical between versions - no breaking changes to existing integrations +- **API Endpoints**: Paths remain the same; authentication and public profile-binding rules change (see client-facing hardening above) - **Data Model**: All entities (profiles, events, segments, rules, schemas) become tenant-specific in 3.1 - **Authentication**: New tenant-based authentication model with system administrator authentication as fallback diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc b/manual/src/main/asciidoc/migrations/migrations.adoc index 9cbe001462..6faa3da16c 100644 --- a/manual/src/main/asciidoc/migrations/migrations.adoc +++ b/manual/src/main/asciidoc/migrations/migrations.adoc @@ -17,7 +17,7 @@ This section contains information and steps to migrate between major Unomi versi Use this decision guide to pick the right runbook: * **Unomi 2.x → 3.0** (platform): <<_migrate_from_2_x_to_3_0,Migrate from 2.x to 3.0>> (+ <<_migrate_from_elasticsearch_7_to_elasticsearch_9,ES7→ES9>> if needed) -* **Unomi 3.0 → 3.1** (tenants / API keys): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients +* **Unomi 3.0 → 3.1** (tenants / API keys / client hardening): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients; see also <<_client_facing_hardening_3_1,client-facing hardening>> * **Elasticsearch → OpenSearch** (same Unomi version, backend swap): <<_migrate_from_elasticsearch_to_opensearch,Migrate from Elasticsearch to OpenSearch>> (not `unomi:migrate`) [plantuml] diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc index 0614d523bf..6238209398 100644 --- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc +++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc @@ -111,7 +111,7 @@ Your V2 clients should now work without any changes: ```java // V2-style authentication still works RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests work without API keys RestAssured.given() @@ -137,7 +137,7 @@ Over time, gradually update your clients to use V3 authentication: ```java // This continues to work in V2 compatibility mode RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); RestAssured.given() .auth().none() @@ -260,7 +260,7 @@ The V2 third-party configuration supports dynamic updates: - Ensure the tenant exists and is accessible **Authentication errors**: -- Verify system administrator credentials (karaf/karaf) +- Verify system administrator credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) - Check that the server is running properly - Review logs for authentication errors diff --git a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc index 4eb64ca280..fbf69a019f 100644 --- a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc +++ b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc @@ -40,7 +40,7 @@ This multi-tenancy support necessitates the authentication changes described bel |Aspect |Unomi V2 |Unomi 3.1 |Authentication Method -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (`karaf` user) |Tenant-based API Keys + System Administrator Authentication |Public API Endpoints @@ -52,8 +52,8 @@ This multi-tenancy support necessitates the authentication changes described bel |Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication |Tenant Administration -|System Administrator Authentication (karaf/karaf) -|System Administrator Authentication (karaf/karaf) +|System Administrator Authentication (`karaf` user) +|System Administrator Authentication (`karaf` user) |=== ===== API Key Types (V3 Only) @@ -74,12 +74,12 @@ V3 introduces two types of API keys per tenant: |Public API Key only |Administrative Operations -|System Admin (karaf/karaf) -|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf) +|System Admin (`karaf` user) +|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf` user) |Tenant Administration (`/cxs/tenants`) -|System Admin (karaf/karaf) -|System Admin (karaf/karaf) +|System Admin (`karaf` user) +|System Admin (`karaf` user) |=== ==== Authentication Flow (V3) @@ -90,7 +90,7 @@ The AuthenticationFilter in V3 follows this resolution order: 2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header 3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication: - **Tenant Authentication**: Basic Auth with `tenantId:privateKey` - - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials) + - **System Administrator Authentication**: Basic Auth with the `karaf` user and your configured `UNOMI_ROOT_PASSWORD` ==== Code Examples @@ -100,7 +100,7 @@ The AuthenticationFilter in V3 follows this resolution order: ---- // Global system administrator authentication for all endpoints RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); // Context requests require no authentication RestAssured.given() @@ -130,14 +130,14 @@ given() // For private endpoints using system administrator authentication given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(payload) .post("/cxs/profiles"); // For tenant administration (system admin only) given() - .auth().preemptive().basic("karaf", "karaf") + .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")) .contentType(ContentType.JSON) .body(tenantPayload) .post("/cxs/tenants"); @@ -170,7 +170,7 @@ public class UnomiConfiguration { public void init() { RestAssured.baseURI = baseUrl; RestAssured.authentication = RestAssured.preemptive() - .basic("karaf", "karaf"); + .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD")); } // V3 Client diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc index 6f6a914cee..904ca0399d 100644 --- a/manual/src/main/asciidoc/multitenancy.adoc +++ b/manual/src/main/asciidoc/multitenancy.adoc @@ -59,7 +59,7 @@ To create a new tenant, use the Tenant API endpoint: [source,bash] ---- curl -X POST http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "my-tenant", @@ -102,10 +102,10 @@ IMPORTANT: Store plaintext keys immediately after calling the key creation endpo [source,bash] ---- curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC" \ - --user karaf:karaf + --user "karaf:$UNOMI_ROOT_PASSWORD" curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \ - --user karaf:karaf + --user "karaf:$UNOMI_ROOT_PASSWORD" ---- Example response: @@ -387,7 +387,7 @@ securityService.executeAsSystemSubject(() -> { [source,bash] ---- curl -X GET http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" ---- @@ -396,7 +396,7 @@ curl -X GET http://localhost:8181/cxs/tenants \ [source,bash] ---- curl -X PUT http://localhost:8181/cxs/tenants/my-tenant \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "displayName": "Updated Organization Name", @@ -411,10 +411,10 @@ Regenerating replaces any existing key of the same type. Store `plainTextKey` fr [source,bash] ---- curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC&validityDays=30" \ - --user karaf:karaf + --user "karaf:$UNOMI_ROOT_PASSWORD" curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \ - --user karaf:karaf + --user "karaf:$UNOMI_ROOT_PASSWORD" ---- NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expiration; omit it (or use `0`) for no expiration. @@ -424,7 +424,7 @@ NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expirat [source,bash] ---- curl -X DELETE http://localhost:8181/cxs/tenants/my-tenant \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" ---- @@ -464,7 +464,7 @@ Unomi exposes read-only usage metrics per tenant. Quota enforcement belongs in y [source,bash] ---- curl -X GET "http://localhost:8181/cxs/tenants/my-tenant/usage?period=current-month" \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" ---- @@ -479,7 +479,7 @@ Upstream control planes can delete old tenant events through Unomi instead of ta [source,bash] ---- curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/purge/events?retentionDays=90" \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" ---- @@ -568,7 +568,7 @@ After migration, verify tenant context and data access: ---- # List tenants (JAAS admin) curl -X GET http://localhost:8181/cxs/tenants \ - --user karaf:karaf \ + --user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Accept: application/json" # List profiles for a tenant (tenant private key auth) @@ -652,7 +652,7 @@ curl -X GET http://localhost:8181/cxs/jsonSchema \ -H "Content-Type: application/json" ---- -NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). +NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). You can also validate events against your schema using the validation endpoint: @@ -683,14 +683,22 @@ curl -X POST http://localhost:8181/cxs/jsonSchema/validateEvent \ Once the event type is defined, you can send events: +[NOTE] +==== +`/cxs/context.json` is a public endpoint, so the profile is carried by the `context-profile-id` +cookie: since Unomi 3.1 a public caller's body `profileId` is ignored. Only a trusted caller +(tenant private key or system administrator) may bind a profile explicitly through the body. See +<<_client_facing_hardening_3_1,client-facing hardening>>. +==== + [source,bash] ---- curl -X POST http://localhost:8181/cxs/context.json \ -H "X-Unomi-Api-Key: " \ -H "Content-Type: application/json" \ + -b "context-profile-id=profile-456" \ -d '{ "sessionId": "session-123", - "profileId": "profile-456", "source": { "itemId": "checkout-page", "itemType": "page", @@ -768,9 +776,9 @@ To test that everything works: curl -X POST http://localhost:8181/cxs/context.json \ -H "X-Unomi-Api-Key: " \ -H "Content-Type: application/json" \ + -b "context-profile-id=profile-456" \ -d '{ "sessionId": "session-123", - "profileId": "profile-456", "source": { "itemId": "checkout-page", "itemType": "page", diff --git a/manual/src/main/asciidoc/privacy.adoc b/manual/src/main/asciidoc/privacy.adoc index dda5c3959b..a32c187fe1 100644 --- a/manual/src/main/asciidoc/privacy.adoc +++ b/manual/src/main/asciidoc/privacy.adoc @@ -78,7 +78,7 @@ curl -X DELETE http://localhost:8181/cxs/privacy/profiles/{profileID}?withData=f --user "TENANT_ID:PRIVATE_KEY" ---- -NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). +NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). where `{profileID}` must be replaced by the actual identifier of a profile and the `withData` specifies whether the data associated with the profile must be anonymized or not diff --git a/manual/src/main/asciidoc/recipes.adoc b/manual/src/main/asciidoc/recipes.adoc index 8f0f8c9ce7..7236f9508e 100644 --- a/manual/src/main/asciidoc/recipes.adoc +++ b/manual/src/main/asciidoc/recipes.adoc @@ -27,7 +27,7 @@ you might be tempted to modify them to fit your use case, which might result in The best approach during development is to enable Apache Unomi debug mode, which will provide you with more detailed logs about events processing. -The debug mode can be activated via the karaf SSH console (default credentials are karaf/karaf): +The debug mode can be activated via the karaf SSH console (authenticate with `karaf` and your configured `UNOMI_ROOT_PASSWORD`): [source] ---- @@ -134,10 +134,11 @@ event data to the profile. This is simpler than it sounds, as usually all it req defining the corresponding JSON schema and you're ready to update profiles using events. - Use the protected built-in "updateProperties" event. This event is designed to be used for administrative purposes -only. Again, prefer the custom events solution because as this is a protected event it will require sending the Unomi -key as a request header, and as Unomi only supports a single key for the moment it could be problematic if the key is -intercepted. But at least by using an event you will get the benefits of auditing and historical property modification -tracing (see <<_request_tracing_explain,request tracing>>). +only. Cross-profile updates and `systemProperties.*` writes require a **trusted** caller (tenant private key or system +administrator). Prefer custom events for public visitors. Again, prefer the custom events solution because as this is a +protected event it will require sending trusted credentials, and as Unomi only supports a single key for the moment it +could be problematic if the key is intercepted. But at least by using an event you will get the benefits of auditing and +historical property modification tracing (see <<_request_tracing_explain,request tracing>>). Let's go into more detail about the preferred way to update a profile. Let's consider the following example of a rule: @@ -208,7 +209,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \ }' ---- -NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). +NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). The next step consist in creating a JSON Schema to validate our event. @@ -485,7 +486,7 @@ much preferred. When sending a login event, you can setup a rule that can check a profile property to see if profiles can be merged on an universal identifier such as an email address. -In our login sample we provide an example of such a rule. You can find it here: +In our login sample we provide an example of such a rule (fired from a **server-side** trusted call — see <<_login_sample,Login sample>>). You can find the rule here: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json @@ -579,7 +580,7 @@ Upon merge: ===== API -/context.json and /eventcollector will now look up profiles by profile ID or aliases from the same cookie (`context-profile-id`) or body parameters (`profileId`) +/context.json and /eventcollector look up profiles by profile ID or aliases from the profile cookie (`context-profile-id` by default). Public callers must present that cookie; body/query `profileId` is ignored for public callers. Trusted callers (tenant private key / system admin) may still pass `profileId` explicitly. See <<_how_profile_tracking_works,How profile tracking works>> and <<_client_facing_hardening_3_1,client-facing hardening>>. |=== | *Verb* | *Path* | *Description* diff --git a/manual/src/main/asciidoc/request-examples.adoc b/manual/src/main/asciidoc/request-examples.adoc index cb06a6f6ba..26edb46e62 100644 --- a/manual/src/main/asciidoc/request-examples.adoc +++ b/manual/src/main/asciidoc/request-examples.adoc @@ -25,7 +25,7 @@ First, create a tenant that will own all the data: [source] ---- curl -X POST http://localhost:8181/cxs/tenants \ ---user karaf:karaf \ +--user "karaf:$UNOMI_ROOT_PASSWORD" \ -H "Content-Type: application/json" \ -d '{ "requestedId": "mytenant", @@ -67,8 +67,8 @@ Regenerate keys to obtain one-time plaintext values (store them immediately): [source,bash] ---- -curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user karaf:karaf -curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user karaf:karaf +curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user "karaf:$UNOMI_ROOT_PASSWORD" +curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user "karaf:$UNOMI_ROOT_PASSWORD" ---- After creating the tenant and regenerating keys, use these credentials in the examples: @@ -549,14 +549,22 @@ The format is always `MM-DD` where: You can also update the personalization example to use the birthday property: +[NOTE] +==== +The profile is selected with the `context-profile-id` cookie, not with a `profileId` in the request +body. `/cxs/context.json` is a public endpoint, and since Unomi 3.1 a public caller's body `profileId` +is ignored — the cookie is the only profile identity bearer. See +<<_client_facing_hardening_3_1,Client-facing hardening>> in the 3.0 to 3.1 migration guide. +==== + [source] ---- curl -X POST http://localhost:8181/cxs/context.json \ -H "Content-Type: application/json" \ -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \ +-b "context-profile-id=profile-1" \ -d '{ "sessionId": "birthday-session", - "profileId": "profile-1", "source": { "itemId": "homepage", "itemType": "page", @@ -702,9 +710,9 @@ For the birthday profile (should show birthday message): curl -X POST http://localhost:8181/cxs/context.json \ -H "Content-Type: application/json" \ -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \ +-b "context-profile-id=profile-1" \ -d '{ "sessionId": "birthday-session", - "profileId": "profile-1", "source": { "itemId": "homepage", "itemType": "page", @@ -748,9 +756,9 @@ For the non-birthday profile (should show welcome message): curl -X POST http://localhost:8181/cxs/context.json \ -H "Content-Type: application/json" \ -H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \ +-b "context-profile-id=profile-2" \ -d '{ "sessionId": "regular-session", - "profileId": "profile-2", "source": { "itemId": "homepage", "itemType": "page", diff --git a/manual/src/main/asciidoc/scheduler.adoc b/manual/src/main/asciidoc/scheduler.adoc index 786fdbc874..3783a767eb 100644 --- a/manual/src/main/asciidoc/scheduler.adoc +++ b/manual/src/main/asciidoc/scheduler.adoc @@ -645,14 +645,14 @@ NOTE: The REST API is for **monitoring and operations** (list, inspect, cancel, .List all tasks (paginated) [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?offset=0&limit=20" ---- .Filter by status [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?status=FAILED&limit=50" ---- @@ -661,7 +661,7 @@ Valid status values: `SCHEDULED`, `WAITING`, `RUNNING`, `COMPLETED`, `FAILED`, ` .Filter by task type [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks?type=cache-refresh-segment&limit=10" ---- @@ -689,7 +689,7 @@ curl -s -u karaf:karaf \ [source,bash] ---- -curl -s -u karaf:karaf \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \ "http://localhost:8181/cxs/tasks/TASK_ID" ---- @@ -699,7 +699,7 @@ Cancellation stops future runs and marks the task `CANCELLED`. The task record r [source,bash] ---- -curl -s -u karaf:karaf -X DELETE \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X DELETE \ "http://localhost:8181/cxs/tasks/TASK_ID" ---- @@ -710,11 +710,11 @@ Returns HTTP `204 No Content` on success. [source,bash] ---- # Retry keeping failure count -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/retry" # Retry and reset failure count to zero -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/retry?resetFailureCount=true" ---- @@ -724,7 +724,7 @@ Use when status is `CRASHED` and the executor supports checkpoint resume. [source,bash] ---- -curl -s -u karaf:karaf -X POST \ +curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \ "http://localhost:8181/cxs/tasks/TASK_ID/resume" ---- diff --git a/manual/src/main/asciidoc/security.adoc b/manual/src/main/asciidoc/security.adoc index acfdd1b1fe..fb14fbec12 100644 --- a/manual/src/main/asciidoc/security.adoc +++ b/manual/src/main/asciidoc/security.adoc @@ -31,12 +31,14 @@ Unomi 3.1 authenticates every request so the server can resolve a **tenant** (or | Server-side integrations, admin UIs | Tenant administration (`/cxs/tenants`) -| JAAS system administrator (for example `karaf:karaf`) +| JAAS system administrator (for example `karaf:$UNOMI_ROOT_PASSWORD`) | Create tenants, rotate keys |=== Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows public endpoints without API keys while migrating 2.x clients. +Public context callers must present the profile cookie as the profile bearer (body `profileId` is ignored for public callers). See <<_client_facing_hardening_3_1,client-facing hardening>> and <<_how_profile_tracking_works,How profile tracking works>>. + ===== Auth resolution sequence [plantuml] diff --git a/manual/src/main/asciidoc/shell-commands.adoc b/manual/src/main/asciidoc/shell-commands.adoc index 19a09dca6f..2a154d5d04 100644 --- a/manual/src/main/asciidoc/shell-commands.adoc +++ b/manual/src/main/asciidoc/shell-commands.adoc @@ -26,7 +26,7 @@ You can connect to the Apache Karaf SSH Shell using the following command: ssh -p 8102 karaf@localhost -The default username/password is karaf/karaf. You should change this as soon as possible by editing the `etc/users.properties` file. +Authenticate with the `karaf` user and the password from `UNOMI_ROOT_PASSWORD` (Unomi does not ship a known default password). Set the password before start; see <<_getting_started,Getting started>> and <<_client_facing_hardening_3_1,client-facing hardening>>. Once connected you can simply type in : diff --git a/manual/src/main/asciidoc/tutorial.adoc b/manual/src/main/asciidoc/tutorial.adoc index f381ce6d15..0d81767c3c 100644 --- a/manual/src/main/asciidoc/tutorial.adoc +++ b/manual/src/main/asciidoc/tutorial.adoc @@ -115,7 +115,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \ }' ---- -NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). The default `karaf:karaf` credentials should be changed as soon as possible by modifying the `etc/users.properties` file. +NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). Unomi ships no default administrator password: set `UNOMI_ROOT_PASSWORD` (and `UNOMI_HEALTHCHECK_PASSWORD`) before starting the server, or it will refuse to start. ==== Using tracker in your own JavaScript projects @@ -210,7 +210,7 @@ Another (powerful) way to look at events is to use the SSH Console. You can conn ssh -p 8102 karaf@localhost ---- -Using the same username password (karaf:karaf) and then you can use command such as : +Using the same credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) and then you can use command such as : - `event-tail` to view in realtime the events as they come in (CTRL+C to stop) - `event-list` to view the latest events diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc index 4b34f3f906..d49febed16 100644 --- a/manual/src/main/asciidoc/whats-new.adoc +++ b/manual/src/main/asciidoc/whats-new.adoc @@ -21,9 +21,17 @@ Apache Unomi 3.1 builds on the 3.0 platform (Elasticsearch 9 client, Karaf 4.4, Complete tenant isolation for profiles, events, segments, rules, and schemas. Public endpoints (for example `/cxs/context.json`) require a tenant public API key; administrative work uses tenant private keys or system administrator credentials. * Operator guide: <<_multitenancy,Multi-tenancy>> -* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> +* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes <<_client_facing_hardening_3_1,client-facing hardening>>) * Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> (`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`) +==== Security hardening (credentials, profile binding, privileged APIs) + +Unomi 3.1 requires explicit admin and health-check passwords at startup, treats the profile cookie as the only public profile bearer on `/context.json` and `/eventcollector`, defaults the profile cookie to `HttpOnly`, restricts Groovy Actions and Router import/export REST to system administrator, and gates cross-profile merge / `updateProperties` / `systemProperties` writes to trusted callers. + +* Details and migration table: <<_client_facing_hardening_3_1,Client-facing hardening>> +* Profile tracking contract: <<_how_profile_tracking_works,How profile tracking works>> +* Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] + ==== Cluster-aware task scheduler Built-in background job scheduler with persistence, cluster locks, recovery, REST API (`/cxs/tasks`), and Karaf shell commands. diff --git a/samples/login-integration/src/main/webapp/WEB-INF/web.xml b/samples/login-integration/src/main/webapp/WEB-INF/web.xml deleted file mode 100644 index dc145f2991..0000000000 --- a/samples/login-integration/src/main/webapp/WEB-INF/web.xml +++ /dev/null @@ -1,24 +0,0 @@ - - - - index.html - - \ No newline at end of file diff --git a/samples/login-integration/src/main/webapp/index.html b/samples/login-integration/src/main/webapp/index.html deleted file mode 100644 index 8b03cbded8..0000000000 --- a/samples/login-integration/src/main/webapp/index.html +++ /dev/null @@ -1,70 +0,0 @@ - - - - - - - Login integration example - - - - - - - - - - - -

-

Login integration example

-

This is a small example of integrating Apache Unomi with an event login in order to merge profile based on emails - as merge keys (see associated rule file in src/main/resources/META-INF/cxs/rules/exampleLogin.json).

-

Important: note that login events should normally always be sent from the server performing the login, not through - Javascript for security reasons. Here we provide this type of example only for brievety and clarity.

-
-
-
- - -
-
- - -
-
- - -
-
- - -
- -
-
- - diff --git a/samples/login-integration/src/main/webapp/javascript/login-example.js b/samples/login-integration/src/main/webapp/javascript/login-example.js deleted file mode 100644 index 2704ac8531..0000000000 --- a/samples/login-integration/src/main/webapp/javascript/login-example.js +++ /dev/null @@ -1,139 +0,0 @@ -/* - * Licensed to the Apache Software Foundation (ASF) under one or more - * contributor license agreements. See the NOTICE file distributed with - * this work for additional information regarding copyright ownership. - * The ASF licenses this file to You under the Apache License, Version 2.0 - * (the "License"); you may not use this file except in compliance with - * the License. You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -(function () { - - // We use this method to generate unique sessions IDs - function generateGuid() { - function s4() { - var array = new Uint16Array(1); - window.crypto.getRandomValues(array); - return array[0].toString(16).padStart(4, '0'); - } - - return s4() + s4() + '-' + s4() + '-' + s4() + '-' + - s4() + '-' + s4() + s4() + s4(); - } - - // -- COOKIE HELPER METHODS --- - - function createCookie(name, value, days) { - var expires; - - if (days) { - var date = new Date(); - date.setTime(date.getTime() + (days * 24 * 60 * 60 * 1000)); - expires = "; expires=" + date.toGMTString(); - } else { - expires = ""; - } - document.cookie = encodeURIComponent(name) + "=" + encodeURIComponent(value) + expires + "; path=/"; - } - - function readCookie(name) { - var nameEQ = encodeURIComponent(name) + "="; - var ca = document.cookie.split(';'); - for (var i = 0; i < ca.length; i++) { - var c = ca[i]; - while (c.charAt(0) === ' ') c = c.substring(1, c.length); - if (c.indexOf(nameEQ) === 0) return decodeURIComponent(c.substring(nameEQ.length, c.length)); - } - return null; - } - - function eraseCookie(name) { - createCookie(name, "", -1); - } - - // -- BOOTSTRAP HELPER METHODS --- - - bootstrapAlert = {}; - bootstrapAlert.success = function (message) { - $('#alert_placeholder').html('
×' + message + '
') - }; - bootstrapAlert.danger = function (message) { - $('#alert_placeholder').html('
×' + message + '
') - }; - - $(document).ready(function () { - - // first we check if we have an existing session ID cookie, if not we generate a new session identifier and - // store it in the cookie. - var unomiSessionId = readCookie('unomi-session-id'); - if (!unomiSessionId) { - unomiSessionId = generateGuid(); - console.log("No existing session cookie found, creating a new one with value " + unomiSessionId); - createCookie('unomi-session-id', unomiSessionId, 1); - } - console.log("Setting up form listener..."); - $("#loginForm").submit(function (event) { - var email = $('#email').val(); - var firstName = $('#firstname').val(); - var lastName = $('#lastname').val(); - var password = $('#password').val(); - if (password != 'test1234') { - bootstrapAlert.danger("Wrong password (default is : test1234)"); - event.preventDefault(); - return false; - } - var contextRequest = { - source: { // the source is required for the request to be process properly - itemId: location.pathname, - itemType: 'webpage', - scope: 'test' // the scope is used to regroup events and sessions into sub-groups (eg sites) - }, - events: [{ // here we provide a simple login event, but as this is actually an array we could provide other events at the same time (page view, clicks, mouse movements, ...) - eventType: "login", - properties: {}, - target: { - itemId: email, - itemType: "exampleUser", - properties: { - preferredLanguage: "en", - email: email, - firstName: firstName, - lastName: lastName - } - } - }], - requiredProfileProperties: ['*'], // this tells Unomi to send us back all the profile properties (by default none are returned) - requiredSessionProperties: ['*'] // this tells Unomi to send us back all the session properties (by default none are returned) - }; - // now let's perform the actual call to Apache Unomi, asking it to process the events and give us back the updated (or created) profile. - // as we have a rule listening to a login event, it will be executed and its actions will be processed. - $.ajax({ - url: "http://localhost:8181/cxs/context.json?sessionId=" + unomiSessionId, - type: 'POST', - data: JSON.stringify(contextRequest), // make sure you sent JSON and not form-encoded, otherwise Unomi will generate an error - contentType: 'application/json; charset=utf-8', - dataType: 'json', - async: false, - headers : { - 'X-Unomi-Api-Key' : '670c26d1cc413346c3b2fd9ce65dab41' // this is configured in the etc/org.apache.unomi.thirdparty.cfg - }, - success: function (data) { - console.log("Unomi response:", data); - bootstrapAlert.success("Successfully sent login event to Apache Unomi ! (profileId=" + data.profileId + ",properties.email=" + data.profileProperties.email + ",nbOfVisits=" + data.profileProperties.nbOfVisits + ")"); - } - }); - event.preventDefault(); - return false; - }); - }); - -})(); - From 8566175d388e08633d05c98c623eb87bdf802d33 Mon Sep 17 00:00:00 2001 From: Serge Huber Date: Tue, 11 Aug 2026 22:05:14 +0200 Subject: [PATCH 9/9] UNOMI-972: correct two factual errors in the docs and harden the log sanitizer against a bad limit The tracker guide told readers the Web Crypto API is restricted to secure contexts and that plain HTTP can therefore reach the fail-closed branch. That is wrong: only crypto.randomUUID() and crypto.subtle are secure-context-only, and the fallback the tracker actually uses, crypto.getRandomValues(), is available over plain HTTP. The branch is reached when Web Crypto is absent entirely, so the error message now says that instead of prescribing HTTPS as the fix. HTTPS is still recommended on its own merits - a session id in clear text is the larger problem - but the support matrix no longer claims getRandomValues needs it. The quickstart's Docker path put literal passwords in the compose example and then ran curl commands reading ${UNOMI_ROOT_PASSWORD}, which is only exported in the Karaf path further down. A reader following the Docker path pasted a literal and then hit 401s against an unset variable. Both compose blocks now read the exported values, matching the shipped compose files, with the export step given once before them. LogSanitizer.forLogging(String, int) clamps a negative limit rather than letting substring throw. No caller passes one today, but this helper exists to be safe to call from inside a log statement, and a computed limit would be an easy way to turn a security-refusal log line into an uncaught exception. Also drops a local repeat() helper in favour of String.repeat, which the Java 17 baseline provides. Co-Authored-By: Claude Opus 5 (1M context) --- .../apache/unomi/api/utils/LogSanitizer.java | 7 ++++++- .../unomi/api/utils/LogSanitizerTest.java | 16 ++++++++-------- manual/src/main/asciidoc/5-min-quickstart.adoc | 18 ++++++++++++++---- .../asciidoc/javascript-tracker-guide.adoc | 15 +++++++++------ 4 files changed, 37 insertions(+), 19 deletions(-) diff --git a/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java index 0ff89eaf34..7af6cfad1a 100644 --- a/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java +++ b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java @@ -63,7 +63,12 @@ public static String forLogging(String input, int maxLength) { if (input == null) { return "null"; } - String value = input.length() > maxLength ? input.substring(0, maxLength) + "...[truncated]" : input; + // Clamped: a negative limit would make substring throw, from inside a helper whose whole + // contract is that it is always safe to call in a log statement. No caller passes one today, + // but a computed limit (a remaining-budget calculation, say) would be an easy way to turn a + // security-refusal log line into an uncaught exception. + int limit = Math.max(0, maxLength); + String value = input.length() > limit ? input.substring(0, limit) + "...[truncated]" : input; StringBuilder sanitized = new StringBuilder(value.length()); for (int i = 0; i < value.length(); i++) { char c = value.charAt(i); diff --git a/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java index 6eee9ffb1c..2a8c7093a9 100644 --- a/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java +++ b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java @@ -68,7 +68,7 @@ public void logFormatMarkersAreNeutralised() { @Test public void oversizedValuesAreTruncatedSoTheyCannotFloodTheLog() { - String sanitized = LogSanitizer.forLogging(repeat("a", 5000)); + String sanitized = LogSanitizer.forLogging("a".repeat(5000)); assertTrue(sanitized.endsWith("...[truncated]")); assertTrue("truncated output must stay bounded", sanitized.length() < 300); @@ -191,7 +191,7 @@ public void zeroWidthCharactersCannotHideATokenFromSearch() { */ @Test public void payloadHiddenBeyondTheTruncationPointIsDropped() { - String sanitized = LogSanitizer.forLogging(repeat("a", 400) + "\nWARN forged-record"); + String sanitized = LogSanitizer.forLogging("a".repeat(400) + "\nWARN forged-record"); assertFalse(sanitized.contains("forged-record")); assertFalse(sanitized.contains("\n")); @@ -217,11 +217,11 @@ public void sanitizationIsIdempotent() { assertEquals(once, LogSanitizer.forLogging(once)); } - private static String repeat(String s, int times) { - StringBuilder sb = new StringBuilder(s.length() * times); - for (int i = 0; i < times; i++) { - sb.append(s); - } - return sb.toString(); + /** A negative limit must not throw: this helper is called from inside log statements. */ + @Test + public void negativeLimitIsClampedRatherThanThrowing() { + assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", -1)); + assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", 0)); } + } diff --git a/manual/src/main/asciidoc/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc index 002e304c31..9966b275ad 100644 --- a/manual/src/main/asciidoc/5-min-quickstart.adoc +++ b/manual/src/main/asciidoc/5-min-quickstart.adoc @@ -19,6 +19,16 @@ Begin by creating a `docker-compose.yml` file. You can choose between Elasticsea ==== Option 1: Using Elasticsearch +Export the passwords first. The compose files read them from your shell, and the `curl` commands +further down use the same variables, so setting them once here keeps both consistent. Unomi refuses +to start if they are unset. + +[source,bash] +---- +export UNOMI_ROOT_PASSWORD='choose-a-strong-password' +export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' +---- + [source,yaml] ---- version: '3.8' @@ -35,8 +45,8 @@ services: environment: - UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 - UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1 - - UNOMI_ROOT_PASSWORD=choose-a-strong-password - - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 @@ -86,8 +96,8 @@ services: - UNOMI_OPENSEARCH_SSL_ENABLE=true - UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence - - UNOMI_ROOT_PASSWORD=choose-a-strong-password - - UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password + - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD} + - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD} ports: - 8181:8181 - 9443:9443 diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc index 0da22ba276..b9acf9773d 100644 --- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc +++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc @@ -144,8 +144,8 @@ generateUUID: function() { throw new Error( 'Unomi tracker: no cryptographically secure random source available ' + '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' + - 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' + - 'secure contexts) or install a Web Crypto polyfill.' + 'unsupported: install a Web Crypto polyfill, or use a browser that provides ' + + 'crypto.getRandomValues.' ); }, @@ -1058,8 +1058,8 @@ Here's a complete, production-ready tracker implementation combining all the con throw new Error( 'Unomi tracker: no cryptographically secure random source available ' + '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' + - 'unsupported. Serve the page over HTTPS (the Web Crypto API is restricted to ' + - 'secure contexts) or install a Web Crypto polyfill.' + 'unsupported: install a Web Crypto polyfill, or use a browser that provides ' + + 'crypto.getRandomValues.' ); }, @@ -1244,7 +1244,10 @@ Session IDs are security-relevant: a session ID is a bearer identifier, so anyon **Recommendations:** * For production applications, ensure your minimum browser support includes browsers with `crypto.getRandomValues()` support (essentially all browsers from 2013+) -* Serve your pages over HTTPS: the Web Crypto API is only exposed in secure contexts, so a page served over plain HTTP can hit the fail-closed branch even on a current browser +* Serve your pages over HTTPS. `crypto.randomUUID()` is restricted to secure contexts, so over plain HTTP the tracker + falls back to `crypto.getRandomValues()`, which is available in non-secure contexts too. HTTPS is worth doing on its + own merits - a session identifier travelling in clear text is the larger problem - but plain HTTP alone does not reach + the fail-closed branch on a current browser * If you must support browsers without the Web Crypto API (IE 10 and below), install a Web Crypto polyfill — do not reintroduce a `Math.random()` fallback * Never use `Math.random()` for security-sensitive identifiers like session IDs or authentication tokens * Handle the thrown error in your integration (for example, disable tracking and log a warning) rather than letting it break unrelated page scripts @@ -1252,7 +1255,7 @@ Session IDs are security-relevant: a session ID is a bearer identifier, so anyon **Browser Compatibility:** * `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+), secure contexts only -* `crypto.getRandomValues()`: All modern browsers (2013+), secure contexts only +* `crypto.getRandomValues()`: All modern browsers (2013+), available in secure and non-secure contexts * Anything older, or any non-secure context: unsupported — the tracker throws rather than generating a weak session ID The implementation automatically uses the best available secure method, and refuses to generate a session ID when there is none.