From 5993fe02910e96f51c6890cca46bb75c0f9c97cf Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Sun, 4 Jan 2026 17:20:45 +0100 Subject: [PATCH 01/14] Adds opt-in X-Session-Id header. --- .../dhis/external/conf/ConfigurationKey.java | 9 ++ .../org/hisp/dhis/http/HttpClientAdapter.java | 5 + .../filter/SessionIdHeaderFilterTest.java | 132 +++++++++++++++++ .../webapi/filter/SessionIdHeaderFilter.java | 139 ++++++++++++++++++ .../servlet/DhisWebApiWebAppInitializer.java | 4 + 5 files changed, 289 insertions(+) create mode 100644 dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java create mode 100644 dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index 9f3cdde267ce..0330d43af2f0 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -535,6 +535,15 @@ public enum ConfigurationKey { */ LOGGING_REQUEST_ID_ENABLED("logging.request_id.enabled", Constants.ON, false), + /** + * Adds an encrypted session/user identifier to the X-Session-ID response header. + * (default: off). + */ + LOGGING_SESSION_ID_HEADER_ENABLED("logging.session_id_header.enabled", Constants.OFF, false), + + /** Encryption key for X-Session-ID header value (sensitive). */ + LOGGING_SESSION_ID_ENCRYPTION_KEY("logging.session_id_encryption_key", "", true), + /** Base URL to the DHIS 2 instance. */ SERVER_BASE_URL("server.base.url", "", false), diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java index d1aa627c1cec..eec16ec30d91 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java @@ -401,5 +401,10 @@ public String header(String name) { public String getContentType() { return response.getContentType(); } + + @CheckForNull + public String getHeader(String headerName) { + return header(headerName); + } } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java new file mode 100644 index 000000000000..777b0c71b6ca --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java @@ -0,0 +1,132 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.util.Arrays; +import java.util.Base64; +import java.util.Properties; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.http.HttpClientAdapter.HttpResponse; +import org.hisp.dhis.test.config.H2DhisConfigurationProvider; +import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; +import org.hisp.dhis.webapi.filter.ApiVersionFilter; +import org.hisp.dhis.webapi.filter.RequestIdFilter; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@ContextConfiguration(classes = SessionIdHeaderFilterTest.TestConfig.class) +class SessionIdHeaderFilterTest extends H2ControllerIntegrationTestBase { + private static final String ENCRYPTION_KEY = "test-session-header-key"; + private static final String HEADER_NAME = "X-Session-ID"; + + @Autowired private RequestIdFilter requestIdFilter; + @Autowired private ApiVersionFilter apiVersionFilter; + @Autowired private SessionIdHeaderFilter sessionIdHeaderFilter; + + @BeforeEach + void setupFilters() { + mvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext) + .addFilter(requestIdFilter) + .addFilter(apiVersionFilter) + .addFilter(sessionIdHeaderFilter) + .build(); + } + + @Test + void shouldEncryptUserUidAndSessionHashInHeader() throws Exception { + HttpResponse response = GET("/test/sessionHeader"); + String header = response.header(HEADER_NAME); + assertNotNull(header); + + String payload = decryptHeader(header, ENCRYPTION_KEY); + String expected = getAdminUid() + ":" + hashSessionId(session.getId()); + assertEquals(expected, payload); + } + + private static String decryptHeader(String header, String key) throws GeneralSecurityException { + byte[] combined = Base64.getUrlDecoder().decode(header.substring(3)); + byte[] iv = Arrays.copyOfRange(combined, 0, 12); + byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length); + byte[] keyBytes = + MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8)); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv)); + return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); + } + + private static String hashSessionId(String sessionId) throws GeneralSecurityException { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hashed = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } + + @Configuration + static class TestConfig { + @Bean + @Primary + DhisConfigurationProvider dhisConfigurationProvider() { + H2DhisConfigurationProvider provider = new H2DhisConfigurationProvider(); + Properties properties = new Properties(); + properties.setProperty("logging.session_id_header.enabled", "on"); + properties.setProperty("logging.session_id_encryption_key", ENCRYPTION_KEY); + provider.addProperties(properties); + return provider; + } + } + + @Controller + static class TestSessionHeaderController { + @GetMapping("/api/test/sessionHeader") + @ResponseBody + public String ok() { + return "ok"; + } + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java new file mode 100644 index 000000000000..ca0c1f927c94 --- /dev/null +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.filter; + +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_ENCRYPTION_KEY; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import jakarta.servlet.http.HttpSession; +import java.io.IOException; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.MessageDigest; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import lombok.extern.slf4j.Slf4j; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.user.CurrentUserUtil; +import org.hisp.dhis.user.UserDetails; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +/** + * Filter that adds an encrypted session/user identifier to the response headers for authenticated + * users. The header value is reversible using the configured key, allowing downstream systems to + * correlate requests to a user session. + * + *

Enabled when {@code logging.session_id_header.enabled} is true and + * {@code logging.session_id_encryption_key} is set. + */ +@Slf4j +@Component("sessionIdHeaderFilter") +public class SessionIdHeaderFilter extends OncePerRequestFilter { + private static final String HEADER_NAME = "X-Session-ID"; + private static final String HEADER_VERSION_PREFIX = "v1."; + private static final int GCM_IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; + private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; + private static final String HASH_ALGO = "SHA-256"; + + private final boolean enabled; + private final byte[] keyBytes; + private final SecureRandom secureRandom = new SecureRandom(); + + public SessionIdHeaderFilter(DhisConfigurationProvider dhisConfig) { + boolean configEnabled = dhisConfig.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED); + String token = dhisConfig.getProperty(LOGGING_SESSION_ID_ENCRYPTION_KEY); + if (configEnabled && (token == null || token.isBlank())) { + log.warn( + "Session ID header logging enabled, but logging.session_id_encryption_key is not set."); + this.enabled = false; + this.keyBytes = new byte[0]; + } else { + this.enabled = configEnabled; + this.keyBytes = token == null ? new byte[0] : deriveKey(token); + } + } + + @Override + protected void doFilterInternal( + HttpServletRequest request, HttpServletResponse response, FilterChain chain) + throws ServletException, IOException { + if (enabled && CurrentUserUtil.hasCurrentUser()) { + try { + UserDetails userDetails = CurrentUserUtil.getCurrentUserDetails(); + HttpSession session = request.getSession(false); + if (session != null) { + String payload = userDetails.getUid() + ":" + hashSessionId(session.getId()); + response.addHeader(HEADER_NAME, encrypt(payload)); + } + } catch (GeneralSecurityException ex) { + log.error("Failed to encrypt session header payload", ex); + } + } + chain.doFilter(request, response); + } + + private String encrypt(String payload) throws GeneralSecurityException { + byte[] iv = new byte[GCM_IV_LENGTH_BYTES]; + secureRandom.nextBytes(iv); + Cipher cipher = Cipher.getInstance(CIPHER_ALGO); + SecretKeySpec key = new SecretKeySpec(keyBytes, "AES"); + cipher.init(Cipher.ENCRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] ciphertext = cipher.doFinal(payload.getBytes(StandardCharsets.UTF_8)); + byte[] combined = new byte[iv.length + ciphertext.length]; + System.arraycopy(iv, 0, combined, 0, iv.length); + System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); + return HEADER_VERSION_PREFIX + + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + } + + private static byte[] deriveKey(String token) { + try { + MessageDigest digest = MessageDigest.getInstance(HASH_ALGO); + return digest.digest(token.getBytes(StandardCharsets.UTF_8)); + } catch (GeneralSecurityException ex) { + throw new IllegalStateException("Unable to derive session header key", ex); + } + } + + private static String hashSessionId(String sessionId) throws GeneralSecurityException { + MessageDigest digest = MessageDigest.getInstance(HASH_ALGO); + byte[] hashed = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); + return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); + } +} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index 9cfb4b93a315..0963c0bebeea 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -159,6 +159,10 @@ public static void setupServlets( .addFilter("sessionIdFilter", new DelegatingFilterProxy("sessionIdFilter")) .addMappingForUrlPatterns(null, true, "/*"); + context + .addFilter("sessionIdHeaderFilter", new DelegatingFilterProxy("sessionIdHeaderFilter")) + .addMappingForUrlPatterns(null, true, "/*"); + /* Intercept index.html, plugin.html, and other html requests to inject no-cache headers using ContextUtils.setNoStore(response). */ From 64c12dec056017a2c4b28007a16f9fcd4618ac23 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Sun, 4 Jan 2026 17:25:26 +0100 Subject: [PATCH 02/14] Linting --- .../org/hisp/dhis/external/conf/ConfigurationKey.java | 3 +-- .../dhis/webapi/filter/SessionIdHeaderFilterTest.java | 2 -- .../hisp/dhis/webapi/filter/SessionIdHeaderFilter.java | 9 ++++----- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index 0330d43af2f0..f999b9649912 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -536,8 +536,7 @@ public enum ConfigurationKey { LOGGING_REQUEST_ID_ENABLED("logging.request_id.enabled", Constants.ON, false), /** - * Adds an encrypted session/user identifier to the X-Session-ID response header. - * (default: off). + * Adds an encrypted session/user identifier to the X-Session-ID response header. (default: off). */ LOGGING_SESSION_ID_HEADER_ENABLED("logging.session_id_header.enabled", Constants.OFF, false), diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java index 777b0c71b6ca..bcde23a08a14 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java @@ -45,8 +45,6 @@ import org.hisp.dhis.http.HttpClientAdapter.HttpResponse; import org.hisp.dhis.test.config.H2DhisConfigurationProvider; import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; -import org.hisp.dhis.webapi.filter.ApiVersionFilter; -import org.hisp.dhis.webapi.filter.RequestIdFilter; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java index ca0c1f927c94..411065906afc 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java @@ -29,8 +29,8 @@ */ package org.hisp.dhis.webapi.filter; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -58,8 +58,8 @@ * users. The header value is reversible using the configured key, allowing downstream systems to * correlate requests to a user session. * - *

Enabled when {@code logging.session_id_header.enabled} is true and - * {@code logging.session_id_encryption_key} is set. + *

Enabled when {@code logging.session_id_header.enabled} is true and {@code + * logging.session_id_encryption_key} is set. */ @Slf4j @Component("sessionIdHeaderFilter") @@ -118,8 +118,7 @@ private String encrypt(String payload) throws GeneralSecurityException { byte[] combined = new byte[iv.length + ciphertext.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(ciphertext, 0, combined, iv.length, ciphertext.length); - return HEADER_VERSION_PREFIX - + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); + return HEADER_VERSION_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); } private static byte[] deriveKey(String token) { From edba1248adde875afb28c58f63ef167846a806cc Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Sun, 4 Jan 2026 17:32:08 +0100 Subject: [PATCH 03/14] Remove unused import --- .../org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java index bcde23a08a14..976e48b89f56 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java @@ -42,7 +42,6 @@ import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; import org.hisp.dhis.external.conf.DhisConfigurationProvider; -import org.hisp.dhis.http.HttpClientAdapter.HttpResponse; import org.hisp.dhis.test.config.H2DhisConfigurationProvider; import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; import org.junit.jupiter.api.BeforeEach; From 62fc0226ff1acb10aad7890659dd13b53fb9fcea Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 5 Jan 2026 06:52:45 +0100 Subject: [PATCH 04/14] Cleanup --- .../org/hisp/dhis/http/HttpClientAdapter.java | 5 - .../filter/SessionIdHeaderFilterTest.java | 11 +- .../webapi/filter/SessionIdHeaderFilter.java | 12 +- .../filter/SessionIdHeaderFilterUnitTest.java | 128 ++++++++++++++++++ 4 files changed, 147 insertions(+), 9 deletions(-) create mode 100644 dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java index eec16ec30d91..d1aa627c1cec 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java +++ b/dhis-2/dhis-support/dhis-support-test/src/main/java/org/hisp/dhis/http/HttpClientAdapter.java @@ -401,10 +401,5 @@ public String header(String name) { public String getContentType() { return response.getContentType(); } - - @CheckForNull - public String getHeader(String headerName) { - return header(headerName); - } } } diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java index 976e48b89f56..6ca29c4d8ef8 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java @@ -39,7 +39,9 @@ import java.util.Base64; import java.util.Properties; import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.test.config.H2DhisConfigurationProvider; @@ -60,6 +62,10 @@ class SessionIdHeaderFilterTest extends H2ControllerIntegrationTestBase { private static final String ENCRYPTION_KEY = "test-session-header-key"; private static final String HEADER_NAME = "X-Session-ID"; + private static final String KDF_ALGO = "PBKDF2WithHmacSHA256"; + private static final int KDF_ITERATIONS = 10000; + private static final int KEY_LENGTH_BITS = 256; + private static final byte[] KDF_SALT = "dhis2-session-id-header".getBytes(StandardCharsets.UTF_8); @Autowired private RequestIdFilter requestIdFilter; @Autowired private ApiVersionFilter apiVersionFilter; @@ -90,8 +96,9 @@ private static String decryptHeader(String header, String key) throws GeneralSec byte[] combined = Base64.getUrlDecoder().decode(header.substring(3)); byte[] iv = Arrays.copyOfRange(combined, 0, 12); byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length); - byte[] keyBytes = - MessageDigest.getInstance("SHA-256").digest(key.getBytes(StandardCharsets.UTF_8)); + PBEKeySpec spec = new PBEKeySpec(key.toCharArray(), KDF_SALT, KDF_ITERATIONS, KEY_LENGTH_BITS); + SecretKeyFactory factory = SecretKeyFactory.getInstance(KDF_ALGO); + byte[] keyBytes = factory.generateSecret(spec).getEncoded(); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init( Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv)); diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java index 411065906afc..f1540aa33cfb 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java @@ -44,7 +44,9 @@ import java.security.SecureRandom; import java.util.Base64; import javax.crypto.Cipher; +import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.external.conf.DhisConfigurationProvider; @@ -70,6 +72,10 @@ public class SessionIdHeaderFilter extends OncePerRequestFilter { private static final int GCM_TAG_LENGTH_BITS = 128; private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; private static final String HASH_ALGO = "SHA-256"; + private static final String KDF_ALGO = "PBKDF2WithHmacSHA256"; + private static final int KDF_ITERATIONS = 10000; + private static final int KEY_LENGTH_BITS = 256; + private static final byte[] KDF_SALT = "dhis2-session-id-header".getBytes(StandardCharsets.UTF_8); private final boolean enabled; private final byte[] keyBytes; @@ -123,8 +129,10 @@ private String encrypt(String payload) throws GeneralSecurityException { private static byte[] deriveKey(String token) { try { - MessageDigest digest = MessageDigest.getInstance(HASH_ALGO); - return digest.digest(token.getBytes(StandardCharsets.UTF_8)); + PBEKeySpec spec = + new PBEKeySpec(token.toCharArray(), KDF_SALT, KDF_ITERATIONS, KEY_LENGTH_BITS); + SecretKeyFactory factory = SecretKeyFactory.getInstance(KDF_ALGO); + return factory.generateSecret(spec).getEncoded(); } catch (GeneralSecurityException ex) { throw new IllegalStateException("Unable to derive session header key", ex); } diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java new file mode 100644 index 000000000000..5e78ef1514ca --- /dev/null +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java @@ -0,0 +1,128 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.filter; + +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; +import static org.mockito.ArgumentMatchers.anyString; +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; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.user.UserDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class SessionIdHeaderFilterUnitTest { + private static final String HEADER_NAME = "X-Session-ID"; + + @Mock private DhisConfigurationProvider dhisConfigurationProvider; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldNotAddHeaderWhenDisabled() throws Exception { + SessionIdHeaderFilter filter = init(false); + withAuthenticatedUser(); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { + SessionIdHeaderFilter filter = init(true); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + void shouldNotAddHeaderWhenSessionMissing() throws Exception { + SessionIdHeaderFilter filter = init(true); + withAuthenticatedUser(); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + when(req.getSession(false)).thenReturn(null); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + private SessionIdHeaderFilter init(boolean enabled) { + when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED)) + .thenReturn(enabled); + when(dhisConfigurationProvider.getProperty(LOGGING_SESSION_ID_ENCRYPTION_KEY)) + .thenReturn("test-key"); + return new SessionIdHeaderFilter(dhisConfigurationProvider); + } + + private void withAuthenticatedUser() { + UserDetails userDetails = mock(UserDetails.class); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, "pw", List.of((GrantedAuthority) () -> "ALL")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } +} From 507c2bcaeb2733c02d86a710f8b5e22325c6a6dd Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 5 Jan 2026 07:15:55 +0100 Subject: [PATCH 05/14] Change encoding method --- .../dhis/external/conf/ConfigurationKey.java | 2 +- .../filter/SessionIdHeaderFilterTest.java | 12 ++------ .../webapi/filter/SessionIdHeaderFilter.java | 30 +++++++++---------- .../filter/SessionIdHeaderFilterUnitTest.java | 3 +- 4 files changed, 20 insertions(+), 27 deletions(-) diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index f999b9649912..dc68c4bb0ff0 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -540,7 +540,7 @@ public enum ConfigurationKey { */ LOGGING_SESSION_ID_HEADER_ENABLED("logging.session_id_header.enabled", Constants.OFF, false), - /** Encryption key for X-Session-ID header value (sensitive). */ + /** Base64-encoded 32-byte encryption key for X-Session-ID header value (sensitive). */ LOGGING_SESSION_ID_ENCRYPTION_KEY("logging.session_id_encryption_key", "", true), /** Base URL to the DHIS 2 instance. */ diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java index 6ca29c4d8ef8..d017dffe2a5a 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java @@ -39,9 +39,7 @@ import java.util.Base64; import java.util.Properties; import javax.crypto.Cipher; -import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.test.config.H2DhisConfigurationProvider; @@ -60,12 +58,8 @@ @ContextConfiguration(classes = SessionIdHeaderFilterTest.TestConfig.class) class SessionIdHeaderFilterTest extends H2ControllerIntegrationTestBase { - private static final String ENCRYPTION_KEY = "test-session-header-key"; + private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; private static final String HEADER_NAME = "X-Session-ID"; - private static final String KDF_ALGO = "PBKDF2WithHmacSHA256"; - private static final int KDF_ITERATIONS = 10000; - private static final int KEY_LENGTH_BITS = 256; - private static final byte[] KDF_SALT = "dhis2-session-id-header".getBytes(StandardCharsets.UTF_8); @Autowired private RequestIdFilter requestIdFilter; @Autowired private ApiVersionFilter apiVersionFilter; @@ -96,9 +90,7 @@ private static String decryptHeader(String header, String key) throws GeneralSec byte[] combined = Base64.getUrlDecoder().decode(header.substring(3)); byte[] iv = Arrays.copyOfRange(combined, 0, 12); byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length); - PBEKeySpec spec = new PBEKeySpec(key.toCharArray(), KDF_SALT, KDF_ITERATIONS, KEY_LENGTH_BITS); - SecretKeyFactory factory = SecretKeyFactory.getInstance(KDF_ALGO); - byte[] keyBytes = factory.generateSecret(spec).getEncoded(); + byte[] keyBytes = Base64.getDecoder().decode(key); Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); cipher.init( Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv)); diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java index f1540aa33cfb..f69e6b5ad881 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java @@ -44,9 +44,7 @@ import java.security.SecureRandom; import java.util.Base64; import javax.crypto.Cipher; -import javax.crypto.SecretKeyFactory; import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.SecretKeySpec; import lombok.extern.slf4j.Slf4j; import org.hisp.dhis.external.conf.DhisConfigurationProvider; @@ -72,10 +70,7 @@ public class SessionIdHeaderFilter extends OncePerRequestFilter { private static final int GCM_TAG_LENGTH_BITS = 128; private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; private static final String HASH_ALGO = "SHA-256"; - private static final String KDF_ALGO = "PBKDF2WithHmacSHA256"; - private static final int KDF_ITERATIONS = 10000; - private static final int KEY_LENGTH_BITS = 256; - private static final byte[] KDF_SALT = "dhis2-session-id-header".getBytes(StandardCharsets.UTF_8); + private static final int KEY_LENGTH_BYTES = 32; private final boolean enabled; private final byte[] keyBytes; @@ -90,8 +85,16 @@ public SessionIdHeaderFilter(DhisConfigurationProvider dhisConfig) { this.enabled = false; this.keyBytes = new byte[0]; } else { - this.enabled = configEnabled; - this.keyBytes = token == null ? new byte[0] : deriveKey(token); + byte[] decodedKey = token == null ? new byte[0] : decodeKey(token); + if (decodedKey.length != KEY_LENGTH_BYTES) { + log.warn( + "Session ID header logging enabled, but logging.session_id_encryption_key must be a base64-encoded 32-byte key."); + this.enabled = false; + this.keyBytes = new byte[0]; + } else { + this.enabled = configEnabled; + this.keyBytes = decodedKey; + } } } @@ -127,14 +130,11 @@ private String encrypt(String payload) throws GeneralSecurityException { return HEADER_VERSION_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); } - private static byte[] deriveKey(String token) { + private static byte[] decodeKey(String token) { try { - PBEKeySpec spec = - new PBEKeySpec(token.toCharArray(), KDF_SALT, KDF_ITERATIONS, KEY_LENGTH_BITS); - SecretKeyFactory factory = SecretKeyFactory.getInstance(KDF_ALGO); - return factory.generateSecret(spec).getEncoded(); - } catch (GeneralSecurityException ex) { - throw new IllegalStateException("Unable to derive session header key", ex); + return Base64.getDecoder().decode(token); + } catch (IllegalArgumentException ex) { + return new byte[0]; } } diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java index 5e78ef1514ca..767b6abf6f8f 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java @@ -58,6 +58,7 @@ @ExtendWith(MockitoExtension.class) class SessionIdHeaderFilterUnitTest { private static final String HEADER_NAME = "X-Session-ID"; + private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; @Mock private DhisConfigurationProvider dhisConfigurationProvider; @@ -112,7 +113,7 @@ private SessionIdHeaderFilter init(boolean enabled) { when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED)) .thenReturn(enabled); when(dhisConfigurationProvider.getProperty(LOGGING_SESSION_ID_ENCRYPTION_KEY)) - .thenReturn("test-key"); + .thenReturn(ENCRYPTION_KEY); return new SessionIdHeaderFilter(dhisConfigurationProvider); } From 49a28410fcd497cfc551d6f191abffe14c3c4e8f Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 5 Jan 2026 09:32:57 +0100 Subject: [PATCH 06/14] Add caching --- .../webapi/filter/SessionIdHeaderFilter.java | 39 ++++++++++++++++++- 1 file changed, 37 insertions(+), 2 deletions(-) diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java index f69e6b5ad881..d7a866257d5b 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java @@ -43,6 +43,8 @@ import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.TimeUnit; import javax.crypto.Cipher; import javax.crypto.spec.GCMParameterSpec; import javax.crypto.spec.SecretKeySpec; @@ -71,6 +73,10 @@ public class SessionIdHeaderFilter extends OncePerRequestFilter { private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; private static final String HASH_ALGO = "SHA-256"; private static final int KEY_LENGTH_BYTES = 32; + private static final long CACHE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(5); + private static final int MAX_CACHE_SIZE = 10_000; + private static final ConcurrentHashMap HEADER_CACHE = + new ConcurrentHashMap<>(); private final boolean enabled; private final byte[] keyBytes; @@ -107,8 +113,10 @@ protected void doFilterInternal( UserDetails userDetails = CurrentUserUtil.getCurrentUserDetails(); HttpSession session = request.getSession(false); if (session != null) { - String payload = userDetails.getUid() + ":" + hashSessionId(session.getId()); - response.addHeader(HEADER_NAME, encrypt(payload)); + String sessionHash = hashSessionId(session.getId()); + String cacheKey = userDetails.getUid() + ":" + sessionHash; + String headerValue = getOrCreateHeaderValue(cacheKey); + response.addHeader(HEADER_NAME, headerValue); } } catch (GeneralSecurityException ex) { log.error("Failed to encrypt session header payload", ex); @@ -130,6 +138,31 @@ private String encrypt(String payload) throws GeneralSecurityException { return HEADER_VERSION_PREFIX + Base64.getUrlEncoder().withoutPadding().encodeToString(combined); } + private String getOrCreateHeaderValue(String cacheKey) throws GeneralSecurityException { + long now = System.currentTimeMillis(); + CacheEntry cached = HEADER_CACHE.get(cacheKey); + if (cached != null && cached.expiresAtMillis > now) { + return cached.headerValue; + } + + if (cached != null) { + HEADER_CACHE.remove(cacheKey, cached); + } + + String headerValue = encrypt(cacheKey); + CacheEntry entry = new CacheEntry(headerValue, now + CACHE_TTL_MILLIS); + HEADER_CACHE.put(cacheKey, entry); + + if (HEADER_CACHE.size() > MAX_CACHE_SIZE) { + HEADER_CACHE.entrySet().removeIf(e -> e.getValue().expiresAtMillis <= now); + if (HEADER_CACHE.size() > MAX_CACHE_SIZE) { + HEADER_CACHE.clear(); + } + } + + return headerValue; + } + private static byte[] decodeKey(String token) { try { return Base64.getDecoder().decode(token); @@ -143,4 +176,6 @@ private static String hashSessionId(String sessionId) throws GeneralSecurityExce byte[] hashed = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); } + + private record CacheEntry(String headerValue, long expiresAtMillis) {} } From 6eee5ceeb5c1449abea1ad4b603cbcf2c00408b5 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Tue, 6 Jan 2026 08:42:15 +0100 Subject: [PATCH 07/14] Refactor for X-User-ID based on feedback --- .../dhis/external/conf/ConfigurationKey.java | 11 +- .../filter/SessionIdHeaderFilterTest.java | 128 ----------------- .../dhis/webapi/filter/SessionIdFilter.java | 26 ++-- ...nIdHeaderFilter.java => UserIdFilter.java} | 61 ++++----- .../servlet/DhisWebApiWebAppInitializer.java | 2 +- .../filter/SessionIdHeaderFilterUnitTest.java | 129 ------------------ 6 files changed, 54 insertions(+), 303 deletions(-) delete mode 100644 dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java rename dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/{SessionIdHeaderFilter.java => UserIdFilter.java} (76%) delete mode 100644 dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index dc68c4bb0ff0..995c161c120c 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -535,13 +535,14 @@ public enum ConfigurationKey { */ LOGGING_REQUEST_ID_ENABLED("logging.request_id.enabled", Constants.ON, false), - /** - * Adds an encrypted session/user identifier to the X-Session-ID response header. (default: off). - */ + /** Adds a hashed session identifier to the X-Session-ID response header. (default: off). */ LOGGING_SESSION_ID_HEADER_ENABLED("logging.session_id_header.enabled", Constants.OFF, false), - /** Base64-encoded 32-byte encryption key for X-Session-ID header value (sensitive). */ - LOGGING_SESSION_ID_ENCRYPTION_KEY("logging.session_id_encryption_key", "", true), + /** Adds an encrypted user identifier to the X-User-ID response header. (default: off). */ + LOGGING_USER_ID_HEADER_ENABLED("logging.user_id_header.enabled", Constants.OFF, false), + + /** Base64-encoded 32-byte encryption key for X-User-ID header value (sensitive). */ + LOGGING_USER_ID_ENCRYPTION_KEY("logging.user_id_encryption_key", "", true), /** Base URL to the DHIS 2 instance. */ SERVER_BASE_URL("server.base.url", "", false), diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java deleted file mode 100644 index d017dffe2a5a..000000000000 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterTest.java +++ /dev/null @@ -1,128 +0,0 @@ -/* - * Copyright (c) 2004-2025, University of Oslo - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package org.hisp.dhis.webapi.filter; - -import static org.junit.jupiter.api.Assertions.assertEquals; -import static org.junit.jupiter.api.Assertions.assertNotNull; - -import java.nio.charset.StandardCharsets; -import java.security.GeneralSecurityException; -import java.security.MessageDigest; -import java.util.Arrays; -import java.util.Base64; -import java.util.Properties; -import javax.crypto.Cipher; -import javax.crypto.spec.GCMParameterSpec; -import javax.crypto.spec.SecretKeySpec; -import org.hisp.dhis.external.conf.DhisConfigurationProvider; -import org.hisp.dhis.test.config.H2DhisConfigurationProvider; -import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; -import org.junit.jupiter.api.BeforeEach; -import org.junit.jupiter.api.Test; -import org.springframework.beans.factory.annotation.Autowired; -import org.springframework.context.annotation.Bean; -import org.springframework.context.annotation.Configuration; -import org.springframework.context.annotation.Primary; -import org.springframework.stereotype.Controller; -import org.springframework.test.context.ContextConfiguration; -import org.springframework.test.web.servlet.setup.MockMvcBuilders; -import org.springframework.web.bind.annotation.GetMapping; -import org.springframework.web.bind.annotation.ResponseBody; - -@ContextConfiguration(classes = SessionIdHeaderFilterTest.TestConfig.class) -class SessionIdHeaderFilterTest extends H2ControllerIntegrationTestBase { - private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; - private static final String HEADER_NAME = "X-Session-ID"; - - @Autowired private RequestIdFilter requestIdFilter; - @Autowired private ApiVersionFilter apiVersionFilter; - @Autowired private SessionIdHeaderFilter sessionIdHeaderFilter; - - @BeforeEach - void setupFilters() { - mvc = - MockMvcBuilders.webAppContextSetup(webApplicationContext) - .addFilter(requestIdFilter) - .addFilter(apiVersionFilter) - .addFilter(sessionIdHeaderFilter) - .build(); - } - - @Test - void shouldEncryptUserUidAndSessionHashInHeader() throws Exception { - HttpResponse response = GET("/test/sessionHeader"); - String header = response.header(HEADER_NAME); - assertNotNull(header); - - String payload = decryptHeader(header, ENCRYPTION_KEY); - String expected = getAdminUid() + ":" + hashSessionId(session.getId()); - assertEquals(expected, payload); - } - - private static String decryptHeader(String header, String key) throws GeneralSecurityException { - byte[] combined = Base64.getUrlDecoder().decode(header.substring(3)); - byte[] iv = Arrays.copyOfRange(combined, 0, 12); - byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length); - byte[] keyBytes = Base64.getDecoder().decode(key); - Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); - cipher.init( - Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv)); - return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); - } - - private static String hashSessionId(String sessionId) throws GeneralSecurityException { - MessageDigest digest = MessageDigest.getInstance("SHA-256"); - byte[] hashed = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); - } - - @Configuration - static class TestConfig { - @Bean - @Primary - DhisConfigurationProvider dhisConfigurationProvider() { - H2DhisConfigurationProvider provider = new H2DhisConfigurationProvider(); - Properties properties = new Properties(); - properties.setProperty("logging.session_id_header.enabled", "on"); - properties.setProperty("logging.session_id_encryption_key", ENCRYPTION_KEY); - provider.addProperties(properties); - return provider; - } - } - - @Controller - static class TestSessionHeaderController { - @GetMapping("/api/test/sessionHeader") - @ResponseBody - public String ok() { - return "ok"; - } - } -} diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java index 022bd7530524..b5c6b4f16005 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java @@ -30,6 +30,7 @@ package org.hisp.dhis.webapi.filter; import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_REQUEST_ID_ENABLED; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; @@ -50,10 +51,12 @@ /** * Filter that adds a hashed version of the Session ID to the Mapped Diagnostic Context (MDC) for * authenticated users. This allows correlating multiple requests from the same user session. Access - * via {@code %X{sessionId}} in log4j2 pattern layouts. + * via {@code %X{sessionId}} in log4j2 pattern layouts. Optionally emits the same value as the + * {@code X-Session-ID} response header. * - *

The session ID is hashed using SHA-256 and base64-encoded for security. Only enabled when - * {@code logging.request_id.enabled} is true. + *

The session ID is hashed using SHA-256 and base64-encoded for security. MDC logging is + * controlled by {@code logging.request_id.enabled}. Header emission is controlled by {@code + * logging.session_id_header.enabled}. * * @author Luciano Fiandesio * @see MDC Documentation @@ -71,24 +74,31 @@ public class SessionIdFilter extends OncePerRequestFilter { private static final String IDENTIFIER_PREFIX = "ID"; - private final boolean enabled; + private final boolean mdcEnabled; + private final boolean headerEnabled; public SessionIdFilter(DhisConfigurationProvider dhisConfig) { - this.enabled = dhisConfig.isEnabled(LOGGING_REQUEST_ID_ENABLED); + this.mdcEnabled = dhisConfig.isEnabled(LOGGING_REQUEST_ID_ENABLED); + this.headerEnabled = dhisConfig.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED); } @Override protected void doFilterInternal( HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { - if (enabled) { + if (mdcEnabled || headerEnabled) { try { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated() && !authentication.getPrincipal().equals("anonymousUser")) { - - MDC.put(SESSION_ID_KEY, IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId())); + String value = IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId()); + if (mdcEnabled) { + MDC.put(SESSION_ID_KEY, value); + } + if (headerEnabled) { + res.addHeader("X-Session-ID", value); + } } } catch (NoSuchAlgorithmException e) { log.error(String.format("Invalid Hash algorithm provided (%s)", HASH_ALGO), e); diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java similarity index 76% rename from dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java rename to dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java index d7a866257d5b..6ac0644fc956 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/UserIdFilter.java @@ -29,18 +29,16 @@ */ package org.hisp.dhis.webapi.filter; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_ENCRYPTION_KEY; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_HEADER_ENABLED; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; -import jakarta.servlet.http.HttpSession; import java.io.IOException; import java.nio.charset.StandardCharsets; import java.security.GeneralSecurityException; -import java.security.MessageDigest; import java.security.SecureRandom; import java.util.Base64; import java.util.concurrent.ConcurrentHashMap; @@ -52,26 +50,27 @@ import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.user.CurrentUserUtil; import org.hisp.dhis.user.UserDetails; +import org.slf4j.MDC; import org.springframework.stereotype.Component; import org.springframework.web.filter.OncePerRequestFilter; /** - * Filter that adds an encrypted session/user identifier to the response headers for authenticated - * users. The header value is reversible using the configured key, allowing downstream systems to - * correlate requests to a user session. + * Filter that adds an encrypted user identifier to the response headers for authenticated users. + * The header value is reversible using the configured key, allowing downstream systems to correlate + * requests to a user. * - *

Enabled when {@code logging.session_id_header.enabled} is true and {@code - * logging.session_id_encryption_key} is set. + *

Enabled when {@code logging.user_id_header.enabled} is true and {@code + * logging.user_id_encryption_key} is set. */ @Slf4j -@Component("sessionIdHeaderFilter") -public class SessionIdHeaderFilter extends OncePerRequestFilter { - private static final String HEADER_NAME = "X-Session-ID"; +@Component("userIdHeaderFilter") +public class UserIdFilter extends OncePerRequestFilter { + private static final String HEADER_NAME = "X-User-ID"; + private static final String MDC_KEY = "userUid"; private static final String HEADER_VERSION_PREFIX = "v1."; private static final int GCM_IV_LENGTH_BYTES = 12; private static final int GCM_TAG_LENGTH_BITS = 128; private static final String CIPHER_ALGO = "AES/GCM/NoPadding"; - private static final String HASH_ALGO = "SHA-256"; private static final int KEY_LENGTH_BYTES = 32; private static final long CACHE_TTL_MILLIS = TimeUnit.MINUTES.toMillis(5); private static final int MAX_CACHE_SIZE = 10_000; @@ -82,9 +81,9 @@ public class SessionIdHeaderFilter extends OncePerRequestFilter { private final byte[] keyBytes; private final SecureRandom secureRandom = new SecureRandom(); - public SessionIdHeaderFilter(DhisConfigurationProvider dhisConfig) { - boolean configEnabled = dhisConfig.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED); - String token = dhisConfig.getProperty(LOGGING_SESSION_ID_ENCRYPTION_KEY); + public UserIdFilter(DhisConfigurationProvider dhisConfig) { + boolean configEnabled = dhisConfig.isEnabled(LOGGING_USER_ID_HEADER_ENABLED); + String token = dhisConfig.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY); if (configEnabled && (token == null || token.isBlank())) { log.warn( "Session ID header logging enabled, but logging.session_id_encryption_key is not set."); @@ -94,7 +93,7 @@ public SessionIdHeaderFilter(DhisConfigurationProvider dhisConfig) { byte[] decodedKey = token == null ? new byte[0] : decodeKey(token); if (decodedKey.length != KEY_LENGTH_BYTES) { log.warn( - "Session ID header logging enabled, but logging.session_id_encryption_key must be a base64-encoded 32-byte key."); + "User ID header logging enabled, but logging.user_id_encryption_key must be a base64-encoded 32-byte key."); this.enabled = false; this.keyBytes = new byte[0]; } else { @@ -108,21 +107,25 @@ public SessionIdHeaderFilter(DhisConfigurationProvider dhisConfig) { protected void doFilterInternal( HttpServletRequest request, HttpServletResponse response, FilterChain chain) throws ServletException, IOException { + String headerValue = null; if (enabled && CurrentUserUtil.hasCurrentUser()) { try { UserDetails userDetails = CurrentUserUtil.getCurrentUserDetails(); - HttpSession session = request.getSession(false); - if (session != null) { - String sessionHash = hashSessionId(session.getId()); - String cacheKey = userDetails.getUid() + ":" + sessionHash; - String headerValue = getOrCreateHeaderValue(cacheKey); - response.addHeader(HEADER_NAME, headerValue); - } + headerValue = getOrCreateHeaderValue(userDetails.getUid()); + response.addHeader(HEADER_NAME, headerValue); + MDC.put(MDC_KEY, headerValue); } catch (GeneralSecurityException ex) { - log.error("Failed to encrypt session header payload", ex); + log.error("Failed to encrypt user header payload", ex); + } + } + + try { + chain.doFilter(request, response); + } finally { + if (headerValue != null) { + MDC.remove(MDC_KEY); } } - chain.doFilter(request, response); } private String encrypt(String payload) throws GeneralSecurityException { @@ -171,11 +174,5 @@ private static byte[] decodeKey(String token) { } } - private static String hashSessionId(String sessionId) throws GeneralSecurityException { - MessageDigest digest = MessageDigest.getInstance(HASH_ALGO); - byte[] hashed = digest.digest(sessionId.getBytes(StandardCharsets.UTF_8)); - return Base64.getUrlEncoder().withoutPadding().encodeToString(hashed); - } - private record CacheEntry(String headerValue, long expiresAtMillis) {} } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index 0963c0bebeea..389907e53bc5 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -160,7 +160,7 @@ public static void setupServlets( .addMappingForUrlPatterns(null, true, "/*"); context - .addFilter("sessionIdHeaderFilter", new DelegatingFilterProxy("sessionIdHeaderFilter")) + .addFilter("userIdHeaderFilter", new DelegatingFilterProxy("userIdHeaderFilter")) .addMappingForUrlPatterns(null, true, "/*"); /* Intercept index.html, plugin.html, and other html requests to inject no-cache diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java deleted file mode 100644 index 767b6abf6f8f..000000000000 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdHeaderFilterUnitTest.java +++ /dev/null @@ -1,129 +0,0 @@ -/* - * Copyright (c) 2004-2025, University of Oslo - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * 1. Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * - * 2. Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * 3. Neither the name of the copyright holder nor the names of its contributors - * may be used to endorse or promote products derived from this software without - * specific prior written permission. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND - * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED - * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE - * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR - * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES - * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; - * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON - * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT - * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS - * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - */ -package org.hisp.dhis.webapi.filter; - -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_ENCRYPTION_KEY; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; -import static org.mockito.ArgumentMatchers.anyString; -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; - -import jakarta.servlet.FilterChain; -import jakarta.servlet.http.HttpServletRequest; -import jakarta.servlet.http.HttpServletResponse; -import java.util.List; -import org.hisp.dhis.external.conf.DhisConfigurationProvider; -import org.hisp.dhis.user.UserDetails; -import org.junit.jupiter.api.AfterEach; -import org.junit.jupiter.api.Test; -import org.junit.jupiter.api.extension.ExtendWith; -import org.mockito.Mock; -import org.mockito.junit.jupiter.MockitoExtension; -import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.GrantedAuthority; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.core.context.SecurityContextHolder; - -@ExtendWith(MockitoExtension.class) -class SessionIdHeaderFilterUnitTest { - private static final String HEADER_NAME = "X-Session-ID"; - private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; - - @Mock private DhisConfigurationProvider dhisConfigurationProvider; - - @AfterEach - void tearDown() { - SecurityContextHolder.clearContext(); - } - - @Test - void shouldNotAddHeaderWhenDisabled() throws Exception { - SessionIdHeaderFilter filter = init(false); - withAuthenticatedUser(); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpServletResponse res = mock(HttpServletResponse.class); - FilterChain chain = mock(FilterChain.class); - - filter.doFilter(req, res, chain); - - verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); - } - - @Test - void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { - SessionIdHeaderFilter filter = init(true); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpServletResponse res = mock(HttpServletResponse.class); - FilterChain chain = mock(FilterChain.class); - - filter.doFilter(req, res, chain); - - verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); - } - - @Test - void shouldNotAddHeaderWhenSessionMissing() throws Exception { - SessionIdHeaderFilter filter = init(true); - withAuthenticatedUser(); - - HttpServletRequest req = mock(HttpServletRequest.class); - HttpServletResponse res = mock(HttpServletResponse.class); - FilterChain chain = mock(FilterChain.class); - when(req.getSession(false)).thenReturn(null); - - filter.doFilter(req, res, chain); - - verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); - } - - private SessionIdHeaderFilter init(boolean enabled) { - when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED)) - .thenReturn(enabled); - when(dhisConfigurationProvider.getProperty(LOGGING_SESSION_ID_ENCRYPTION_KEY)) - .thenReturn(ENCRYPTION_KEY); - return new SessionIdHeaderFilter(dhisConfigurationProvider); - } - - private void withAuthenticatedUser() { - UserDetails userDetails = mock(UserDetails.class); - Authentication authentication = - new UsernamePasswordAuthenticationToken( - userDetails, "pw", List.of((GrantedAuthority) () -> "ALL")); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); - SecurityContextHolder.setContext(context); - } -} From 87dd2cd056312066577feb56368e05c22185d398 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Tue, 6 Jan 2026 08:42:54 +0100 Subject: [PATCH 08/14] Add missing files --- .../webapi/filter/UserIdHeaderFilterTest.java | 120 ++++++++++++++++++ .../filter/UserIdHeaderFilterUnitTest.java | 114 +++++++++++++++++ 2 files changed, 234 insertions(+) create mode 100644 dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java create mode 100644 dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java new file mode 100644 index 000000000000..7930202077b9 --- /dev/null +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterTest.java @@ -0,0 +1,120 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.filter; + +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertNotNull; + +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Arrays; +import java.util.Base64; +import java.util.Properties; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.test.config.H2DhisConfigurationProvider; +import org.hisp.dhis.test.webapi.H2ControllerIntegrationTestBase; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.stereotype.Controller; +import org.springframework.test.context.ContextConfiguration; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ResponseBody; + +@ContextConfiguration(classes = UserIdHeaderFilterTest.TestConfig.class) +class UserIdHeaderFilterTest extends H2ControllerIntegrationTestBase { + private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + private static final String HEADER_NAME = "X-User-ID"; + + @Autowired private RequestIdFilter requestIdFilter; + @Autowired private ApiVersionFilter apiVersionFilter; + @Autowired private UserIdFilter userIdHeaderFilter; + + @BeforeEach + void setupFilters() { + mvc = + MockMvcBuilders.webAppContextSetup(webApplicationContext) + .addFilter(requestIdFilter) + .addFilter(apiVersionFilter) + .addFilter(userIdHeaderFilter) + .build(); + } + + @Test + void shouldEncryptUserUidInHeader() throws Exception { + HttpResponse response = GET("/test/userHeader"); + String header = response.header(HEADER_NAME); + assertNotNull(header); + + String payload = decryptHeader(header, ENCRYPTION_KEY); + assertEquals(getAdminUid(), payload); + } + + private static String decryptHeader(String header, String key) throws GeneralSecurityException { + byte[] combined = Base64.getUrlDecoder().decode(header.substring(3)); + byte[] iv = Arrays.copyOfRange(combined, 0, 12); + byte[] cipherText = Arrays.copyOfRange(combined, 12, combined.length); + byte[] keyBytes = Base64.getDecoder().decode(key); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init( + Cipher.DECRYPT_MODE, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(128, iv)); + return new String(cipher.doFinal(cipherText), StandardCharsets.UTF_8); + } + + @Configuration + static class TestConfig { + @Bean + @Primary + DhisConfigurationProvider dhisConfigurationProvider() { + H2DhisConfigurationProvider provider = new H2DhisConfigurationProvider(); + Properties properties = new Properties(); + properties.setProperty("logging.user_id_header.enabled", "on"); + properties.setProperty("logging.user_id_encryption_key", ENCRYPTION_KEY); + provider.addProperties(properties); + return provider; + } + } + + @Controller + static class TestUserHeaderController { + @GetMapping("/api/test/userHeader") + @ResponseBody + public String ok() { + return "ok"; + } + } +} diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java new file mode 100644 index 000000000000..745443b40a1d --- /dev/null +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java @@ -0,0 +1,114 @@ +/* + * Copyright (c) 2004-2025, University of Oslo + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * 3. Neither the name of the copyright holder nor the names of its contributors + * may be used to endorse or promote products derived from this software without + * specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE LIABLE FOR + * ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package org.hisp.dhis.webapi.filter; + +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_ENCRYPTION_KEY; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_USER_ID_HEADER_ENABLED; +import static org.mockito.ArgumentMatchers.anyString; +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; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.util.List; +import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.hisp.dhis.user.UserDetails; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; + +@ExtendWith(MockitoExtension.class) +class UserIdHeaderFilterUnitTest { + private static final String HEADER_NAME = "X-User-ID"; + private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + + @Mock private DhisConfigurationProvider dhisConfigurationProvider; + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); + } + + @Test + void shouldNotAddHeaderWhenDisabled() throws Exception { + UserIdFilter filter = init(false); + withAuthenticatedUser(); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { + UserIdFilter filter = init(true); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); + } + + @Test + private UserIdFilter init(boolean enabled) { + when(dhisConfigurationProvider.isEnabled(LOGGING_USER_ID_HEADER_ENABLED)).thenReturn(enabled); + when(dhisConfigurationProvider.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY)) + .thenReturn(ENCRYPTION_KEY); + return new UserIdFilter(dhisConfigurationProvider); + } + + private void withAuthenticatedUser() { + UserDetails userDetails = mock(UserDetails.class); + Authentication authentication = + new UsernamePasswordAuthenticationToken( + userDetails, "pw", List.of((GrantedAuthority) () -> "ALL")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); + } +} From e93b44c744136e36f68c47ee2f97ecfd32fc26dd Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Tue, 6 Jan 2026 11:44:57 +0100 Subject: [PATCH 09/14] Fix test --- .../org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java | 1 - 1 file changed, 1 deletion(-) diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java index 745443b40a1d..d3b92fd5a751 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java @@ -94,7 +94,6 @@ void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); } - @Test private UserIdFilter init(boolean enabled) { when(dhisConfigurationProvider.isEnabled(LOGGING_USER_ID_HEADER_ENABLED)).thenReturn(enabled); when(dhisConfigurationProvider.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY)) From 042eca6f1cb2ecfb0433b66d5e7688d64181c9fb Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 12 Jan 2026 09:22:19 +0100 Subject: [PATCH 10/14] Fix test --- .../filter/UserIdHeaderFilterUnitTest.java | 50 ++++++++++++++++++- 1 file changed, 48 insertions(+), 2 deletions(-) diff --git a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java index d3b92fd5a751..33a01cc14710 100644 --- a/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java +++ b/dhis-2/dhis-web-api/src/test/java/org/hisp/dhis/webapi/filter/UserIdHeaderFilterUnitTest.java @@ -41,12 +41,19 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.util.Base64; import java.util.List; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; import org.hisp.dhis.external.conf.DhisConfigurationProvider; import org.hisp.dhis.user.UserDetails; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; @@ -59,6 +66,9 @@ class UserIdHeaderFilterUnitTest { private static final String HEADER_NAME = "X-User-ID"; private static final String ENCRYPTION_KEY = "MDEyMzQ1Njc4OWFiY2RlZjAxMjM0NTY3ODlhYmNkZWY="; + private static final String HEADER_VERSION_PREFIX = "v1."; + private static final int GCM_IV_LENGTH_BYTES = 12; + private static final int GCM_TAG_LENGTH_BITS = 128; @Mock private DhisConfigurationProvider dhisConfigurationProvider; @@ -84,7 +94,7 @@ void shouldNotAddHeaderWhenDisabled() throws Exception { @Test void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { UserIdFilter filter = init(true); - + SecurityContextHolder.clearContext(); HttpServletRequest req = mock(HttpServletRequest.class); HttpServletResponse res = mock(HttpServletResponse.class); FilterChain chain = mock(FilterChain.class); @@ -94,6 +104,27 @@ void shouldNotAddHeaderWhenNoAuthenticatedUser() throws Exception { verify(res, never()).addHeader(eq(HEADER_NAME), anyString()); } + @Test + void shouldAddHeaderWhenAuthenticatedUser() throws Exception { + + String userUID = "uid123"; + UserIdFilter filter = init(true); + UserDetails userDetails = withAuthenticatedUser(); + when(userDetails.getUid()).thenReturn(userUID); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + + filter.doFilter(req, res, chain); + + ArgumentCaptor headerValueCaptor = ArgumentCaptor.forClass(String.class); + verify(res).addHeader(eq(HEADER_NAME), headerValueCaptor.capture()); + String headerValue = headerValueCaptor.getValue(); + org.junit.jupiter.api.Assertions.assertTrue(headerValue.startsWith(HEADER_VERSION_PREFIX)); + org.junit.jupiter.api.Assertions.assertEquals(userUID, decryptUserIDHeaderValue(headerValue)); + } + private UserIdFilter init(boolean enabled) { when(dhisConfigurationProvider.isEnabled(LOGGING_USER_ID_HEADER_ENABLED)).thenReturn(enabled); when(dhisConfigurationProvider.getProperty(LOGGING_USER_ID_ENCRYPTION_KEY)) @@ -101,7 +132,7 @@ private UserIdFilter init(boolean enabled) { return new UserIdFilter(dhisConfigurationProvider); } - private void withAuthenticatedUser() { + private UserDetails withAuthenticatedUser() { UserDetails userDetails = mock(UserDetails.class); Authentication authentication = new UsernamePasswordAuthenticationToken( @@ -109,5 +140,20 @@ private void withAuthenticatedUser() { SecurityContext context = SecurityContextHolder.createEmptyContext(); context.setAuthentication(authentication); SecurityContextHolder.setContext(context); + return userDetails; + } + + private String decryptUserIDHeaderValue(String headerValue) throws GeneralSecurityException { + String token = headerValue.substring(HEADER_VERSION_PREFIX.length()); + byte[] combined = Base64.getUrlDecoder().decode(token); + byte[] iv = new byte[GCM_IV_LENGTH_BYTES]; + byte[] ciphertext = new byte[combined.length - GCM_IV_LENGTH_BYTES]; + System.arraycopy(combined, 0, iv, 0, iv.length); + System.arraycopy(combined, iv.length, ciphertext, 0, ciphertext.length); + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + SecretKeySpec key = new SecretKeySpec(Base64.getDecoder().decode(ENCRYPTION_KEY), "AES"); + cipher.init(Cipher.DECRYPT_MODE, key, new GCMParameterSpec(GCM_TAG_LENGTH_BITS, iv)); + byte[] plaintext = cipher.doFinal(ciphertext); + return new String(plaintext, StandardCharsets.UTF_8); } } From 9845d9756e9b373abfd20c0336f9f26487af6e65 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 11 May 2026 07:28:37 +0200 Subject: [PATCH 11/14] Address reviewer comments on session-id-header PR - SessionIdFilter now always populates MDC for authenticated requests; logging output is controlled via log4j2 pattern layout, not dhis.conf. Reviewer: 'Logging should not need a config in the dhis.conf IMHO.' - X-Session-ID header emission remains opt-in via logging.session_id_header.enabled (reverse proxy boundary feature). - Remove the now-unused logging.session_id ConfigurationKey. - Fix broken merge state: restore missing excludableShallowEtagHeaderFilter registration in DhisWebApiWebAppInitializer. - Update SessionIdFilterTest to capture MDC during filter chain execution (MDC is correctly cleared in the finally block). Co-Authored-By: Claude Sonnet 4.6 --- .../dhis/external/conf/ConfigurationKey.java | 6 -- .../webapi/filter/SessionIdFilterTest.java | 101 +++++++++++++----- .../dhis/webapi/filter/SessionIdFilter.java | 39 ++++--- .../servlet/DhisWebApiWebAppInitializer.java | 7 ++ 4 files changed, 105 insertions(+), 48 deletions(-) diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index 94948f10f836..a5f902697b7b 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -526,12 +526,6 @@ public enum ConfigurationKey { /** Number of log file archives to keep around. Does not affect audit logs. (default: 1). */ LOGGING_FILE_MAX_ARCHIVES("logging.file.max_archives", "1"), - /** - * Adds a hashed (SHA-256) session ID to the MDC ({@code sessionId} key). Include it in log output - * via {@code %X{sessionId}} in the log4j2 pattern layout. - */ - LOGGING_SESSION_ID("logging.session_id", Constants.ON, false), - /** * Enables SQL query logging via a datasource proxy. All {@code logging.query.*} keys below * require this to be enabled. diff --git a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java index 08a7bcd257df..beedf70292a2 100644 --- a/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java +++ b/dhis-2/dhis-test-web-api/src/test/java/org/hisp/dhis/webapi/filter/SessionIdFilterTest.java @@ -29,12 +29,14 @@ */ package org.hisp.dhis.webapi.filter; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; import static org.hisp.dhis.log.MdcKeys.MDC_SESSION_ID; import static org.hisp.dhis.webapi.filter.SessionIdFilter.hashToBase64; import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNull; import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; import jakarta.servlet.FilterChain; @@ -42,8 +44,8 @@ import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import java.util.List; -import java.util.function.Consumer; import org.hisp.dhis.external.conf.DhisConfigurationProvider; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; @@ -68,50 +70,97 @@ class SessionIdFilterTest { @BeforeEach void setUp() { MDC.clear(); + SecurityContextHolder.clearContext(); + } + + @AfterEach + void tearDown() { + SecurityContextHolder.clearContext(); } @Test - void testIsDisabled() throws Exception { + void testMdcNotSetWhenUnauthenticated() throws Exception { init(false); - doFilter(request -> {}); + String[] capturedMdc = {null}; + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = (r, p) -> capturedMdc[0] = MDC.get(MDC_SESSION_ID); + + subject.doFilter(req, res, chain); + assertNull(capturedMdc[0]); assertNull(MDC.get(MDC_SESSION_ID)); } @Test - void testIsEnabled() throws Exception { + void testMdcSetWhenAuthenticated() throws Exception { + withAuthenticatedUser(); + init(false); - Authentication authentication = - new UsernamePasswordAuthenticationToken( - "admin", "admin", List.of((GrantedAuthority) () -> "ALL")); - SecurityContext context = SecurityContextHolder.createEmptyContext(); - context.setAuthentication(authentication); + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession()).thenReturn(session); + when(session.getId()).thenReturn("ABCDEFGHILMNO"); - SecurityContextHolder.setContext(context); + String[] capturedMdc = {null}; + FilterChain chain = (r, p) -> capturedMdc[0] = MDC.get(MDC_SESSION_ID); - init(true); - doFilter( - request -> { - HttpSession session = mock(HttpSession.class); - when(request.getSession()).thenReturn(session); - when(session.getId()).thenReturn("ABCDEFGHILMNO"); - }); - - assertEquals("ID" + hashToBase64("ABCDEFGHILMNO"), MDC.get(MDC_SESSION_ID)); + subject.doFilter(req, res, chain); + + assertEquals("ID" + hashToBase64("ABCDEFGHILMNO"), capturedMdc[0]); + assertNull(MDC.get(MDC_SESSION_ID)); } - private void doFilter(Consumer withRequest) throws Exception { + @Test + void testHeaderNotAddedWhenDisabled() throws Exception { + withAuthenticatedUser(); + init(false); + + HttpServletRequest req = mock(HttpServletRequest.class); + HttpServletResponse res = mock(HttpServletResponse.class); + FilterChain chain = mock(FilterChain.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession()).thenReturn(session); + when(session.getId()).thenReturn("ABCDEFGHILMNO"); + + subject.doFilter(req, res, chain); + + verify(res, never()) + .addHeader( + org.mockito.ArgumentMatchers.eq("X-Session-ID"), + org.mockito.ArgumentMatchers.anyString()); + } + + @Test + void testHeaderAddedWhenEnabled() throws Exception { + withAuthenticatedUser(); + init(true); + HttpServletRequest req = mock(HttpServletRequest.class); HttpServletResponse res = mock(HttpServletResponse.class); - FilterChain filterChain = mock(FilterChain.class); + FilterChain chain = mock(FilterChain.class); + HttpSession session = mock(HttpSession.class); + when(req.getSession()).thenReturn(session); + when(session.getId()).thenReturn("ABCDEFGHILMNO"); + + subject.doFilter(req, res, chain); - withRequest.accept(req); + verify(res).addHeader("X-Session-ID", "ID" + hashToBase64("ABCDEFGHILMNO")); + } - subject.doFilter(req, res, filterChain); + private void withAuthenticatedUser() { + Authentication authentication = + new UsernamePasswordAuthenticationToken( + "admin", "admin", List.of((GrantedAuthority) () -> "ALL")); + SecurityContext context = SecurityContextHolder.createEmptyContext(); + context.setAuthentication(authentication); + SecurityContextHolder.setContext(context); } - private void init(boolean enabled) { - when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID)).thenReturn(enabled); + private void init(boolean headerEnabled) { + when(dhisConfigurationProvider.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED)) + .thenReturn(headerEnabled); subject = new SessionIdFilter(dhisConfigurationProvider); } } diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java index 9d5d94838c8e..517b346d11ff 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/filter/SessionIdFilter.java @@ -29,7 +29,7 @@ */ package org.hisp.dhis.webapi.filter; -import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID; +import static org.hisp.dhis.external.conf.ConfigurationKey.LOGGING_SESSION_ID_HEADER_ENABLED; import static org.hisp.dhis.log.MdcKeys.MDC_SESSION_ID; import jakarta.servlet.FilterChain; @@ -52,10 +52,11 @@ * Filter that adds a hashed version of the Session ID to the Mapped Diagnostic Context (MDC) for * authenticated users. This allows correlating multiple requests from the same user session. Access * via {@code %X{sessionId}} in log4j2 pattern layouts. Optionally emits the same value as the - * {@code X-Session-ID} response header. + * {@code X-Session-ID} response header for use by reverse proxies. * - *

The session ID is hashed using SHA-256 and base64-encoded for security. Only enabled when - * {@code logging.session_id} is true. + *

The session ID is hashed using SHA-256 and base64-encoded for security. MDC is always + * populated for authenticated requests; configure the log4j2 pattern layout to include or exclude + * it. Header emission is controlled by {@code logging.session_id_header.enabled} in dhis.conf. * * @author Luciano Fiandesio * @see MDC Documentation @@ -66,37 +67,43 @@ @Slf4j @Component public class SessionIdFilter extends OncePerRequestFilter { - /** The hash algorithm to use. */ private static final String HASH_ALGO = "SHA-256"; - private static final String IDENTIFIER_PREFIX = "ID"; + private static final String X_SESSION_ID_HEADER = "X-Session-ID"; - private final boolean mdcEnabled; private final boolean headerEnabled; public SessionIdFilter(DhisConfigurationProvider dhisConfig) { - this.enabled = dhisConfig.isEnabled(LOGGING_SESSION_ID); + this.headerEnabled = dhisConfig.isEnabled(LOGGING_SESSION_ID_HEADER_ENABLED); } @Override protected void doFilterInternal( HttpServletRequest req, HttpServletResponse res, FilterChain chain) throws ServletException, IOException { - if (mdcEnabled || headerEnabled) { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + String sessionValue = null; + if (authentication != null + && authentication.isAuthenticated() + && !authentication.getPrincipal().equals("anonymousUser")) { try { - Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null - && authentication.isAuthenticated() - && !authentication.getPrincipal().equals("anonymousUser")) { - - MDC.put(MDC_SESSION_ID, IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId())); + sessionValue = IDENTIFIER_PREFIX + hashToBase64(req.getSession().getId()); + MDC.put(MDC_SESSION_ID, sessionValue); + if (headerEnabled) { + res.addHeader(X_SESSION_ID_HEADER, sessionValue); } } catch (NoSuchAlgorithmException e) { log.error(String.format("Invalid Hash algorithm provided (%s)", HASH_ALGO), e); } } - chain.doFilter(req, res); + try { + chain.doFilter(req, res); + } finally { + if (sessionValue != null) { + MDC.remove(MDC_SESSION_ID); + } + } } static String hashToBase64(String sessionId) throws NoSuchAlgorithmException { diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index 389907e53bc5..a184037aaabb 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -151,6 +151,13 @@ public static void setupServlets( "springSecurityFilterChain", new DelegatingFilterProxy("springSecurityFilterChain")) .addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), false, "/*"); + FilterRegistration.Dynamic etagFilter = + context.addFilter( + "excludableShallowEtagHeaderFilter", + new DelegatingFilterProxy("excludableShallowEtagHeaderFilter")); + etagFilter.setAsyncSupported(true); + etagFilter.addMappingForUrlPatterns(null, true, "/api/*"); + context .addFilter("ApiVersionFilter", new DelegatingFilterProxy("apiVersionFilter")) .addMappingForUrlPatterns(EnumSet.allOf(DispatcherType.class), true, "/api/*"); From 6dfc6227c8abb05b6cbed91bb52819477127449d Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 11 May 2026 07:46:26 +0200 Subject: [PATCH 12/14] Linting --- .../main/java/org/hisp/dhis/external/conf/ConfigurationKey.java | 1 - .../hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java | 1 - 2 files changed, 2 deletions(-) diff --git a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java index 7da49342cb88..66a108a0b000 100644 --- a/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java +++ b/dhis-2/dhis-support/dhis-support-external/src/main/java/org/hisp/dhis/external/conf/ConfigurationKey.java @@ -545,7 +545,6 @@ public enum ConfigurationKey { /** Base64-encoded 32-byte encryption key for X-User-ID header value (sensitive). */ LOGGING_USER_ID_ENCRYPTION_KEY("logging.user_id_encryption_key", "", true), - /** Base URL to the DHIS 2 instance. */ SERVER_BASE_URL("server.base.url", "", false), diff --git a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java index 0d263b7263b3..74c8d8a38c02 100644 --- a/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java +++ b/dhis-2/dhis-web-api/src/main/java/org/hisp/dhis/webapi/servlet/DhisWebApiWebAppInitializer.java @@ -170,7 +170,6 @@ public static void setupServlets( .addFilter("userIdHeaderFilter", new DelegatingFilterProxy("userIdHeaderFilter")) .addMappingForUrlPatterns(null, true, "/*"); - context .addFilter("GlobalShellFilter", new DelegatingFilterProxy("globalShellFilter")) .addMappingForUrlPatterns(null, true, "/*"); From 8e2d0c388d0dbaf76a886eea28ce586ae22bc097 Mon Sep 17 00:00:00 2001 From: Jason Pickering Date: Mon, 11 May 2026 10:59:16 +0200 Subject: [PATCH 13/14] More noise suppression --- .../src/main/resources/log4j2-test.xml | 4 ++++ ...his2OAuth2AuthorizationServiceIntegrationTest.java | 6 ------ .../src/test/resources/log4j2-test.xml | 4 ++++ .../src/test/resources/log4j2-test.xml | 11 +++++++++++ 4 files changed, 19 insertions(+), 6 deletions(-) diff --git a/dhis-2/dhis-support/dhis-support-test/src/main/resources/log4j2-test.xml b/dhis-2/dhis-support/dhis-support-test/src/main/resources/log4j2-test.xml index 9dd20813d726..a0f9eba39417 100644 --- a/dhis-2/dhis-support/dhis-support-test/src/main/resources/log4j2-test.xml +++ b/dhis-2/dhis-support/dhis-support-test/src/main/resources/log4j2-test.xml @@ -27,6 +27,10 @@ + + + + + + + + + + + + + - - - - - - - - - - - -