This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to refs/heads/master by this push:
new c71577130 UNOMI-945: Fix context filter list sanitization and add
tests (#789)
c71577130 is described below
commit c71577130f7a40b448af922cce000a9da3718926
Author: Serge Huber <[email protected]>
AuthorDate: Mon Jun 29 19:54:21 2026 +0200
UNOMI-945: Fix context filter list sanitization and add tests (#789)
---
.../org/apache/unomi/itests/ContextServletIT.java | 20 ++
.../test/resources/security/mvel-payload-list.json | 23 ++
.../unomi/rest/endpoints/ContextJsonEndpoint.java | 5 +-
.../rest/endpoints/ContextJsonEndpointTest.java | 257 +++++++++++++++++++++
4 files changed, 303 insertions(+), 2 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index 636320fc9..03a314a7d 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -548,6 +548,26 @@ public class ContextServletIT extends BaseIT {
exists -> exists == Boolean.FALSE, DEFAULT_TRYING_TIMEOUT,
DEFAULT_SHOULDBETRUE_TRIES);
}
+ @Test
+ public void testMVELVulnerabilityWithListPropertyValues() throws Exception
{
+ File vulnFile = new File("target/vuln-file-list.txt");
+ if (vulnFile.exists()) {
+ vulnFile.delete();
+ }
+ String vulnFileCanonicalPath = vulnFile.getCanonicalPath();
+ vulnFileCanonicalPath = vulnFileCanonicalPath.replace("\\", "\\\\");
+
+ Map<String, String> parameters = new HashMap<>();
+ parameters.put("VULN_FILE_PATH", vulnFileCanonicalPath);
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ request.setEntity(
+ new
StringEntity(getValidatedBundleJSON("security/mvel-payload-list.json",
parameters), ContentType.APPLICATION_JSON));
+ TestUtils.executeContextJSONRequest(request, TEST_SESSION_ID);
+
+ shouldBeTrueUntilEnd("Vulnerability successfully executed ! File
created at " + vulnFileCanonicalPath, vulnFile::exists,
+ exists -> exists == Boolean.FALSE, DEFAULT_TRYING_TIMEOUT,
DEFAULT_SHOULDBETRUE_TRIES);
+ }
+
@Test
public void testPersonalization() throws Exception {
diff --git a/itests/src/test/resources/security/mvel-payload-list.json
b/itests/src/test/resources/security/mvel-payload-list.json
new file mode 100644
index 000000000..bfc6ff8f8
--- /dev/null
+++ b/itests/src/test/resources/security/mvel-payload-list.json
@@ -0,0 +1,23 @@
+{
+ "filters": [
+ {
+ "id": "filter1",
+ "filters": [
+ {
+ "condition": {
+ "parameterValues": {
+ "propertyName": "firstName",
+ "comparisonOperator": "equals",
+ "propertyValues": [
+ "safe-value",
+ "script::Runtime r = Runtime.getRuntime(); r.exec(\"touch
###VULN_FILE_PATH###\");"
+ ]
+ },
+ "type": "profilePropertyCondition"
+ }
+ }
+ ]
+ }
+ ],
+ "sessionId": "demo-session-id"
+}
diff --git
a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
index bb234b9b2..bcec27d77 100644
---
a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
+++
b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
@@ -385,11 +385,12 @@ public class ContextJsonEndpoint {
for (Map.Entry<String, Object> parameterEntry :
condition.getParameterValues().entrySet()) {
Object sanitizedValue = sanitizeValue(parameterEntry.getValue());
if (sanitizedValue != null) {
- newParameterValues.put(parameterEntry.getKey(),
parameterEntry.getValue());
+ newParameterValues.put(parameterEntry.getKey(),
sanitizedValue);
} else {
return null;
}
}
+ condition.setParameterValues(newParameterValues);
return condition;
}
@@ -412,7 +413,7 @@ public class ContextJsonEndpoint {
newValues.add(newObject);
}
}
- return values;
+ return newValues;
} else if (value instanceof Map) {
Map<Object, Object> newMap = new LinkedHashMap<>();
((Map<?, ?>) value).forEach((key, value1) -> {
diff --git
a/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
b/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
new file mode 100644
index 000000000..b29946802
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/endpoints/ContextJsonEndpointTest.java
@@ -0,0 +1,257 @@
+/*
+ * 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.rest.endpoints;
+
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.Persona;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.Session;
+import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.services.PersonalizationService;
+import org.junit.jupiter.api.Test;
+
+import javax.ws.rs.core.Response;
+import java.lang.reflect.Method;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Unit tests for {@link ContextJsonEndpoint}. Private sanitization helpers
are exercised via reflection
+ * so filter/personalization hardening can be validated without a running
container (UNOMI-945).
+ */
+class ContextJsonEndpointTest {
+
+ private final ContextJsonEndpoint endpoint = new ContextJsonEndpoint();
+
+ @Test
+ void contextOptionsEndpoints_returnNoContentWithCorsHeader() {
+ Response jsOptions = endpoint.contextJSAsOptions();
+ assertEquals(204, jsOptions.getStatus());
+ assertEquals("*",
jsOptions.getHeaderString("Access-Control-Allow-Origin"));
+
+ Response jsonOptions = endpoint.contextJSONAsOptions();
+ assertEquals(204, jsonOptions.getStatus());
+ assertEquals("*",
jsonOptions.getHeaderString("Access-Control-Allow-Origin"));
+ }
+
+ @Test
+ void destroy_logsShutdownWithoutThrowing() {
+ endpoint.destroy();
+ }
+
+ @Test
+ void sanitizeValue_keepsSafeStringsAndScalars() throws Exception {
+ assertEquals("hello", invokeSanitizeValue("hello"));
+ assertEquals(42, invokeSanitizeValue(42));
+ assertEquals(true, invokeSanitizeValue(true));
+ }
+
+ @Test
+ void sanitizeValue_filtersScriptAndParameterReferences() throws Exception {
+
assertNull(invokeSanitizeValue("script::Runtime.getRuntime().exec(\"touch
/tmp/evil\")"));
+ assertNull(invokeSanitizeValue("parameter::eventTypeId"));
+ }
+
+ @Test
+ void sanitizeValue_filtersScriptAndParameterReferencesFromLists() throws
Exception {
+ Object sanitized = invokeSanitizeValue(Arrays.asList(
+ "safe-value",
+ "script::Runtime.getRuntime().exec(\"touch /tmp/evil\")",
+ "parameter::eventTypeId",
+ "another-safe-value"));
+
+ assertEquals(List.of("safe-value", "another-safe-value"), sanitized);
+ }
+
+ @Test
+ void sanitizeValue_returnsEmptyListWhenAllEntriesAreFiltered() throws
Exception {
+ Object sanitized = invokeSanitizeValue(List.of("script::evil",
"parameter::key"));
+ assertEquals(Collections.emptyList(), sanitized);
+ }
+
+ @Test
+ void sanitizeValue_filtersNestedScriptReferencesInMapsAndLists() throws
Exception {
+ Map<String, Object> nested = new LinkedHashMap<>();
+ nested.put("allowed", List.of("ok", "script::evil"));
+ nested.put("blocked", "parameter::key");
+
+ @SuppressWarnings("unchecked")
+ Map<Object, Object> sanitizedMap = (Map<Object, Object>)
invokeSanitizeValue(nested);
+ assertEquals(List.of("ok"), sanitizedMap.get("allowed"));
+ assertTrue(!sanitizedMap.containsKey("blocked"));
+ }
+
+ @Test
+ void sanitizeCondition_returnsNullWhenParameterContainsScriptReference()
throws Exception {
+ Condition condition = conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "propertyValue", "script::evil"));
+
+ assertNull(invokeSanitizeCondition(condition));
+ }
+
+ @Test
+ void sanitizeCondition_returnsConditionWhenParametersAreSafe() throws
Exception {
+ Condition condition = conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "propertyValue", "Jane"));
+
+ assertNotNull(invokeSanitizeCondition(condition));
+ }
+
+ @Test
+ void sanitizeCondition_stripsScriptReferencesFromListParameterValues()
throws Exception {
+ Condition condition = conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "comparisonOperator", "equals",
+ "propertyValues", new ArrayList<>(List.of("safe-value",
"script::Runtime.getRuntime().exec(\"touch /tmp/evil\")"))));
+
+ Condition sanitized = (Condition) invokeSanitizeCondition(condition);
+
+ assertNotNull(sanitized);
+ assertEquals(List.of("safe-value"),
sanitized.getParameterValues().get("propertyValues"));
+ }
+
+ @Test
+ void
sanitizePersonalizedContentObjects_dropsContentWithUnsafeFilterCondition()
throws Exception {
+ PersonalizationService.Filter unsafeFilter =
filterWithCondition(conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "propertyValue", "script::evil")));
+ PersonalizationService.Filter safeFilter =
filterWithCondition(conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "propertyValue", "Jane")));
+
+ PersonalizationService.PersonalizedContent unsafeContent =
personalizedContent("unsafe", unsafeFilter);
+ PersonalizationService.PersonalizedContent safeContent =
personalizedContent("safe", safeFilter);
+
+ @SuppressWarnings("unchecked")
+ List<PersonalizationService.PersonalizedContent> sanitized =
(List<PersonalizationService.PersonalizedContent>)
+
invokeSanitizePersonalizedContentObjects(List.of(unsafeContent, safeContent));
+
+ assertEquals(1, sanitized.size());
+ assertEquals("safe", sanitized.get(0).getId());
+ }
+
+ @Test
+ void sanitizePersonalizations_dropsRequestWhenAllContentsAreUnsafe()
throws Exception {
+ PersonalizationService.Filter unsafeFilter =
filterWithCondition(conditionWithParameters(Map.of(
+ "propertyName", "firstName",
+ "propertyValue", "script::evil")));
+ PersonalizationService.PersonalizedContent unsafeContent =
personalizedContent("variant", unsafeFilter);
+
+ PersonalizationService.PersonalizationRequest request = new
PersonalizationService.PersonalizationRequest();
+ request.setId("perso-1");
+ request.setContents(List.of(unsafeContent));
+
+ @SuppressWarnings("unchecked")
+ List<PersonalizationService.PersonalizationRequest> sanitized =
(List<PersonalizationService.PersonalizationRequest>)
+ invokeSanitizePersonalizations(List.of(request));
+
+ assertTrue(sanitized.isEmpty());
+ }
+
+ @Test
+ void processOverrides_appliesOverridesOnlyForPersonaProfiles() throws
Exception {
+ Persona persona = new Persona();
+ persona.setItemId("persona-1");
+ Session session = new Session();
+ session.setItemId("session-1");
+ session.setProperties(new HashMap<>(Map.of("campaign", "summer")));
+
+ ContextRequest contextRequest = new ContextRequest();
+ Profile profileOverrides = new Profile();
+ profileOverrides.setProperty("firstName", "Test");
+ contextRequest.setProfileOverrides(profileOverrides);
+ contextRequest.setSessionPropertiesOverrides(Map.of("campaign",
"winter"));
+
+ invokeProcessOverrides(contextRequest, persona, session);
+ assertEquals("Test", persona.getProperty("firstName"));
+ assertEquals("winter", session.getProperty("campaign"));
+
+ Profile regularProfile = new Profile();
+ regularProfile.setItemId("profile-1");
+ regularProfile.setProperty("firstName", "Original");
+ Session regularSession = new Session();
+ regularSession.setProperties(new HashMap<>(Map.of("campaign",
"summer")));
+
+ invokeProcessOverrides(contextRequest, regularProfile, regularSession);
+ assertEquals("Original", regularProfile.getProperty("firstName"));
+ assertEquals("summer", regularSession.getProperty("campaign"));
+ }
+
+ private static Condition conditionWithParameters(Map<String, Object>
parameters) {
+ Condition condition = new Condition();
+ condition.setConditionTypeId("profilePropertyCondition");
+ condition.setParameterValues(new HashMap<>(parameters));
+ return condition;
+ }
+
+ private static PersonalizationService.Filter filterWithCondition(Condition
condition) {
+ PersonalizationService.Filter filter = new
PersonalizationService.Filter();
+ filter.setCondition(condition);
+ return filter;
+ }
+
+ private static PersonalizationService.PersonalizedContent
personalizedContent(String id,
+ PersonalizationService.Filter... filters) {
+ PersonalizationService.PersonalizedContent content = new
PersonalizationService.PersonalizedContent();
+ content.setId(id);
+ content.setFilters(Arrays.asList(filters));
+ return content;
+ }
+
+ private Object invokeSanitizeValue(Object value) throws Exception {
+ return invokePrivate("sanitizeValue", new Class<?>[] { Object.class },
value);
+ }
+
+ private Object invokeSanitizeCondition(Condition condition) throws
Exception {
+ return invokePrivate("sanitizeCondition", new Class<?>[] {
Condition.class }, condition);
+ }
+
+ private Object
invokeSanitizePersonalizedContentObjects(List<PersonalizationService.PersonalizedContent>
contents)
+ throws Exception {
+ return invokePrivate("sanitizePersonalizedContentObjects", new
Class<?>[] { List.class }, contents);
+ }
+
+ private Object
invokeSanitizePersonalizations(List<PersonalizationService.PersonalizationRequest>
personalizations)
+ throws Exception {
+ return invokePrivate("sanitizePersonalizations", new Class<?>[] {
List.class }, personalizations);
+ }
+
+ private void invokeProcessOverrides(ContextRequest contextRequest, Profile
profile, Session session)
+ throws Exception {
+ invokePrivate("processOverrides",
+ new Class<?>[] { ContextRequest.class, Profile.class,
Session.class },
+ contextRequest, profile, session);
+ }
+
+ private Object invokePrivate(String methodName, Class<?>[] parameterTypes,
Object... args) throws Exception {
+ Method method =
ContextJsonEndpoint.class.getDeclaredMethod(methodName, parameterTypes);
+ method.setAccessible(true);
+ return method.invoke(endpoint, args);
+ }
+}