This is an automated email from the ASF dual-hosted git repository.
ashishvijaywargiya pushed a commit to branch trunk
in repository https://gitbox.apache.org/repos/asf/ofbiz-framework.git
The following commit(s) were added to refs/heads/trunk by this push:
new 28c3a4356c ECS/JSON structured logging for Apache OFBiz. (#1443)
28c3a4356c is described below
commit 28c3a4356c2b34197b29ddb26e63c8b385362cca
Author: Ashish Vijaywargiya <[email protected]>
AuthorDate: Fri Aug 7 18:21:45 2026 +0530
ECS/JSON structured logging for Apache OFBiz. (#1443)
Adding the opt-in support of ECS/JSON structured logging for Apache
OFBiz.
Grafana-Loki-Promtail
Elasticsearch-Kibana-Filebeat
OpenSearch-OpenSearchDashboards-FluentBit
======================
1) Added log.correlation.userLoginId.mode property (plain/hash/off) in
debug.properties controlling whether userLoginId is written raw, as a PBKDF2
digest, or omitted entirely from the structured JSON log correlation context,
since userLoginId is often PII such as an email address.
2) Added log.correlation.userLoginId.hash.pepper and
log.correlation.userLoginId.hash.keyLengthBits properties, required only when
mode=hash, providing the deployment-specific PBKDF2 salt and digest length used
when hashing.
3) Updated CorrelationValve to resolve and validate these properties at
construction time, failing fast with an IllegalStateException at startup if
mode=hash is set without a pepper or with a non-positive key length, instead of
failing later on the first login attempt.
4) Implemented hashUserLoginId() in CorrelationValve using
PBKDF2WithHmacSHA256 with the same iteration count OFBiz already uses for
password storage (security.properties password.encrypt.pbkdf2.iterations),
producing a deterministic, non-reversible digest so cross-request correlation
still works without exposing the raw userLoginId in logs.
5) Added CorrelationValveTests covering all three modes (plain/hash/off),
digest determinism and length, distinct users producing distinct digests,
invalid mode falling back to plain, and fail-fast construction errors for a
missing pepper or an invalid key length.
======================
---
.github/workflows/scorecard.yml | 2 +-
build.gradle | 8 +
dependencies.gradle | 1 +
framework/base/config/debug.properties | 58 ++++-
framework/base/config/log4j2.xml | 39 ++++
framework/base/config/templates/ecs-layout.json | 49 +++++
.../catalina/container/CatalinaContainer.java | 3 +
.../container/CorrelationFieldProvider.java | 48 ++++
.../ofbiz/catalina/container/CorrelationValve.java | 242 +++++++++++++++++++++
.../catalina/container/CorrelationValveTests.java | 204 +++++++++++++++++
10 files changed, 652 insertions(+), 2 deletions(-)
diff --git a/.github/workflows/scorecard.yml b/.github/workflows/scorecard.yml
index 52dd7aa4df..6184b0f6a4 100644
--- a/.github/workflows/scorecard.yml
+++ b/.github/workflows/scorecard.yml
@@ -103,6 +103,6 @@ jobs:
# Upload the results to GitHub's code scanning dashboard.
- name: "Upload to code-scanning"
if: ${{ !env.ACT }}
- uses:
github/codeql-action/upload-sarif@e58424170fb0262c8d7ed60a2e84b9bffe205c67 #
v2.16.2
+ uses:
github/codeql-action/upload-sarif@29c712e0b7e56a70523cddd70c2cb49d3e3eb717 #
v2.16.2
with:
sarif_file: results.sarif
diff --git a/build.gradle b/build.gradle
index 8a80411f6f..98e6e72b8d 100644
--- a/build.gradle
+++ b/build.gradle
@@ -119,6 +119,12 @@ ext.os = System.getProperty('os.name').toLowerCase()
ext.gradlew = os.contains('windows') ? 'gradlew.bat' : './gradlew'
ext.pluginsDir = "${rootDir}/plugins"
+// "./gradlew ofbiz" command reflects them directly, with no separate
command-line flag to remember.
+def debugProperties = new Properties()
+file('framework/base/config/debug.properties').withInputStream {
debugProperties.load(it) }
+ext.jsonLogsEnabled = debugProperties.getProperty('json.logs.enabled',
'false').toBoolean()
+ext.jsonLogsTemplate = debugProperties.getProperty('json.logs.template',
'classpath:templates/ecs-layout.json')
+
application {
mainClass = 'org.apache.ofbiz.base.start.Start'
applicationDefaultJvmArgs = project.hasProperty('jvmArgs')
@@ -133,6 +139,8 @@ application {
'--add-opens=java.rmi/sun.rmi.transport=ALL-UNNAMED',
// Allow libraries using the stable Foreign Function & Memory API
(Apache SSHD, Tika)
'--enable-native-access=ALL-UNNAMED',
+ "-Dofbiz.json.logs=${jsonLogsEnabled}",
+ "-Dofbiz.json.logs.template=${jsonLogsTemplate}",
]
}
diff --git a/dependencies.gradle b/dependencies.gradle
index f4b3880aaf..3c01a889de 100644
--- a/dependencies.gradle
+++ b/dependencies.gradle
@@ -51,6 +51,7 @@ dependencies {
implementation 'org.apache.httpcomponents:httpclient-cache:4.5.14'
implementation 'org.apache.logging.log4j:log4j-api:2.25.4' // the API of
log4j 2
implementation 'org.apache.logging.log4j:log4j-core:2.25.4' // Somehow
needed by Buildbot to compile OFBizDynamicThresholdFilter.java
+ implementation
'org.apache.logging.log4j:log4j-layout-template-json:2.25.4' // JSON/ECS
structured logging profile (log4j2-json.xml)
implementation 'org.apache.poi:poi:5.5.1'
implementation 'org.apache.pdfbox:pdfbox:3.0.7'
implementation 'org.apache.pdfbox:pdfbox-io:3.0.7'
diff --git a/framework/base/config/debug.properties
b/framework/base/config/debug.properties
index 412c424d13..88fb5f0554 100644
--- a/framework/base/config/debug.properties
+++ b/framework/base/config/debug.properties
@@ -35,4 +35,60 @@ print.fatal=true
## File to display on webtools https://localhost:8443/webtools/control/LogView
# log4j.appender.css.defaultFile=ofbiz.log
## RegExp use to filter file available on dropdown selection to diplay log on
https://localhost:8443/webtools/control/FetchLog
-# log4j.appender.css.fileNameRegExp=[(ofbiz)|(error)].*
\ No newline at end of file
+# log4j.appender.css.fileNameRegExp=[(ofbiz)|(error)].*
+
+## Global switch for structured (ECS JSON) logging - default false, zero extra
cost when disabled.
+## Set to true to have the default "./gradlew ofbiz" command write
runtime/logs/ofbiz-json.log
+## alongside the normal text logs; no extra command-line flag needed.
+json.logs.enabled=false
+
+## Which JSON event template to use when structured logging is enabled above -
a classpath
+## location, defaulting to the bundled ECS template. Point this at a template
shipped in your own
+## custom component to switch formats without touching framework code or
passing any JVM flag -
+## picked up automatically by the default "./gradlew ofbiz" command, same as
json.logs.enabled
+## above.
+##
+## To define your own template in a component under plugins/, e.g.
plugins/your-component/:
+## 1. plugins/your-component/ofbiz-component.xml needs <classpath type="dir"
location="config"/>
+## 2. put the template at
plugins/your-component/config/templates/your-template.json
+## 3. point this property at it:
json.logs.template=classpath:templates/your-template.json
+## Note: classpath: resolution isn't scoped per-component - it finds the first
matching resource
+## across the entire combined classpath, so a filename collision with
ecs-layout.json or another
+## component's template is ambiguous/classpath-order-dependent. Use a
distinctive name (e.g.
+## templates/mycompany-splunk-template.json, not
templates/splunk-template.json) to avoid that.
+json.logs.template=classpath:templates/ecs-layout.json
+
+## How userLoginId is written into the log correlation context (see
CorrelationValve in
+## framework/catalina). userLoginId is frequently PII (often an email
address), and today it is
+## the only correlation field written into ofbiz-json.log's "labels" - the
plain-text logs never
+## included it, so this setting only affects structured JSON logging.
+## plain (default) - current behavior, raw userLoginId.
+## hash - deterministic PBKDF2 digest keyed with
log.correlation.userLoginId.hash.pepper below;
+## the same user always yields the same value, so correlation across
requests still
+## works, but the raw userLoginId isn't in the logs. Still
deterministic/linkable, so
+## this reduces exposure to a casual reader of the logs - it does
not by itself make
+## userLoginId stop being personal data under regulations like GDPR.
+## off - userLoginId is left out of the correlation context entirely. Use
this, not hash, when
+## the actual requirement is "no PII in logs" - correlate on
requestId/visitId instead.
+## mode=hash REQUIRES the pepper property below to be set - OFBiz refuses to
start with mode=hash
+## and no pepper configured, rather than silently hashing without one.
+log.correlation.userLoginId.mode=plain
+
+## Mandatory when log.correlation.userLoginId.mode=hash above, ignored
otherwise. Deployment-
+## specific secret used as the PBKDF2 salt when hashing userLoginId. Treat
this with the same
+## handling rigor as a database password: it's one shared secret for every
user (a per-user random
+## salt would make the same user hash differently on every request, defeating
the correlation this
+## field exists for), so if it leaks, every userLoginId ever written to the
logs under this
+## deployment becomes crackable offline in one shot.
+log.correlation.userLoginId.hash.pepper=
+
+## Only used when log.correlation.userLoginId.mode=hash above, ignored
otherwise. Length in bits of
+## the derived digest written into the logs - shorter values produce a shorter
userLoginId field in
+## every log line. This does not weaken resistance against an attacker who
holds the pepper (that's
+## governed by the iteration count and pepper secrecy alone); it only raises
the odds of two
+## different users' digests colliding, which is negligible at the 128-bit
default for any realistic
+## deployment. If you need to trace a specific user's activity, correlate via
visitId against the
+## Visit entity instead of trying to reverse this digest.
+## Must be a positive number when mode=hash - OFBiz refuses to start with
mode=hash and a zero,
+## negative, or otherwise invalid value here, rather than letting it fail on
the first login attempt.
+log.correlation.userLoginId.hash.keyLengthBits=128
\ No newline at end of file
diff --git a/framework/base/config/log4j2.xml b/framework/base/config/log4j2.xml
index e0a44aeecf..b114a8f991 100644
--- a/framework/base/config/log4j2.xml
+++ b/framework/base/config/log4j2.xml
@@ -29,6 +29,15 @@ under the License.
<Property name="includeLocation_prod">false</Property>
<Property name="logPattern">%date{DEFAULT} |%-20.20thread
|%-30.30logger{1}${lineToken_${sys:ofbiz.env:-dev}}|%level{length=1}|
%message%n</Property>
<Property
name="includeLocation">${includeLocation_${sys:ofbiz.env:-dev}}</Property>
+ <!--
+ Placeholder for the JSON event template used by the opt-in
structured logging appender
+ below. Defaults to the bundled ECS template. To use a different
schema (e.g. Splunk,
+ CloudWatch), ship a template JSON file in your own component's
config/ directory and
+ point json.logs.template at it (classpath:your-template.json) in
+ framework/base/config/debug.properties - the default "./gradlew
ofbiz" command picks
+ that up directly, no JVM flag or change to this file required.
+ -->
+ <Property
name="jsonLogTemplateUri">${sys:ofbiz.json.logs.template:-classpath:templates/ecs-layout.json}</Property>
</Properties>
<OFBizDynamicThresholdFilter key="uri" onMatch="ACCEPT" onMismatch="DENY">
<KeyValuePair key="/getJs" value="ERROR"/>
@@ -74,10 +83,40 @@ under the License.
<DefaultRolloverStrategy fileIndex="min" max="10"/>
</RollingFile>
+ <!--
+ Opt-in structured (ECS JSON) logging - disabled by default, so this
contributes zero
+ extra disk/CPU cost unless explicitly enabled. Controlled by the
json.logs.enabled
+ switch in framework/base/config/debug.properties (default false);
the default
+ "./gradlew ofbiz" command already picks that up, no separate flag
needed. When enabled,
+ writes runtime/logs/ofbiz-json.log alongside the plain-text files
above, unchanged.
+ The Select/SystemPropertyArbiter below means this appender (and its
AppenderRef further
+ down) simply doesn't exist in the resolved configuration when
disabled.
+ -->
+ <Select>
+ <SystemPropertyArbiter propertyName="ofbiz.json.logs"
propertyValue="true">
+ <RollingFile name="jsonFile"
fileName="runtime/logs/ofbiz-json.log"
filePattern="runtime/logs/ofbiz-json.log.%i">
+ <JsonTemplateLayout
eventTemplateUri="${jsonLogTemplateUri}">
+ <EventTemplateAdditionalField key="service.name"
value="ofbiz"/>
+ <EventTemplateAdditionalField
key="service.environment" value="${sys:ofbiz.env:-dev}"/>
+ </JsonTemplateLayout>
+ <Policies>
+ <OnStartupTriggeringPolicy/>
+ <SizeBasedTriggeringPolicy size="10 MB"/>
+ </Policies>
+ <DefaultRolloverStrategy fileIndex="min" max="30"/>
+ </RollingFile>
+ </SystemPropertyArbiter>
+ </Select>
+
<Async name="async" includeLocation="${includeLocation}">
<AppenderRef ref="ofbiz"/>
<AppenderRef ref="stdout"/>
<AppenderRef ref="error"/>
+ <Select>
+ <SystemPropertyArbiter propertyName="ofbiz.json.logs"
propertyValue="true">
+ <AppenderRef ref="jsonFile"/>
+ </SystemPropertyArbiter>
+ </Select>
</Async>
</Appenders>
diff --git a/framework/base/config/templates/ecs-layout.json
b/framework/base/config/templates/ecs-layout.json
new file mode 100644
index 0000000000..5a96edcb2a
--- /dev/null
+++ b/framework/base/config/templates/ecs-layout.json
@@ -0,0 +1,49 @@
+{
+ "@timestamp": {
+ "$resolver": "timestamp",
+ "pattern": {
+ "format": "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",
+ "timeZone": "UTC"
+ }
+ },
+ "ecs.version": "1.2.0",
+ "log.level": {
+ "$resolver": "level",
+ "field": "name"
+ },
+ "message": {
+ "$resolver": "message",
+ "stringified": true
+ },
+ "process.thread.name": {
+ "$resolver": "thread",
+ "field": "name"
+ },
+ "log.logger": {
+ "$resolver": "logger",
+ "field": "name"
+ },
+ "labels": {
+ "$resolver": "mdc",
+ "flatten": false,
+ "stringified": true
+ },
+ "tags": {
+ "$resolver": "ndc"
+ },
+ "error.type": {
+ "$resolver": "exception",
+ "field": "className"
+ },
+ "error.message": {
+ "$resolver": "exception",
+ "field": "message"
+ },
+ "error.stack_trace": {
+ "$resolver": "exception",
+ "field": "stackTrace",
+ "stackTrace": {
+ "stringified": true
+ }
+ }
+}
diff --git
a/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CatalinaContainer.java
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CatalinaContainer.java
index fac58c164c..84c8460381 100644
---
a/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CatalinaContainer.java
+++
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CatalinaContainer.java
@@ -376,6 +376,9 @@ public class CatalinaContainer implements Container {
throws ContainerException {
List<Valve> engineValves = new ArrayList<>();
+ // populates Log4j2 ThreadContext with requestId/visitId/userLoginId
for log correlation
+ engineValves.add(new CorrelationValve());
+
// configure the CrossSubdomainSessionValve
if (ContainerConfig.getPropertyValue(engineConfig,
"enable-cross-subdomain-sessions", false)) {
engineValves.add(new CrossSubdomainSessionValve());
diff --git
a/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationFieldProvider.java
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationFieldProvider.java
new file mode 100644
index 0000000000..668b8275d6
--- /dev/null
+++
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationFieldProvider.java
@@ -0,0 +1,48 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.catalina.container;
+
+import java.util.Map;
+
+import org.apache.catalina.connector.Request;
+import org.apache.ofbiz.entity.GenericValue;
+
+/**
+ * Extension point for {@link CorrelationValve}: lets a component contribute
additional
+ * key/value pairs to the per-request logging correlation context (Log4j2
{@code ThreadContext}),
+ * beyond the core {@code requestId}/{@code visitId}/{@code userLoginId}
fields the Valve always
+ * sets.
+ *
+ * <p>Implementations are discovered via {@link java.util.ServiceLoader} - a
component adds one by
+ * providing an implementation class plus a
+ * {@code
META-INF/services/org.apache.ofbiz.catalina.container.CorrelationFieldProvider}
file
+ * naming it, with no framework code changes required. All discovered
providers are called for
+ * every request and their fields merged together.
+ */
+public interface CorrelationFieldProvider {
+
+ /**
+ * Returns additional correlation fields for the current request, or
{@code null}/empty if
+ * this provider has nothing to add (e.g. an anonymous request for a
provider that only
+ * contributes fields for logged-in users).
+ * @param request the current request
+ * @param userLogin the logged-in user, or {@code null} if the request is
anonymous
+ */
+ Map<String, String> getFields(Request request, GenericValue userLogin);
+}
diff --git
a/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationValve.java
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationValve.java
new file mode 100644
index 0000000000..9044080dfb
--- /dev/null
+++
b/framework/catalina/src/main/java/org/apache/ofbiz/catalina/container/CorrelationValve.java
@@ -0,0 +1,242 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.catalina.container;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.NoSuchAlgorithmException;
+import java.security.spec.InvalidKeySpecException;
+import java.util.ArrayList;
+import java.util.Base64;
+import java.util.LinkedHashSet;
+import java.util.List;
+import java.util.Map;
+import java.util.ServiceLoader;
+import java.util.Set;
+import java.util.UUID;
+import java.util.concurrent.ThreadLocalRandom;
+
+import javax.crypto.SecretKeyFactory;
+import javax.crypto.spec.PBEKeySpec;
+
+import jakarta.servlet.ServletException;
+import jakarta.servlet.http.HttpSession;
+
+import org.apache.catalina.connector.Request;
+import org.apache.catalina.connector.Response;
+import org.apache.catalina.valves.ValveBase;
+import org.apache.logging.log4j.ThreadContext;
+import org.apache.ofbiz.base.util.Debug;
+import org.apache.ofbiz.base.util.GeneralRuntimeException;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.base.util.UtilValidate;
+import org.apache.ofbiz.entity.GenericValue;
+import org.apache.ofbiz.webapp.stats.VisitHandler;
+
+/**
+ * Populates Log4j2's {@code ThreadContext} (MDC) with per-request logging
correlation fields -
+ * {@code requestId}, {@code visitId}, {@code userLoginId} - so every log line
produced while
+ * handling one HTTP request or one user visit can be tied together (see
+ * {@code framework/base/config/log4j2.xml}, the ECS JSON logging profile in
particular).
+ *
+ * <p>Runs as an engine-level Valve, ahead of the servlet filter chain, so the
fields are already
+ * in place before {@code ControlFilter} and every downstream filter/servlet
run.
+ *
+ * <p>Additional fields can be contributed by any component without changing
this class - see
+ * {@link CorrelationFieldProvider}.
+ */
+public class CorrelationValve extends ValveBase {
+
+ private static final String MODULE = CorrelationValve.class.getName();
+ private static final Set<String> CORE_FIELDS = Set.of("requestId",
"visitId", "userLoginId");
+ private static final List<CorrelationFieldProvider> PROVIDERS =
loadProviders();
+ private static final String PBKDF2_HASH_TYPE = "PBKDF2WithHmacSHA256";
+ // Same property HashCrypt's password hashing uses, so hash mode's cost
factor stays in step
+ // with whatever this deployment already tuned for password storage.
+ private static final int PBKDF2_ITERATIONS =
UtilProperties.getPropertyAsInteger("security.properties",
+ "password.encrypt.pbkdf2.iterations", 10000);
+
+ /**
+ * Controls what {@code userLoginId} value (if any) is written into the
log correlation
+ * context - see {@code log.correlation.userLoginId.mode} in {@code
debug.properties}.
+ * {@code PLAIN} keeps today's behavior; {@code HASH} substitutes a
deterministic PBKDF2
+ * digest (same user, same digest, every request, but not reversible to
the raw value without
+ * the pepper); {@code OFF} omits the field entirely.
+ */
+ private enum UserLoginIdMode { PLAIN, HASH, OFF }
+
+ private final UserLoginIdMode userLoginIdMode;
+ private final String userLoginIdHashPepper;
+ // Digest length, not the PRF named above - shrinks the userLoginId field
written to every log
+ // line. Lowering this does not weaken resistance against an attacker who
holds the pepper
+ // (that's governed by PBKDF2_ITERATIONS and pepper secrecy alone); it
only raises the odds of
+ // two different users' digests colliding, negligible at 128 bits for any
realistic userbase -
+ // and tracing a specific user is expected to go through visitId/Visit,
not a reversed digest.
+ private final int userLoginIdHashKeyLengthBits;
+
+ CorrelationValve() {
+ super();
+ this.userLoginIdMode = resolveUserLoginIdMode();
+ this.userLoginIdHashPepper = resolveHashPepper(this.userLoginIdMode);
+ this.userLoginIdHashKeyLengthBits =
resolveHashKeyLengthBits(this.userLoginIdMode);
+ }
+
+ private static List<CorrelationFieldProvider> loadProviders() {
+ List<CorrelationFieldProvider> providers = new ArrayList<>();
+
ServiceLoader.load(CorrelationFieldProvider.class).forEach(providers::add);
+ return List.copyOf(providers);
+ }
+
+ private static UserLoginIdMode resolveUserLoginIdMode() {
+ String mode = UtilProperties.getPropertyValue("debug",
"log.correlation.userLoginId.mode", "plain");
+ try {
+ return UserLoginIdMode.valueOf(mode.trim().toUpperCase());
+ } catch (IllegalArgumentException e) {
+ Debug.logWarning("Invalid log.correlation.userLoginId.mode [" +
mode + "]; defaulting to 'plain'", MODULE);
+ return UserLoginIdMode.PLAIN;
+ }
+ }
+
+ private static String resolveHashPepper(UserLoginIdMode mode) {
+ String pepper = UtilProperties.getPropertyValue("debug",
"log.correlation.userLoginId.hash.pepper", "");
+ if (mode == UserLoginIdMode.HASH && UtilValidate.isEmpty(pepper)) {
+ // Fail fast: a blank pepper must never fall back to "unsalted" or
"randomly salted
+ // per call" behind the operator's back - either would silently
defeat what hash mode
+ // promises (see HashCrypt.pbkdf2HashCrypt - an empty salt draws a
fresh random one on
+ // every call, which would make the same userLoginId hash
differently every request).
+ throw new
IllegalStateException("log.correlation.userLoginId.mode=hash requires "
+ + "log.correlation.userLoginId.hash.pepper to be set -
refusing to start with an "
+ + "unconfigured pepper");
+ }
+ return pepper;
+ }
+
+ private static int resolveHashKeyLengthBits(UserLoginIdMode mode) {
+ int keyLengthBits = UtilProperties.getPropertyAsInteger("debug",
+ "log.correlation.userLoginId.hash.keyLengthBits", 128);
+ if (mode == UserLoginIdMode.HASH && keyLengthBits <= 0) {
+ // Fail fast at startup rather than at first login: PBEKeySpec
rejects a non-positive
+ // key length with an unchecked IllegalArgumentException that
hashUserLoginId's
+ // catch block doesn't handle, so a bad value would otherwise only
surface as an
+ // uncaught error on the first authenticated request.
+ throw new
IllegalStateException("log.correlation.userLoginId.mode=hash requires "
+ + "log.correlation.userLoginId.hash.keyLengthBits to be a
positive number, got "
+ + keyLengthBits);
+ }
+ return keyLengthBits;
+ }
+
+ @Override
+ public void invoke(Request request, Response response) throws IOException,
ServletException {
+ Set<String> contextKeys = new LinkedHashSet<>();
+ try {
+ putField(contextKeys, "requestId", generateRequestId());
+
+ HttpSession session = request.getSession(false);
+ String visitId = session != null ?
VisitHandler.getVisitId(session) : null;
+ if (visitId != null) {
+ putField(contextKeys, "visitId", visitId);
+ }
+ GenericValue userLogin = session != null ? (GenericValue)
session.getAttribute("userLogin") : null;
+ if (userLogin != null) {
+ switch (userLoginIdMode) {
+ case HASH:
+ putField(contextKeys, "userLoginId",
hashUserLoginId(userLogin.getString("userLoginId")));
+ break;
+ case OFF:
+ break;
+ case PLAIN:
+ default:
+ putField(contextKeys, "userLoginId",
userLogin.getString("userLoginId"));
+ }
+ }
+
+ for (CorrelationFieldProvider provider : PROVIDERS) {
+ applyProvider(provider, request, userLogin, contextKeys);
+ }
+
+ getNext().invoke(request, response);
+ } finally {
+ contextKeys.forEach(ThreadContext::remove);
+ }
+ }
+
+ private void applyProvider(CorrelationFieldProvider provider, Request
request, GenericValue userLogin, Set<String> contextKeys) {
+ try {
+ Map<String, String> fields = provider.getFields(request,
userLogin);
+ if (fields == null) {
+ return;
+ }
+ for (Map.Entry<String, String> field : fields.entrySet()) {
+ if (CORE_FIELDS.contains(field.getKey())) {
+ Debug.logWarning("CorrelationFieldProvider [" +
provider.getClass().getName() + "] overwrote core "
+ + "correlation field '" + field.getKey() + "'",
MODULE);
+ }
+ putField(contextKeys, field.getKey(), field.getValue());
+ }
+ } catch (Exception e) {
+ Debug.logError(e, "CorrelationFieldProvider [" +
provider.getClass().getName() + "] failed to provide "
+ + "correlation fields; skipping it for this request",
MODULE);
+ }
+ }
+
+ private void putField(Set<String> contextKeys, String key, String value) {
+ ThreadContext.put(key, value);
+ contextKeys.add(key);
+ }
+
+ /**
+ * Hashes {@code userLoginId} with the same password-grade PBKDF2
algorithm/iteration count
+ * OFBiz already uses for password storage, so the same user always
produces the same value
+ * (preserving cross-request correlation) while making offline guessing of
low-entropy values
+ * like email addresses meaningfully more expensive than a fast digest
would.
+ *
+ * <p>Deliberately does <b>not</b> call {@code HashCrypt.pbkdf2HashCrypt}
- that method's
+ * output format is {@code {type}iterations$base64(salt)$base64(hash)},
i.e. it embeds the
+ * salt itself, base64-encoded (trivially reversible), directly in the
returned string. That's
+ * correct for password storage, where each password's own random salt has
to travel with its
+ * hash to be verifiable later. Here the "salt" is {@link
#userLoginIdHashPepper}, one fixed
+ * deployment secret already available out-of-band from {@code
debug.properties} - embedding
+ * it in every hashed log line would put the pepper in
cleartext-recoverable form in the exact
+ * artifact this feature exists to protect, regardless of whether the
pepper is ever otherwise
+ * "leaked". Deriving the key directly and returning only the digest
avoids that.
+ */
+ private String hashUserLoginId(String userLoginId) {
+ try {
+ PBEKeySpec spec = new PBEKeySpec(userLoginId.toCharArray(),
+ userLoginIdHashPepper.getBytes(StandardCharsets.UTF_8),
PBKDF2_ITERATIONS, userLoginIdHashKeyLengthBits);
+ SecretKeyFactory factory =
SecretKeyFactory.getInstance(PBKDF2_HASH_TYPE);
+ byte[] key = factory.generateSecret(spec).getEncoded();
+ return "{PBKDF2-SHA256}" + Base64.getEncoder().encodeToString(key);
+ } catch (NoSuchAlgorithmException | InvalidKeySpecException e) {
+ throw new GeneralRuntimeException("Error hashing userLoginId for
log correlation", e);
+ }
+ }
+
+ /**
+ * Generates a UUID-shaped log correlation id from {@link
ThreadLocalRandom} instead of
+ * {@link UUID#randomUUID()}. Request ids only need to be unique for log
correlation, not
+ * cryptographically unpredictable, and UUID.randomUUID() draws from a
shared SecureRandom
+ * that can become a contention point across threads under high
concurrency.
+ */
+ private static String generateRequestId() {
+ ThreadLocalRandom random = ThreadLocalRandom.current();
+ return new UUID(random.nextLong(), random.nextLong()).toString();
+ }
+}
diff --git
a/framework/catalina/src/test/java/org/apache/ofbiz/catalina/container/CorrelationValveTests.java
b/framework/catalina/src/test/java/org/apache/ofbiz/catalina/container/CorrelationValveTests.java
new file mode 100644
index 0000000000..8a6b1d4991
--- /dev/null
+++
b/framework/catalina/src/test/java/org/apache/ofbiz/catalina/container/CorrelationValveTests.java
@@ -0,0 +1,204 @@
+/*******************************************************************************
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing,
+ * software distributed under the License is distributed on an
+ * "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+ * KIND, either express or implied. See the License for the
+ * specific language governing permissions and limitations
+ * under the License.
+
*******************************************************************************/
+package org.apache.ofbiz.catalina.container;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.doAnswer;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import jakarta.servlet.http.HttpSession;
+
+import org.apache.catalina.Valve;
+import org.apache.catalina.connector.Request;
+import org.apache.catalina.connector.Response;
+import org.apache.logging.log4j.ThreadContext;
+import org.apache.ofbiz.base.util.UtilProperties;
+import org.apache.ofbiz.entity.GenericValue;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+
+public final class CorrelationValveTests {
+
+ private static final String MODE_PROP = "log.correlation.userLoginId.mode";
+ private static final String PEPPER_PROP =
"log.correlation.userLoginId.hash.pepper";
+ private static final String KEY_LENGTH_PROP =
"log.correlation.userLoginId.hash.keyLengthBits";
+
+ private Request request;
+ private Response response;
+ private HttpSession session;
+ private GenericValue userLogin;
+ private Valve next;
+
+ @BeforeEach
+ public void setUp() {
+ request = mock(Request.class);
+ response = mock(Response.class);
+ session = mock(HttpSession.class);
+ when(session.getAttribute(anyString())).thenReturn(null);
+ // VisitHandler.getVisit() falls through to creating a new Visit
entity (needs a real
+ // Delegator) unless session already has a "visit" attribute - stub
one so tests only
+ // exercise the userLoginId path, which is what they're actually about.
+ GenericValue visit = mock(GenericValue.class);
+ when(visit.getString("visitId")).thenReturn("10000");
+ when(session.getAttribute("visit")).thenReturn(visit);
+ when(request.getSession(false)).thenReturn(session);
+ userLogin = mock(GenericValue.class);
+ next = mock(Valve.class);
+ resetProperties();
+ }
+
+ @AfterEach
+ public void tearDown() {
+ resetProperties();
+ }
+
+ private void resetProperties() {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "plain");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP, "");
+ UtilProperties.setPropertyValueInMemory("debug", KEY_LENGTH_PROP,
"128");
+ }
+
+ private CorrelationValve newValve() {
+ CorrelationValve valve = new CorrelationValve();
+ valve.setNext(next);
+ return valve;
+ }
+
+ private String captureUserLoginIdSeenByNext() throws Exception {
+ String[] captured = new String[1];
+ doAnswer(invocation -> {
+ captured[0] = ThreadContext.get("userLoginId");
+ return null;
+ }).when(next).invoke(request, response);
+ newValve().invoke(request, response);
+ return captured[0];
+ }
+
+ @Test
+ public void plainModeWritesRawUserLoginId() throws Exception {
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+
+ assertEquals("[email protected]", captureUserLoginIdSeenByNext());
+ assertNull(ThreadContext.get("userLoginId"), "context must be cleared
after the request");
+ }
+
+ @Test
+ public void hashModeWritesDeterministicDigestNotRawValue() throws
Exception {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP,
"test-pepper");
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+
+ String firstDigest = captureUserLoginIdSeenByNext();
+ String secondDigest = captureUserLoginIdSeenByNext();
+
+ assertNotEquals("[email protected]", firstDigest);
+ assertEquals(firstDigest, secondDigest, "same user must hash the same
way across requests");
+ assertTrue(firstDigest.startsWith("{PBKDF2"), "digest should be
self-describing, was: " + firstDigest);
+ }
+
+ @Test
+ public void hashModeDefaultKeyLengthProducesThirtyNineCharDigest() throws
Exception {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP,
"test-pepper");
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+
+ String digest = captureUserLoginIdSeenByNext();
+
+ // "{PBKDF2-SHA256}" (15 chars) + base64(16-byte/128-bit key, padded)
(24 chars) = 39.
+ assertEquals(39, digest.length(), "digest was: " + digest);
+ }
+
+ @Test
+ public void hashModeProducesDifferentDigestsForDifferentUsers() throws
Exception {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP,
"test-pepper");
+
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+ String firstUserDigest = captureUserLoginIdSeenByNext();
+
+ GenericValue otherUserLogin = mock(GenericValue.class);
+
when(otherUserLogin.getString("userLoginId")).thenReturn("[email protected]");
+ when(session.getAttribute("userLogin")).thenReturn(otherUserLogin);
+ String secondUserDigest = captureUserLoginIdSeenByNext();
+
+ assertNotEquals(firstUserDigest, secondUserDigest);
+ }
+
+ @Test
+ public void hashModeWithoutPepperFailsFastAtConstruction() {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+
+ assertThrows(IllegalStateException.class, CorrelationValve::new);
+ }
+
+ @Test
+ public void hashModeWithZeroKeyLengthFailsFastAtConstruction() {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP,
"test-pepper");
+ UtilProperties.setPropertyValueInMemory("debug", KEY_LENGTH_PROP, "0");
+
+ assertThrows(IllegalStateException.class, CorrelationValve::new);
+ }
+
+ @Test
+ public void hashModeWithNegativeKeyLengthFailsFastAtConstruction() {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "hash");
+ UtilProperties.setPropertyValueInMemory("debug", PEPPER_PROP,
"test-pepper");
+ UtilProperties.setPropertyValueInMemory("debug", KEY_LENGTH_PROP,
"-128");
+
+ assertThrows(IllegalStateException.class, CorrelationValve::new);
+ }
+
+ @Test
+ public void offModeOmitsUserLoginIdEntirely() throws Exception {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "off");
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+
+ boolean[] hadKey = new boolean[1];
+ doAnswer(invocation -> {
+ hadKey[0] = ThreadContext.containsKey("userLoginId");
+ return null;
+ }).when(next).invoke(request, response);
+ newValve().invoke(request, response);
+
+ assertFalse(hadKey[0]);
+ }
+
+ @Test
+ public void invalidModeFallsBackToPlain() throws Exception {
+ UtilProperties.setPropertyValueInMemory("debug", MODE_PROP, "bogus");
+ when(session.getAttribute("userLogin")).thenReturn(userLogin);
+
when(userLogin.getString("userLoginId")).thenReturn("[email protected]");
+
+ assertEquals("[email protected]", captureUserLoginIdSeenByNext());
+ }
+}