This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch UNOMI-974-explicit-admin-password
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to 
refs/heads/UNOMI-974-explicit-admin-password by this push:
     new c82964c5b UNOMI-974: Refuse an empty password on the health check and 
GraphQL endpoints too
c82964c5b is described below

commit c82964c5b6aa9bcdc67ff545b245453cd69e3044
Author: Serge Huber <[email protected]>
AuthorDate: Sat Aug 15 18:23:01 2026 +0200

    UNOMI-974: Refuse an empty password on the health check and GraphQL 
endpoints too
    
    The blank-password refusal added to AuthenticationFilter only covers 
JAX-RS. Two other surfaces
    parse the Basic header themselves and call LoginContext.login() directly 
against the same karaf
    realm, so an empty password still authenticated there: the health check 
HTTP context and the
    GraphQL servlet validator. Both now refuse it.
    
    The health check path was reachable in practice. It split the decoded 
credential with split(":"),
    which discards trailing empty strings, so "health::x" decoded to ["health", 
"", "x"] and handed
    JAAS an empty password. Bounding the split to two parts restores the RFC 
7617 rule that the
    password is everything after the first colon, and makes the emptiness check 
meaningful. The same
    parser threw out of the servlet on several malformed headers, answering 500 
where 401 was meant;
    it now returns no credential instead. The scheme is matched 
case-insensitively (RFC 7235 2.1)
    because the previous blind substring(6) accepted "basic " and a stricter 
check would have started
    rejecting those clients.
    
    Both tests stub a realm that accepts any credential. That is deliberate: a 
rejecting realm answers
    "not authenticated" whether or not the guard exists, so only an accepting 
one can tell "refused
    before JAAS" from "JAAS said no". The health check test asserts on the 
credential the realm was
    actually handed, which is the only way to catch the split bug, since the 
bypass still returned
    "authenticated".
    
    extractBasicCredentials is covered exhaustively -- 9 header shapes that 
must yield no credential
    and 12 that must decode to specific values, including the empty, colon-only 
and non-ASCII cases,
    and an assertion that neither element is ever null so the emptiness check 
cannot throw.
    
    AuthenticationFilter's guard at the ordinary V3 private path had no test: 
the suite only exercised
    the tenants branch and the V2 branch, so deleting that third call site left 
every test green. Two
    tests now cover it.
    
    Documentation that still presented the removed karaf/karaf and 
health/health pairs as working
    defaults is corrected, including the configuration chapter, which 
contradicted its own REST API
    security section. building-and-deploying told readers to run ./bin/karaf 
with no password step,
    which now fails outright; it gains the export step and the Windows caveat.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 extensions/healthcheck/README.md                   |   2 +-
 extensions/healthcheck/pom.xml                     |  10 +
 .../servlet/HealthCheckHttpContext.java            |  67 ++++-
 .../HealthCheckHttpContextBlankPasswordTest.java   | 308 +++++++++++++++++++++
 .../auth/GraphQLServletSecurityValidator.java      |  11 +
 .../auth/GraphQLServletSecurityValidatorTest.java  |  35 +++
 .../src/main/asciidoc/building-and-deploying.adoc  |  18 +-
 manual/src/main/asciidoc/configuration.adoc        |   4 +-
 manual/src/main/asciidoc/index.adoc                |   2 +-
 manual/src/main/asciidoc/recipes.adoc              |   2 +-
 manual/src/main/asciidoc/samples/login-sample.adoc |   2 +-
 manual/src/main/asciidoc/useful-unomi-urls.adoc    |   2 +-
 .../AuthenticationFilterBlankPasswordTest.java     |  30 ++
 13 files changed, 480 insertions(+), 13 deletions(-)

diff --git a/extensions/healthcheck/README.md b/extensions/healthcheck/README.md
index fccbf65d7..14ae4c62f 100644
--- a/extensions/healthcheck/README.md
+++ b/extensions/healthcheck/README.md
@@ -25,7 +25,7 @@ The health check endpoint is available at
 ``` 
 and returns a simple JSON response that includes all health check provider 
responses.  
 
-Basic Http Authentication is enabled by default for the health check endpoint. 
The user needs to have the role `health` to access the endpoint. Users and 
roles can be configured in the etc/users.properties file. By default a user 
health/health is configured. 
+Basic Http Authentication is enabled by default for the health check endpoint. 
The user needs to have the role `health` to access the endpoint. Users and 
roles can be configured in the etc/users.properties file. The shipped `health` 
user has no default password: its password comes from the 
`UNOMI_HEALTHCHECK_PASSWORD` environment variable, which must be set before 
starting. An empty password is never accepted. 
 
 The healthcheck is available even if unomi is not started. It gives health 
information about :   
   - Karaf (as soon as the karaf container is started)
diff --git a/extensions/healthcheck/pom.xml b/extensions/healthcheck/pom.xml
index cf97e9c66..7cddbc911 100644
--- a/extensions/healthcheck/pom.xml
+++ b/extensions/healthcheck/pom.xml
@@ -108,6 +108,16 @@
             <artifactId>org.apache.karaf.jaas.boot</artifactId>
             <scope>provided</scope>
         </dependency>
+        <dependency>
+            <groupId>org.junit.jupiter</groupId>
+            <artifactId>junit-jupiter</artifactId>
+            <scope>test</scope>
+        </dependency>
+        <dependency>
+            <groupId>org.mockito</groupId>
+            <artifactId>mockito-core</artifactId>
+            <scope>test</scope>
+        </dependency>
     </dependencies>
 
     <build>
diff --git 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java
 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java
index 8e9331a61..85f83e5a2 100644
--- 
a/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java
+++ 
b/extensions/healthcheck/src/main/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContext.java
@@ -32,6 +32,7 @@ import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.io.IOException;
 import java.net.URL;
+import java.nio.charset.StandardCharsets;
 import java.util.Base64;
 
 /**
@@ -41,6 +42,8 @@ public class HealthCheckHttpContext implements HttpContext {
 
     private static final Logger LOGGER = 
LoggerFactory.getLogger(HealthCheckHttpContext.class.getName());
 
+    private static final String BASIC_PREFIX = "Basic ";
+
     private final String realm;
 
     public HealthCheckHttpContext(String realm) {
@@ -67,20 +70,34 @@ public class HealthCheckHttpContext implements HttpContext {
     protected boolean authenticated(HttpServletRequest request) {
         request.setAttribute(AUTHENTICATION_TYPE, 
HttpServletRequest.BASIC_AUTH);
 
-        String authzHeader = request.getHeader("Authorization");
-        String usernameAndPassword = new 
String(Base64.getDecoder().decode(authzHeader.substring(6).getBytes()));
-        String[] parts = usernameAndPassword.split(":");
+        String[] parts = 
extractBasicCredentials(request.getHeader("Authorization"));
+        if (parts == null) {
+            LOGGER.debug("Malformed Basic credentials, refusing access");
+            return false;
+        }
+        final String user = parts[0];
+        final String password = parts[1];
+
+        // An unset org.apache.unomi.healthcheck.password resolves to the 
empty string, which
+        // PropertiesLoginModule then accepts as this account's password 
(UNOMI-974). This endpoint
+        // authenticates against the karaf realm directly rather than through 
the REST
+        // AuthenticationFilter, so it needs its own refusal: it stays 
reachable on launch paths the
+        // startup guards in bin/setenv and the Docker entrypoint cannot 
cover, notably karaf.bat.
+        if (password.isEmpty()) {
+            LOGGER.warn("Rejecting health check Basic authentication with an 
empty password");
+            return false;
+        }
 
-        LOGGER.debug("Authenticating user {}", parts[0]);
+        LOGGER.debug("Authenticating user {}", user);
         try {
             //We use JAAS for authentication and authorization but it could be 
done using UserAdmin OSGI service
             LOGGER.debug("Creating Login Context for realm {}", realm);
             LoginContext loginContext = new LoginContext(realm, callbacks -> {
                 for (Callback callback : callbacks) {
                     if (callback instanceof NameCallback) {
-                        ((NameCallback) callback).setName(parts[0]);
+                        ((NameCallback) callback).setName(user);
                     } else if (callback instanceof PasswordCallback) {
-                        ((PasswordCallback) 
callback).setPassword(parts[1].toCharArray());
+                        ((PasswordCallback) 
callback).setPassword(password.toCharArray());
                     } else {
                         throw new UnsupportedCallbackException(callback);
                     }
@@ -106,6 +123,44 @@ public class HealthCheckHttpContext implements HttpContext 
{
         return false;
     }
 
+    /**
+     * Decodes a Basic {@code Authorization} header into {user, password}, or 
{@code null} when it is
+     * missing, not Basic, undecodable, or carries no {@code ':'} separator.
+     * <p>
+     * The split is bounded to two parts on purpose. {@code split(":")} 
discards trailing empty
+     * strings, so {@code "health:"} yielded a single element and blew up on 
{@code parts[1]}, while
+     * {@code "health::x"} yielded {@code ["health", "", "x"]} — an 
<em>empty</em> password that was
+     * handed straight to JAAS. Bounding it keeps the RFC 7617 rule that the 
password is everything
+     * after the first colon, and makes the emptiness check in {@link 
#authenticated} meaningful.
+     * <p>
+     * The scheme is matched case-insensitively per RFC 7235 §2.1. The 
previous implementation did a
+     * blind {@code substring(6)} with no prefix check at all, so it accepted 
{@code "basic "}; a
+     * case-sensitive check here would have quietly started rejecting those 
clients.
+     * <p>
+     * Neither returned element is ever {@code null}: {@link 
String#split(String, int)} only ever
+     * produces non-null substrings, and a result that is not exactly two 
elements is rejected above.
+     * Package-private for {@code HealthCheckHttpContextBlankPasswordTest}, 
which pins every one of
+     * these cases.
+     */
+    String[] extractBasicCredentials(String authzHeader) {
+        if (authzHeader == null
+                || authzHeader.length() < BASIC_PREFIX.length()
+                || !authzHeader.regionMatches(true, 0, BASIC_PREFIX, 0, 
BASIC_PREFIX.length())) {
+            return null;
+        }
+        try {
+            String decoded = new 
String(Base64.getDecoder().decode(authzHeader.substring(BASIC_PREFIX.length()).trim()),
+                    StandardCharsets.UTF_8);
+            String[] parts = decoded.split(":", 2);
+            return parts.length == 2 ? parts : null;
+        } catch (IllegalArgumentException e) {
+            // Undecodable base64. Deliberately not logged at error: this is 
attacker-controlled input
+            // and a malformed header is a client error, not a server fault.
+            LOGGER.debug("Could not decode Basic credentials");
+            return null;
+        }
+    }
+
     public URL getResource(String s) {
         return null;
     }
diff --git 
a/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java
 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java
new file mode 100644
index 000000000..e8fa6823b
--- /dev/null
+++ 
b/extensions/healthcheck/src/test/java/org/apache/unomi/healthcheck/servlet/HealthCheckHttpContextBlankPasswordTest.java
@@ -0,0 +1,308 @@
+/*
+ * 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.healthcheck.servlet;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
+
+import javax.security.auth.Subject;
+import javax.security.auth.callback.Callback;
+import javax.security.auth.callback.CallbackHandler;
+import javax.security.auth.callback.NameCallback;
+import javax.security.auth.callback.PasswordCallback;
+import javax.security.auth.callback.UnsupportedCallbackException;
+import javax.security.auth.login.AppConfigurationEntry;
+import javax.security.auth.login.Configuration;
+import javax.security.auth.login.LoginException;
+import javax.security.auth.spi.LoginModule;
+import javax.servlet.http.HttpServletRequest;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.Map;
+import java.util.stream.Stream;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+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.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+/**
+ * An unset {@code org.apache.unomi.healthcheck.password} resolves to the 
empty string, which
+ * {@code PropertiesLoginModule} accepts as this account's password 
(UNOMI-974). {@code /health/check}
+ * authenticates against the karaf realm directly rather than through the REST
+ * {@code AuthenticationFilter}, so it carries its own refusal — this covers 
it.
+ * <p>
+ * The realm stubbed here accepts <em>any</em> credential. That is the point: 
a test against a
+ * rejecting realm would pass whether or not the guard exists, since both 
answer "not authenticated".
+ * Only an always-succeeding realm can distinguish "refused before JAAS" from 
"JAAS said no".
+ */
+class HealthCheckHttpContextBlankPasswordTest {
+
+    private static final String REALM = "karaf";
+
+    private Configuration previousConfiguration;
+    private HealthCheckHttpContext context;
+
+    @BeforeEach
+    void setUp() {
+        previousConfiguration = Configuration.getConfiguration();
+        Configuration.setConfiguration(new AlwaysSucceedingConfiguration());
+        AlwaysSucceedingLoginModule.reset();
+        context = new HealthCheckHttpContext(REALM);
+    }
+
+    @AfterEach
+    void tearDown() {
+        Configuration.setConfiguration(previousConfiguration);
+    }
+
+    @Test
+    void blankPasswordIsRefused() {
+        assertFalse(context.authenticated(requestWith("health:")));
+    }
+
+    @Test
+    void blankUserAndPasswordIsRefused() {
+        assertFalse(context.authenticated(requestWith(":")));
+    }
+
+    /**
+     * Regression test for the real bypass, and the reason this asserts on the 
captured password
+     * rather than on the return value: {@code split(":")} discards trailing 
empty strings, so
+     * {@code "health::x"} decoded to {@code ["health", "", "x"]} and {@code 
parts[1]} was the
+     * <em>empty</em> string, handed to JAAS as the password. Because the 
stubbed realm accepts
+     * anything, that bypass still returned "authenticated" — only inspecting 
what JAAS was actually
+     * given can tell the two apart. Bounding the split to two parts makes the 
password everything
+     * after the first colon ({@code ":x"} here), per RFC 7617.
+     */
+    @Test
+    void extraColonsDoNotCollapseIntoABlankPassword() {
+        assertTrue(context.authenticated(requestWith("health::x")));
+
+        assertEquals("health", AlwaysSucceedingLoginModule.lastUser);
+        assertEquals(":x", AlwaysSucceedingLoginModule.lastPassword);
+    }
+
+    /** Control: an ordinary credential still reaches the realm, unaltered. */
+    @Test
+    void nonBlankPasswordReachesJaas() {
+        
assertTrue(context.authenticated(requestWith("health:a-strong-password")));
+
+        assertEquals("health", AlwaysSucceedingLoginModule.lastUser);
+        assertEquals("a-strong-password", 
AlwaysSucceedingLoginModule.lastPassword);
+    }
+
+    /** A password of spaces is a real (if terrible) password, not the 
blank-resolution failure. */
+    @Test
+    void whitespacePasswordIsNotTreatedAsBlank() {
+        assertTrue(context.authenticated(requestWith("health: ")));
+
+        assertEquals(" ", AlwaysSucceedingLoginModule.lastPassword);
+    }
+
+    /** The guard must refuse before JAAS is consulted at all, not rely on the 
realm to say no. */
+    @Test
+    void blankPasswordNeverReachesJaas() {
+        assertFalse(context.authenticated(requestWith("health:")));
+
+        assertNull(AlwaysSucceedingLoginModule.lastUser);
+        assertNull(AlwaysSucceedingLoginModule.lastPassword);
+    }
+
+    @Test
+    void malformedHeadersAreRefused() {
+        assertFalse(context.authenticated(requestWith("no-colon-at-all")));
+        assertFalse(context.authenticated(requestWithRawHeader("Basic 
not-base64!!")));
+        assertFalse(context.authenticated(requestWithRawHeader("Bearer 
some-token")));
+        assertFalse(context.authenticated(requestWithRawHeader(null)));
+
+        assertNull(AlwaysSucceedingLoginModule.lastUser, "no malformed header 
may reach JAAS");
+    }
+
+    // ------------------------------------------------------- 
extractBasicCredentials, exhaustively
+
+    /**
+     * Every header shape that yields no usable credential. All must produce 
{@code null} rather than
+     * throwing: this runs on unauthenticated, attacker-controlled input, and 
the original
+     * implementation threw out of the servlet (a 500) on several of these.
+     */
+    @ParameterizedTest(name = "[{index}] rejected: {0}")
+    @MethodSource("unusableHeaders")
+    void extractBasicCredentials_returnsNullFor(String description, String 
header) {
+        assertNull(context.extractBasicCredentials(header), description);
+    }
+
+    static Stream<Arguments> unusableHeaders() {
+        return Stream.of(
+                Arguments.of("null header", null),
+                Arguments.of("empty header", ""),
+                Arguments.of("shorter than the scheme", "Bas"),
+                Arguments.of("scheme with no trailing space", "Basic"),
+                Arguments.of("a different scheme", "Bearer some-token"),
+                Arguments.of("scheme only, nothing to decode", "Basic "),
+                Arguments.of("not valid base64", "Basic not-base64!!"),
+                Arguments.of("valid base64, no colon separator", "Basic " + 
b64("nocolon")),
+                Arguments.of("valid base64, empty payload", "Basic " + 
b64("")));
+    }
+
+    /**
+     * Every header shape that yields a credential, and exactly what it 
decodes to. Pins the RFC 7617
+     * rule (password is everything after the <em>first</em> colon) and the 
case-insensitive scheme
+     * match of RFC 7235 §2.1 — the previous blind {@code substring(6)} 
accepted {@code "basic "},
+     * so tightening the prefix check had to preserve that.
+     */
+    @ParameterizedTest(name = "[{index}] {0}")
+    @MethodSource("usableHeaders")
+    void extractBasicCredentials_decodes(String description, String header, 
String user, String password) {
+        String[] parts = context.extractBasicCredentials(header);
+
+        assertNotNull(parts, description);
+        assertEquals(2, parts.length);
+        assertEquals(user, parts[0], description);
+        assertEquals(password, parts[1], description);
+    }
+
+    static Stream<Arguments> usableHeaders() {
+        return Stream.of(
+                Arguments.of("ordinary credential", "Basic " + 
b64("health:s3cret"), "health", "s3cret"),
+                Arguments.of("empty password", "Basic " + b64("health:"), 
"health", ""),
+                Arguments.of("empty user", "Basic " + b64(":s3cret"), "", 
"s3cret"),
+                Arguments.of("both empty", "Basic " + b64(":"), "", ""),
+                Arguments.of("password is a lone colon", "Basic " + 
b64("health::"), "health", ":"),
+                Arguments.of("password starts with a colon", "Basic " + 
b64("health::x"), "health", ":x"),
+                Arguments.of("password contains colons", "Basic " + 
b64("health:pa:ss:wd"), "health", "pa:ss:wd"),
+                Arguments.of("password is whitespace", "Basic " + b64("health: 
"), "health", " "),
+                Arguments.of("lowercase scheme", "basic " + 
b64("health:s3cret"), "health", "s3cret"),
+                Arguments.of("mixed-case scheme", "BaSiC " + 
b64("health:s3cret"), "health", "s3cret"),
+                Arguments.of("padded base64", "Basic   " + 
b64("health:s3cret") + "  ", "health", "s3cret"),
+                Arguments.of("non-ASCII password", "Basic " + 
b64("health:pässwörd"), "health", "pässwörd"));
+    }
+
+    /**
+     * Answers the question directly: can {@code parts[1]} be {@code null}, 
making the
+     * {@code password.isEmpty()} guard throw? It cannot. {@link 
String#split(String, int)} only ever
+     * produces non-null substrings, and any result that is not exactly two 
elements is rejected
+     * before it is returned — so both elements are always non-null when a 
caller gets an array.
+     */
+    @ParameterizedTest
+    @MethodSource("usableHeaders")
+    void extractBasicCredentials_neverReturnsNullElements(String description, 
String header, String user,
+            String password) {
+        String[] parts = context.extractBasicCredentials(header);
+
+        assertNotNull(parts[0], description);
+        assertNotNull(parts[1], description);
+    }
+
+    private static String b64(String raw) {
+        return 
Base64.getEncoder().encodeToString(raw.getBytes(StandardCharsets.UTF_8));
+    }
+
+    private HttpServletRequest requestWith(String credentials) {
+        return requestWithRawHeader("Basic " + b64(credentials));
+    }
+
+    private HttpServletRequest requestWithRawHeader(String authorization) {
+        HttpServletRequest request = mock(HttpServletRequest.class);
+        when(request.getHeader("Authorization")).thenReturn(authorization);
+        return request;
+    }
+
+    /** Minimal JAAS setup so {@code new LoginContext(realm, ...)} succeeds 
without a real Karaf realm. */
+    private static class AlwaysSucceedingConfiguration extends Configuration {
+        @Override
+        public AppConfigurationEntry[] getAppConfigurationEntry(String name) {
+            if (!REALM.equals(name)) {
+                return null;
+            }
+            return new AppConfigurationEntry[]{
+                    new AppConfigurationEntry(
+                            AlwaysSucceedingLoginModule.class.getName(),
+                            
AppConfigurationEntry.LoginModuleControlFlag.REQUIRED,
+                            new HashMap<>())
+            };
+        }
+    }
+
+    public static class AlwaysSucceedingLoginModule implements LoginModule {
+
+        /**
+         * What the realm was actually handed, so a test can distinguish 
"refused before JAAS" from
+         * "JAAS was called with a password the parser mangled". Static 
because JAAS instantiates the
+         * module reflectively, leaving no handle on the instance; {@link 
#reset()} runs before each
+         * test, and these tests are not parallelised.
+         */
+        static String lastUser;
+        static String lastPassword;
+
+        static void reset() {
+            lastUser = null;
+            lastPassword = null;
+        }
+
+        private Subject subject;
+        private CallbackHandler callbackHandler;
+
+        @Override
+        public void initialize(Subject subject, CallbackHandler 
callbackHandler, Map<String, ?> sharedState,
+                Map<String, ?> options) {
+            this.subject = subject;
+            this.callbackHandler = callbackHandler;
+        }
+
+        @Override
+        public boolean login() throws LoginException {
+            try {
+                NameCallback nameCallback = new NameCallback("name");
+                PasswordCallback passwordCallback = new 
PasswordCallback("password", false);
+                callbackHandler.handle(new Callback[]{nameCallback, 
passwordCallback});
+                lastUser = nameCallback.getName();
+                lastPassword = passwordCallback.getPassword() == null
+                        ? null : new String(passwordCallback.getPassword());
+            } catch (IOException | UnsupportedCallbackException e) {
+                throw new LoginException(e.getMessage());
+            }
+            return true;
+        }
+
+        @Override
+        public boolean commit() {
+            return true;
+        }
+
+        @Override
+        public boolean abort() {
+            return true;
+        }
+
+        @Override
+        public boolean logout() {
+            subject.getPrincipals().clear();
+            return true;
+        }
+    }
+}
diff --git 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
index 441449e42..c391033ce 100644
--- 
a/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
+++ 
b/graphql/cxs-impl/src/main/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidator.java
@@ -155,6 +155,17 @@ public class GraphQLServletSecurityValidator {
         String username = usernameAndPassword.substring(0, userNameIndex);
         String password = usernameAndPassword.substring(userNameIndex + 1);
 
+        // An unset org.apache.unomi.security.root.password resolves to the 
empty string, which
+        // PropertiesLoginModule then accepts as the shipped administrator's 
password (UNOMI-974).
+        // This servlet authenticates against the karaf realm directly rather 
than through the REST
+        // AuthenticationFilter, so it needs its own refusal: it stays 
reachable on launch paths the
+        // startup guards in bin/setenv and the Docker entrypoint cannot 
cover, notably karaf.bat.
+        // Checked ahead of the API key lookup too — an empty private key is 
never a valid one.
+        if (password.isEmpty()) {
+            LOG.warn("Rejecting Basic authentication with an empty password");
+            return false;
+        }
+
         // First try API key authentication
         if (username.length() > 0) {
             Tenant tenant = tenantService.getTenantByApiKey(password, 
ApiKey.ApiKeyType.PRIVATE);
diff --git 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
index e64acd008..1023741cd 100644
--- 
a/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
+++ 
b/graphql/cxs-impl/src/test/java/org/apache/unomi/graphql/servlet/auth/GraphQLServletSecurityValidatorTest.java
@@ -138,6 +138,41 @@ class GraphQLServletSecurityValidatorTest {
         verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
     }
 
+    /**
+     * An unset {@code org.apache.unomi.security.root.password} resolves to 
the empty string, which
+     * {@code PropertiesLoginModule} accepts as the shipped administrator's 
password (UNOMI-974).
+     * This servlet logs in against the karaf realm directly, outside the REST
+     * {@code AuthenticationFilter}, so it carries its own refusal.
+     * <p>
+     * The realm stubbed here accepts <em>any</em> credential, so this only 
passes if the empty
+     * password is refused before JAAS is ever consulted — asserting on the 
401 alone would prove
+     * nothing, since a rejecting realm answers 401 too.
+     */
+    @Test
+    void validate_withBlankPassword_isRejectedBeforeReachingJaas() throws 
IOException {
+        when(request.getHeader("Authorization"))
+                .thenReturn("Basic " + 
Base64.getEncoder().encodeToString("karaf:".getBytes()));
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertFalse(authenticated);
+        verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED);
+        verify(securityService, never()).setCurrentSubject(any());
+        verify(executionContextManager, never()).setCurrentContext(any());
+    }
+
+    /** Control: a non-blank credential still reaches the realm and is 
accepted by it. */
+    @Test
+    void validate_withNonBlankPassword_reachesJaas() throws IOException {
+        when(request.getHeader("Authorization")).thenReturn(BASIC_AUTH);
+        when(tenantService.getTenantByApiKey(any(), 
eq(ApiKey.ApiKeyType.PRIVATE))).thenReturn(null);
+
+        boolean authenticated = validator.validate(null, null, request, 
response);
+
+        assertTrue(authenticated);
+        verify(response, never()).sendError(any(Integer.class));
+    }
+
     /**
      * Minimal JAAS configuration that makes {@code new LoginContext("karaf", 
...)} succeed
      * without requiring a real Karaf realm, so the post-login branches under 
test can run
diff --git a/manual/src/main/asciidoc/building-and-deploying.adoc 
b/manual/src/main/asciidoc/building-and-deploying.adoc
index dc1553326..f4599cf90 100644
--- a/manual/src/main/asciidoc/building-and-deploying.adoc
+++ b/manual/src/main/asciidoc/building-and-deploying.adoc
@@ -339,6 +339,15 @@ The "package" sub-project generates a pre-configured 
Apache Karaf installation t
 Simply uncompress the package/target/unomi-VERSION.tar.gz (for Linux or Mac OS 
X) or
  package/target/unomi-VERSION.zip (for Windows) archive into the directory of 
your choice.
 
+Apache Unomi ships no default administrator password, and `bin/karaf` refuses 
to start until one is
+set, so export both passwords first:
+
+[source]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
 You can then start the server simply by using the command on UNIX/Linux/MacOS 
X :
 
 [source]
@@ -350,9 +359,16 @@ or on Windows shell :
 
 [source]
 ----
+set UNOMI_ROOT_PASSWORD=choose-a-strong-password
+set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
 bin\karaf.bat
 ----
 
+WARNING: On Windows, `karaf.bat` does not check the exit code of `setenv.bat`, 
so a missing password
+is reported but startup continues anyway with an account whose password is 
empty. Set both variables
+before launching, and verify with `curl -i -u "karaf:" 
http://localhost:8181/cxs/tenants`, which must
+return 401.
+
 You will then need to launch (only on the first Karaf start) the Apache Unomi 
packages using the following Apache Karaf
 shell command:
 
@@ -397,7 +413,7 @@ Create a new $MY_KARAF_HOME/etc/org.apache.cxf.osgi.cfg 
file and put the followi
 ----
 
 If all went smoothly, you should be able to access the context script here : 
http://localhost:8181/cxs/cluster[http://localhost:8181/cxs/cluster] .
- You should be able to login with karaf / karaf and see basic server 
information. If not something went wrong during the install.
+ You should be able to login as `karaf` with the password you set in 
`UNOMI_ROOT_PASSWORD` and see basic server information. If not something went 
wrong during the install.
 
 ==== Installing GraphViz for Manual Generation
 
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 2ebbbc88c..5f51a0561 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -1756,7 +1756,9 @@ is up and running and can serve requests.
 The health check endpoint is available at the following URL: /health/check and 
returns a simple JSON response that includes all health check provider 
responses.
 
 Basic Http Authentication enforce security for the health check endpoint using 
the existing karaf realm. The user needs to have the specific role **health**
-to access the endpoint. Users and roles can be configured in the 
etc/users.properties file. By default, a login/pass health/health is configured.
+to access the endpoint. Users and roles can be configured in the 
etc/users.properties file. The shipped `health` user has no default password: 
its
+password comes from `UNOMI_HEALTHCHECK_PASSWORD`, which must be set before 
starting (see <<_rest_api_security,REST API security>>). An empty password is
+never accepted, on this endpoint or any other.
 
 Specific configuration is located in : org.apache.unomi.healthcheck.cfg  
Existing health checks are using configuration from that file, including 
authentication realm.
 
diff --git a/manual/src/main/asciidoc/index.adoc 
b/manual/src/main/asciidoc/index.adoc
index c14d4b5f4..36bf630d0 100644
--- a/manual/src/main/asciidoc/index.adoc
+++ b/manual/src/main/asciidoc/index.adoc
@@ -181,7 +181,7 @@ UNOMI_HEALTHCHECK_ENABLED=true
 UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence
 ----
 
-The endpoint is protected by the `health` role (default user `health` / 
`health`). Full provider configuration, sample JSON, and extension points are 
documented in the Configuration chapter: <<_health_check,Health check 
extension>>.
+The endpoint is protected by the `health` role (user `health`, password from 
`UNOMI_HEALTHCHECK_PASSWORD` — no default is shipped). Full provider 
configuration, sample JSON, and extension points are documented in the 
Configuration chapter: <<_health_check,Health check extension>>.
 
 == Reference
 
diff --git a/manual/src/main/asciidoc/recipes.adoc 
b/manual/src/main/asciidoc/recipes.adoc
index 8f0f8c9ce..7a0eef0df 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 (user `karaf`, with 
the password you set in `UNOMI_ROOT_PASSWORD` — no default is shipped):
 
 [source]
 ----
diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc 
b/manual/src/main/asciidoc/samples/login-sample.adoc
index 0a33e2377..dc8a88d20 100644
--- a/manual/src/main/asciidoc/samples/login-sample.adoc
+++ b/manual/src/main/asciidoc/samples/login-sample.adoc
@@ -30,7 +30,7 @@ Login into the Unomi Karaf SSH shell using something like 
this :
 
 [source]
 ----
-ssh -p 8102 karaf@localhost (default password is karaf) 
+ssh -p 8102 karaf@localhost (the password is the one you set in 
UNOMI_ROOT_PASSWORD; no default is shipped)
 ----
 
 Install the login samples using the following command:
diff --git a/manual/src/main/asciidoc/useful-unomi-urls.adoc 
b/manual/src/main/asciidoc/useful-unomi-urls.adoc
index 804c96e87..238ea423a 100644
--- a/manual/src/main/asciidoc/useful-unomi-urls.adoc
+++ b/manual/src/main/asciidoc/useful-unomi-urls.adoc
@@ -132,7 +132,7 @@ where PROFILE_ID is a profile identifier. This will indeed 
retrieve all the even
 
 |/health/check
 |GET
-|Health check JSON (role `health`, default user `health`/`health`). See 
<<_health_check,Health Check extension>>.
+|Health check JSON (role `health`, user `health` with the password from 
`UNOMI_HEALTHCHECK_PASSWORD` — no default is shipped). See 
<<_health_check,Health Check extension>>.
 
 |/cxs/context.json?explain=true
 |POST
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
index 19d1203fa..016a45756 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
@@ -140,6 +140,36 @@ class AuthenticationFilterBlankPasswordTest {
         verify(jaasAuthenticationFilter).filter(requestContext);
     }
 
+    /**
+     * The ordinary V3 branch: not {@code tenants}, not a public path, V2 
compatibility off. This is
+     * the route most private REST calls take, and it consumes the Basic 
credential at its own call
+     * site — with only the {@code tenants} and V2 tests above, deleting the 
guard here would leave
+     * the whole suite green.
+     */
+    @Test
+    void filterRejectsBlankPasswordOnAPrivatePath() throws IOException {
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
basic("karaf:"));
+
+        filter.filter(requestContext);
+
+        assertUnauthorizedWithoutReachingJaas(requestContext);
+    }
+
+    /**
+     * Control for the ordinary V3 branch: a non-blank credential must still 
be offered to the tenant
+     * private-key check and then to JAAS.
+     */
+    @Test
+    void filterPassesNonBlankPasswordToJaasOnAPrivatePath() throws IOException 
{
+        
when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+        ContainerRequestContext requestContext = request("profiles", 
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.

Reply via email to