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.