This is an automated email from the ASF dual-hosted git repository. asf-gitbox-commits pushed a commit to branch UNOMI-972-credentials-profile-binding-privileged-rest in repository https://gitbox.apache.org/repos/asf/unomi.git
commit 899ef37b1e23a306932618b0a18d113d7f2b8232 Author: Serge Huber <[email protected]> AuthorDate: Mon Aug 10 09:24:58 2026 +0200 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) <[email protected]> --- clear-elasticsearch.sh | 2 + clear-opensearch.sh | 2 + docker/README.md | 18 +- docker/src/main/docker/docker-compose-build-es.yml | 2 + docker/src/main/docker/docker-compose-build-os.yml | 2 + docker/src/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 ++ .../test/java/org/apache/unomi/itests/BaseIT.java | 6 + .../test/java/org/apache/unomi/itests/BasicIT.java | 29 +- .../org/apache/unomi/itests/HealthCheckIT.java | 11 +- .../java/org/apache/unomi/itests/TenantIT.java | 8 +- .../apache/unomi/itests/V2CompatibilityModeIT.java | 4 +- .../apache/unomi/itests/graphql/BaseGraphQLIT.java | 2 +- itests/src/test/resources/etc/users.properties | 4 +- package/src/main/resources/bin/setenv | 88 +++++ package/src/main/resources/bin/setenv.bat | 61 +++ .../main/resources/etc/custom.system.properties | 11 +- package/src/main/resources/etc/users.properties | 6 +- .../rest/authentication/AuthenticationFilter.java | 76 +++- .../AuthenticationFilterBlankPasswordTest.java | 243 ++++++++++++ .../config/ShippedAdminPasswordConfigTest.java | 428 +++++++++++++++++++++ setup-elasticsearch.sh | 22 +- setup-opensearch.sh | 26 +- setup-utils.sh | 27 ++ 26 files changed, 1077 insertions(+), 52 deletions(-) diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh index 0d92e9da2..baa760647 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 61b6c5a12..df4c9425b 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 c5ab6553d..a5dc49bde 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 03f265a7b..34193ad30 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 97d38cfe4..f5c8592d5 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 75aed4628..fa1a3069e 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 edfada591..a3d294672 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 579d3e2bc..a37babf5b 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 1cfc68456..e805537b4 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 <<EOF + +WARNING: $3=true but $1 is empty. + Unless $2 is supplied another way, the + account will have an EMPTY password that grants full administrator access. + +EOF + return 0 + fi + + cat >&2 <<EOF +ERROR: $1 is not set. + +Apache Unomi does not ship a known default password, and an unset value becomes an EMPTY +password that still authenticates. Pass it when starting the container, for example: + + docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \\ + -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ... + +Or with docker compose, export both UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD first. +EOF + return 1 +} + +check_required_password UNOMI_ROOT_PASSWORD \ + org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1 +check_required_password UNOMI_HEALTHCHECK_PASSWORD \ + org.apache.unomi.healthcheck.password UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1 + # Function to check cluster health for a specific node check_node_health() { local node_url="$1" diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java index 5ec6385ba..1940d3c79 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java @@ -132,7 +132,10 @@ public abstract class BaseIT extends KarafTestSupport { protected static final ContentType JSON_CONTENT_TYPE = ContentType.create("application/json"); protected static final String BASE_URL = "http://localhost"; protected static final String BASIC_AUTH_USER_NAME = "karaf"; + /** Explicit IT password — package no longer ships a known default ({@code UNOMI_ROOT_PASSWORD}). */ protected static final String BASIC_AUTH_PASSWORD = "karaf"; + protected static final String HEALTHCHECK_AUTH_USER_NAME = "health"; + protected static final String HEALTHCHECK_AUTH_PASSWORD = "health"; protected static final int REQUEST_TIMEOUT = 60000; protected static final int DEFAULT_TRYING_TIMEOUT = 1000; protected static final int DEFAULT_TRYING_TRIES = 10; @@ -671,6 +674,9 @@ public abstract class BaseIT extends KarafTestSupport { editConfigurationFilePut("etc/system.properties", SEARCH_ENGINE_PROPERTY, searchEngine), editConfigurationFilePut("etc/system.properties", PERSISTENCE_PROVIDER_PROPERTY, searchEngine), editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.migration.tenant.id", TEST_TENANT_ID), + // Explicit test credentials (package no longer ships a known default password). + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD), + editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD), // Default scheduler.thread.poolSize (5) is sized for the near-instant in-memory unit-test // double, not a real ES/OS backend. Under real refresh/write latency, the checker, task // executions, and lease-renewal heartbeats (see scheduler.adoc) compete for the same small diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java index bd650359b..ff4e12f06 100644 --- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java @@ -38,6 +38,8 @@ import org.junit.Before; import org.junit.Test; import org.junit.runner.RunWith; import org.ops4j.pax.exam.junit.PaxExam; + +import java.util.Base64; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; import org.slf4j.Logger; @@ -248,15 +250,36 @@ public class BasicIT extends BaseIT { loginEventPropertiesVisitor2.put(LAST_NAME, LAST_NAME_VISITOR_2); loginEventPropertiesVisitor2.put(EMAIL, EMAIL_VISITOR_2); - // Create login event with VISITOR_2 ContextRequest contextRequestLoginVisitor2 = getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor2, EMAIL_VISITOR_2, SESSION_ID_4); + + // Public API key must not switch identity / merge into another profile on a shared cookie. + HttpPost publicSwitchAttempt = new HttpPost(getFullUrl("/cxs/context.json")); + publicSwitchAttempt.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue()); + publicSwitchAttempt.addHeader("X-Unomi-Api-Key", testPublicKeyValue); + publicSwitchAttempt.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2), + ContentType.create("application/json"))); + TestUtils.RequestResponse publicSwitchResponse = executeContextJSONRequest(publicSwitchAttempt, SESSION_ID_4); + Assert.assertEquals("Public login must not switch away from the cookie profile", + profileIdVisitor1, publicSwitchResponse.getContextResponse().getProfileId()); + + // Public login still runs copyProperties on the cookie profile; restore visitor1 before the trusted switch. + Profile restoredVisitor1 = profileService.load(profileIdVisitor1); + restoredVisitor1.setProperty(FIRST_NAME, FIRST_NAME_VISITOR_1); + restoredVisitor1.setProperty(LAST_NAME, LAST_NAME_VISITOR_1); + restoredVisitor1.setProperty(EMAIL, EMAIL_VISITOR_1); + profileService.save(restoredVisitor1); + keepTrying("Visitor1 properties not restored", () -> 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 305c2f38e..f7179a7f4 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 @@ public class HealthCheckIT extends BaseIT { } } + @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> T get(final String url, TypeReference<T> 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 a9ef9a595..9cce84707 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 class TenantIT extends BaseIT { // 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<String, Object> requestBody = new HashMap<>(); @@ -273,7 +273,7 @@ public class TenantIT extends BaseIT { // 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 class TenantIT extends BaseIT { // 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 class TenantIT extends BaseIT { // 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 cce6c5484..f27089368 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 @@ public class V2CompatibilityModeIT extends BaseIT { 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 @@ public class V2CompatibilityModeIT extends BaseIT { 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 1ff220109..34db0381e 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 @@ public abstract class BaseGraphQLIT extends BaseIT { } /** - * 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 377fe6a16..538906c38 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 e48561de4..66fa7157d 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 <<EOF + +WARNING: $3=true, but neither $1 nor + $2 is set to a non-empty value. + If nothing else supplies this password, the account will be created with an EMPTY + password that authenticates successfully, granting full administrator access. + +EOF + return 0 + fi + + cat >&2 <<EOF +ERROR: $1 is not set. + +Apache Unomi does not ship a known default password, and an unset value becomes an EMPTY +password that still authenticates. Set one before starting, for example: + + export UNOMI_ROOT_PASSWORD='choose-a-strong-password' + export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' + ./bin/karaf + +Or set $2 in etc/custom.system.properties +(then $3=true only suppresses this early check). + +See getting started / configuration documentation for details. +EOF + return 1 +} + +# Only guard the scripts that actually start the server. bin/stop, bin/status, bin/client and +# bin/shell set KARAF_SCRIPT to their own name, so the outer case already excludes them. +# +# "bin/karaf stop" and "bin/karaf status" reuse KARAF_SCRIPT=karaf, so skip on the subcommand too: +# those are the only two that replace the main class (with Main.Stop / Main.Status) instead of +# booting the server. Everything else - including "karaf client", "karaf shell" and any unknown +# argument - falls through to org.apache.karaf.main.Main and starts a full server, so it must be +# guarded. +case "${KARAF_SCRIPT}" in + karaf|start|server) + case "$1" in + stop|status) ;; + *) + _unomi_require_password UNOMI_ROOT_PASSWORD \ + org.apache.unomi.security.root.password UNOMI_SKIP_ROOT_PASSWORD_CHECK || exit 1 + _unomi_require_password UNOMI_HEALTHCHECK_PASSWORD \ + org.apache.unomi.healthcheck.password UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1 + ;; + esac + ;; +esac + # In order to activate the Yourkit Profiler agent, uncomment one of the following lines depending on the operating # system and adjust the path to the location of the installation directory. # On MacOS: diff --git a/package/src/main/resources/bin/setenv.bat b/package/src/main/resources/bin/setenv.bat index f5db6f80b..5333a1090 100644 --- a/package/src/main/resources/bin/setenv.bat +++ b/package/src/main/resources/bin/setenv.bat @@ -63,3 +63,64 @@ rem SET KARAF_DEBUG set MY_DIRNAME=%~dp0% set MY_KARAF_HOME=%DIRNAME%.. + +rem Warn early when starting without admin/health passwords (no known defaults are shipped). +rem +rem An unset password does NOT mean "no account": it expands to the empty string, which Karaf's +rem PropertiesLoginModule accepts as a valid password for the admin account. +rem +rem KNOWN LIMITATION: karaf.bat invokes this file with "call" and does not test errorlevel +rem afterwards, so the "exit /b 1" below returns from this script but does NOT stop the launcher. +rem The error message is printed and startup continues. Windows operators must therefore act on the +rem message; there is no way to fail closed from here without also killing the operator's console +rem (plain "exit 1" would terminate the whole cmd.exe session, including an interactive prompt). +rem +rem karaf.bat sets KARAF_SCRIPT with the quotes included in the value (SET KARAF_SCRIPT="karaf.bat"), +rem so strip them before comparing - otherwise every comparison below silently fails to match. +set _UNOMI_KARAF_SCRIPT=%KARAF_SCRIPT:"=% +if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf.bat" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="start.bat" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="karaf" goto checkPasswords +if /I "%_UNOMI_KARAF_SCRIPT%"=="start" goto checkPasswords +goto afterPasswordChecks + +:checkPasswords +if not "%UNOMI_ROOT_PASSWORD%"=="" goto afterRootPasswordCheck +if /I "%UNOMI_SKIP_ROOT_PASSWORD_CHECK%"=="true" goto afterRootPasswordCheck +echo ERROR: UNOMI_ROOT_PASSWORD is not set. +echo. +echo Apache Unomi does not ship a known default admin password, and an unset value becomes +echo an EMPTY password that still authenticates. Set one before starting, for example: +echo. +echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password +echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password +echo bin\karaf.bat +echo. +echo Or set org.apache.unomi.security.root.password in etc\custom.system.properties +echo and set UNOMI_SKIP_ROOT_PASSWORD_CHECK=true. +echo. +echo WARNING: startup continues anyway on Windows - see the note at the top of this file. +set _UNOMI_KARAF_SCRIPT= +exit /b 1 + +:afterRootPasswordCheck +if not "%UNOMI_HEALTHCHECK_PASSWORD%"=="" goto afterPasswordChecks +if /I "%UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK%"=="true" goto afterPasswordChecks +echo ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set. +echo. +echo Apache Unomi does not ship a known default health-check password, and an unset value +echo becomes an EMPTY password that still authenticates. Set one before starting, for example: +echo. +echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password +echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password +echo bin\karaf.bat +echo. +echo Or set org.apache.unomi.healthcheck.password in etc\custom.system.properties +echo and set UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true. +echo. +echo WARNING: startup continues anyway on Windows - see the note at the top of this file. +set _UNOMI_KARAF_SCRIPT= +exit /b 1 + +:afterPasswordChecks +set _UNOMI_KARAF_SCRIPT= diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties index fa98f7979..4dd435a78 100644 --- a/package/src/main/resources/etc/custom.system.properties +++ b/package/src/main/resources/etc/custom.system.properties @@ -22,7 +22,13 @@ ${optionals}=unomi.custom.system.properties ####################################################################################################################### ## Security settings ## ####################################################################################################################### -org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD:-karaf} +# Required: set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start +# (bin/setenv and the Docker entrypoint refuse to start if either is unset). +# There are no shipped known defaults. +# Escape hatches: UNOMI_SKIP_ROOT_PASSWORD_CHECK=true / UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true +# if you set the matching property another way. +org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD} +org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD} # These parameters control the list of classes that are allowed or forbidden when executing expressions. org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String} @@ -302,7 +308,8 @@ org.apache.unomi.profile.cookie.name=${env:UNOMI_PROFILE_COOKIE_NAME:-context-pr # This setting controls the maximum age of the profile cookie. By default it is set to a year. org.apache.unomi.profile.cookie.maxAgeInSeconds=${env:UNOMI_PROFILE_COOKIE_MAXAGEINSECONDS:-31536000} # This setting controls if the cookie should be flagged as HttpOnly or not. -org.apache.unomi.profile.cookie.httpOnly=${env:UNOMI_PROFILE_COOKIE_HTTPONLY:-false} +# Default true so browser JavaScript cannot read the profile bearer cookie. +org.apache.unomi.profile.cookie.httpOnly=${env:UNOMI_PROFILE_COOKIE_HTTPONLY:-true} #Allowed profile download formats, actually only csv (horizontal and vertical), json, text and yaml are allowed. org.apache.unomi.profile.download.formats=${env:UNOMI_PROFILE_DOWNLOAD_FORMATS:-csv,yaml,json,text} # This setting allow for request size (Content-length) protection. Checking that the requests do not exceed the limit. diff --git a/package/src/main/resources/etc/users.properties b/package/src/main/resources/etc/users.properties index bffdc5bf3..c7064f536 100644 --- a/package/src/main/resources/etc/users.properties +++ b/package/src/main/resources/etc/users.properties @@ -29,6 +29,8 @@ # 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 +# Passwords come from org.apache.unomi.security.root.password / healthcheck.password (see custom.system.properties). +# No known defaults are shipped; set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start. +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_ADMIN,ROLE_UNOMI_TENANT_USER diff --git a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java index 3c59c1cf6..0e990ffaa 100644 --- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java +++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java @@ -101,17 +101,33 @@ public class AuthenticationFilter implements ContainerRequestFilter { TenantService tenantService, SecurityService securityService, ExecutionContextManager executionContextManager) { + this(restAuthenticationConfig, tenantService, securityService, executionContextManager, buildJaasFilter()); + } + + /** + * Test seam: lets a test supply a stub JAAS filter so it can assert that a credential was + * refused <em>before</em> 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 class AuthenticationFilter implements ContainerRequestFilter { unauthorized(requestContext); return; } + if (rejectBlankBasicAuthPassword(requestContext, authHeader)) { + return; + } try { jaasAuthenticationFilter.filter(requestContext); @@ -192,6 +211,9 @@ public class AuthenticationFilter implements ContainerRequestFilter { // 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 @@ public class AuthenticationFilter implements ContainerRequestFilter { // 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 @@ public class AuthenticationFilter implements ContainerRequestFilter { unauthorized(requestContext); } + /** + * Rejects the request when the Basic credential about to be used carries an empty password. + * <p> + * 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. + * <p> + * 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 000000000..a219885c3 --- /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. + * <p> + * 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<Response> 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. + * <p> + * 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 000000000..b44e9fea0 --- /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. + * <p> + * The launcher guards are <em>executed</em> 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<String> 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<String, String> 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<String, String> 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 <em>not</em> 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<String, String> 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 + * <em>both</em> 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/<script>: export KARAF_SCRIPT, then source setenv at top level. + Path launcher = bin.resolve(karafScript); + Files.write(launcher, ("#!/bin/sh\n" + + "KARAF_SCRIPT=\"" + karafScript + "\"\n" + + "export KARAF_SCRIPT\n" + + ". \"$(dirname \"$0\")/setenv\"\n" + + "echo LAUNCHED\n").getBytes(StandardCharsets.UTF_8)); + launcher.toFile().setExecutable(true); + } + + /** + * Copies the entrypoint's password guard into a standalone script, so the test does not have to + * run the rest of the container bootstrap (which needs a search engine). + */ + private static Path extractEntrypointPasswordGuard(Path workDir) throws IOException { + List<String> lines = Files.readAllLines(repoFile(ENTRYPOINT_PATH)); + StringBuilder guard = new StringBuilder("#!/bin/sh\n"); + boolean capturing = false; + boolean terminated = false; + for (String line : lines) { + if (line.startsWith(ENTRYPOINT_GUARD_START)) { + capturing = true; + } + if (capturing) { + guard.append(line).append('\n'); + } + if (capturing && line.contains(ENTRYPOINT_GUARD_END)) { + terminated = true; + break; + } + } + assertTrue(capturing, "entrypoint.sh must define the guard; start marker '" + ENTRYPOINT_GUARD_START + + "' was not found in " + ENTRYPOINT_PATH); + // Without this, a reformatted entrypoint.sh would make the slice run to end-of-file: the test + // would still "pass" while executing something entirely different from the guard. + assertTrue(terminated, "the guard slice never reached its terminating line '" + ENTRYPOINT_GUARD_END + + "' in " + ENTRYPOINT_PATH + " - update the marker, this test is no longer testing the guard"); + guard.append("echo GUARD-PASSED\n"); + + Path script = workDir.resolve("entrypoint-guard.sh"); + Files.write(script, guard.toString().getBytes(StandardCharsets.UTF_8)); + return script; + } + + private static Result runLauncher(Path karafHome, String karafScript, Map<String, String> env, String... args) throws Exception { + String[] command = new String[args.length + 2]; + command[0] = "/bin/sh"; + command[1] = karafHome.resolve("bin").resolve(karafScript).toString(); + System.arraycopy(args, 0, command, 2, args.length); + return run(new ProcessBuilder(command).directory(karafHome.toFile()), env); + } + + private static Result runShell(Path script, Path workDir, Map<String, String> env) throws Exception { + return run(new ProcessBuilder("/bin/sh", script.toString()).directory(workDir.toFile()), env); + } + + private static Result run(ProcessBuilder builder, Map<String, String> env) throws Exception { + Map<String, String> environment = builder.environment(); + + // Inherit the JVM's environment - bin/setenv shells out to dirname, sed, grep and tail, which + // do not live under /usr/bin:/bin on every platform (NixOS, minimal images), and a hardcoded + // PATH turns that into a confusing failure instead of a working test. + // + // But strip every UNOMI_* variable first: an ambient UNOMI_ROOT_PASSWORD on a developer's + // machine would otherwise satisfy the "missing password" cases and make them pass by accident, + // which is the one property these tests cannot afford to lose. KARAF_* goes too, because + // KARAF_ETC would redirect the script at the developer's own etc/ directory. + List<String> inherited = new ArrayList<>(environment.keySet()); + for (String name : inherited) { + if (name.startsWith("UNOMI_") || name.startsWith("KARAF_")) { + environment.remove(name); + } + } + environment.putIfAbsent("PATH", "/usr/bin:/bin"); + environment.putAll(env); + builder.redirectErrorStream(true); + + Process process = builder.start(); + String output = new String(process.getInputStream().readAllBytes(), StandardCharsets.UTF_8); + assertTrue(process.waitFor(60, TimeUnit.SECONDS), "script did not terminate"); + return new Result(process.exitValue(), output); + } + + private static final class Result { + private final int exitCode; + private final String output; + + private Result(int exitCode, String output) { + this.exitCode = exitCode; + this.output = output; + } + } +} diff --git a/setup-elasticsearch.sh b/setup-elasticsearch.sh index 8c103668f..f794801ce 100755 --- a/setup-elasticsearch.sh +++ b/setup-elasticsearch.sh @@ -56,17 +56,15 @@ _setup_elasticsearch() { return 1 fi - # Load only the Elasticsearch password from .env.local if it exists - # This ensures we don't load the OpenSearch password - if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then - # If not found in .env.local, check if it's already set in environment - if [ -z "${UNOMI_ELASTICSEARCH_PASSWORD}" ]; then - echo "Note: UNOMI_ELASTICSEARCH_PASSWORD not found in .env.local or environment" - fi + # Load required passwords from .env.local (or existing environment). + # Engine password is Elasticsearch-only so we do not pull OpenSearch's password. + if ! require_password "${SCRIPT_DIR}" "UNOMI_ELASTICSEARCH_PASSWORD"; then + return 1 fi - - # Check if password is set - if ! check_password "UNOMI_ELASTICSEARCH_PASSWORD"; then + if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then + return 1 + fi + if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then return 1 fi @@ -76,7 +74,6 @@ _setup_elasticsearch() { export UNOMI_ELASTICSEARCH_USERNAME=elastic export UNOMI_ELASTICSEARCH_SSL_ENABLE=true export UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES=true - # Password is already set from .env.local or environment # Set the distribution to use Elasticsearch (default, but explicit for clarity) export UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch @@ -87,6 +84,9 @@ _setup_elasticsearch() { echo " Username: ${UNOMI_ELASTICSEARCH_USERNAME} (overridden from default: empty)" echo " SSL Enabled: ${UNOMI_ELASTICSEARCH_SSL_ENABLE} (overridden from default: false)" echo " Trust All Certificates: ${UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES} (overridden from default: false)" + echo " Elasticsearch password: (set from .env.local or environment)" + echo " Root password: (set from .env.local or environment)" + echo " Healthcheck password: (set from .env.local or environment)" return 0 } diff --git a/setup-opensearch.sh b/setup-opensearch.sh index 565dcc4e9..995ad1b2a 100755 --- a/setup-opensearch.sh +++ b/setup-opensearch.sh @@ -56,30 +56,26 @@ _setup_opensearch() { return 1 fi - # Load only the OpenSearch password from .env.local if it exists - # This ensures we don't load the Elasticsearch password - if ! load_password_from_env_local "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then - # If not found in .env.local, check if it's already set in environment - if [ -z "${UNOMI_OPENSEARCH_PASSWORD}" ]; then - echo "Note: UNOMI_OPENSEARCH_PASSWORD not found in .env.local or environment" - fi + # Load required passwords from .env.local (or existing environment). + # Engine password is OpenSearch-only so we do not pull Elasticsearch's password. + if ! require_password "${SCRIPT_DIR}" "UNOMI_OPENSEARCH_PASSWORD"; then + return 1 fi - - # Check if password is set - if ! check_password "UNOMI_OPENSEARCH_PASSWORD"; then + if ! require_password "${SCRIPT_DIR}" "UNOMI_ROOT_PASSWORD"; then + return 1 + fi + if ! require_password "${SCRIPT_DIR}" "UNOMI_HEALTHCHECK_PASSWORD"; then return 1 fi - - # Set OpenSearch 3 password (only override needed - defaults are appropriate for OpenSearch 3) - # Password is already set from .env.local or environment, just ensure it's exported - export UNOMI_OPENSEARCH_PASSWORD # Set the distribution to use OpenSearch export UNOMI_DISTRIBUTION=unomi-distribution-opensearch echo "OpenSearch 3 environment variables configured." echo " Distribution: ${UNOMI_DISTRIBUTION}" - echo " Password: (set from .env.local or environment)" + echo " OpenSearch password: (set from .env.local or environment)" + echo " Root password: (set from .env.local or environment)" + echo " Healthcheck password: (set from .env.local or environment)" echo " Note: Using Unomi defaults for other OpenSearch settings (cluster, addresses, username, SSL)" return 0 diff --git a/setup-utils.sh b/setup-utils.sh index 6d8f04338..fd82fb0c2 100755 --- a/setup-utils.sh +++ b/setup-utils.sh @@ -139,6 +139,33 @@ check_password() { return 0 } +# Load a password from .env.local (if present) and require it to be set. +# Usage: require_password SCRIPT_DIR PASSWORD_VAR_NAME +# Returns: 0 if set, 1 if missing +require_password() { + local script_dir="$1" + local password_var="$2" + + if ! load_password_from_env_local "${script_dir}" "${password_var}"; then + if [ -n "${ZSH_VERSION}" ]; then + local password_value="${(P)password_var}" + else + local password_value="${!password_var}" + fi + if [ -z "${password_value}" ]; then + echo "Note: ${password_var} not found in .env.local or environment" + fi + fi + + if ! check_password "${password_var}"; then + return 1 + fi + + # Ensure the variable is exported into the current shell + eval "export ${password_var}" + return 0 +} + # Clear the opposite search engine's environment variables # Usage: clear_opposite SCRIPT_DIR OPPOSITE_TYPE # OPPOSITE_TYPE should be "opensearch" or "elasticsearch"
