This is an automated email from the ASF dual-hosted git repository.

asf-gitbox-commits pushed a commit to branch 
validate-public-session-and-client-ip
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 3a8b785fc668433a859df2de02216c2c7a333295
Author: Serge Huber <[email protected]>
AuthorDate: Tue Sep 8 21:09:38 2026 +0200

    Validate eventcollector query session ids and honor client IPs only from 
configured proxies.
    
    Apply the same requestIds grammar used on context.json to the 
query-parameter fallback, and restrict GeoIP address hints to trusted proxy 
peers.
---
 .../org/apache/unomi/itests/InputValidationIT.java |  28 ++++++
 .../validation/eventcollector_noSessionId.json     |  12 +++
 manual/src/main/asciidoc/configuration.adoc        |   4 +
 .../main/resources/etc/custom.system.properties    |   1 +
 .../request/actions/SetRemoteHostInfoAction.java   | 110 +++++++++++++++++++--
 .../resources/OSGI-INF/blueprint/blueprint.xml     |   3 +
 .../resources/org.apache.unomi.plugins.request.cfg |   5 +
 .../actions/SetRemoteHostInfoActionTest.java       |  86 ++++++++++++++++
 .../rest/endpoints/EventsCollectorEndpoint.java    |   6 ++
 .../rest/service/RequestIdentifierValidator.java   |  50 ++++++++++
 .../service/RequestIdentifierValidatorTest.java    |  57 +++++++++++
 11 files changed, 352 insertions(+), 10 deletions(-)

diff --git 
a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java 
b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
index ab7ea7269..078177ef2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/InputValidationIT.java
@@ -67,6 +67,7 @@ public class InputValidationIT extends BaseIT {
             // InvalidRequestExceptionMapper errors (expected when testing 
invalid requests)
             .addIgnoredSubstring("InvalidRequestExceptionMapper")
             .addIgnoredSubstring("Invalid parameter")
+            .addIgnoredSubstring("Invalid sessionId query parameter")
             .addIgnoredSubstring("Invalid Context request object")
             .addIgnoredSubstring("Invalid events collector object")
             .addIgnoredSubstring("Invalid profile ID format in cookie")
@@ -111,6 +112,33 @@ public class InputValidationIT extends BaseIT {
         doGETRequestTest(EVENT_COLLECTOR_URL, null, 
"/validation/eventcollector_invalidSessionId.json", 400, 
ERROR_MESSAGE_INVALID_DATA_RECEIVED);
     }
 
+    @Test
+    public void test_eventCollector_querySessionIDPattern() throws Exception {
+        
schemaService.saveSchema(resourceAsString("schemas/schema-dummy.json"));
+        
schemaService.saveSchema(resourceAsString("schemas/schema-dummy-properties.json"));
+        keepTrying("Event should be valid",
+                () -> 
schemaService.isEventValid(resourceAsString("schemas/event-dummy-valid.json")),
+                isValid -> isValid,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+        String invalidQuery = "?sessionId=" + 
URLEncoder.encode("<script>alert();</script>", StandardCharsets.UTF_8);
+        doPOSTRequestTest(EVENT_COLLECTOR_URL + invalidQuery, null, 
"/validation/eventcollector_noSessionId.json", 400,
+                ERROR_MESSAGE_INVALID_DATA_RECEIVED);
+        doGETRequestTest(EVENT_COLLECTOR_URL + invalidQuery, null, 
"/validation/eventcollector_noSessionId.json", 400,
+                ERROR_MESSAGE_INVALID_DATA_RECEIVED);
+
+        String validQuery = "?sessionId=" + 
URLEncoder.encode("dummy-session-id", StandardCharsets.UTF_8);
+        doPOSTRequestTest(EVENT_COLLECTOR_URL + validQuery, null, 
"/validation/eventcollector_noSessionId.json", 200, null);
+        doGETRequestTest(EVENT_COLLECTOR_URL + validQuery, null, 
"/validation/eventcollector_noSessionId.json", 200, null);
+
+        
schemaService.deleteSchema("https://vendor.test.com/schemas/json/events/dummy/1-0-0";);
+        
schemaService.deleteSchema("https://vendor.test.com/schemas/json/events/dummy/properties/1-0-0";);
+        keepTrying("Event should be invalid",
+                () -> 
schemaService.isEventValid(resourceAsString("schemas/event-dummy-valid.json")),
+                isValid -> !isValid,
+                DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+    }
+
     @Test
     public void test_eventCollector_valid() throws Exception {
         // needed schema for event to be valid during tests
diff --git 
a/itests/src/test/resources/validation/eventcollector_noSessionId.json 
b/itests/src/test/resources/validation/eventcollector_noSessionId.json
new file mode 100644
index 000000000..9940058a4
--- /dev/null
+++ b/itests/src/test/resources/validation/eventcollector_noSessionId.json
@@ -0,0 +1,12 @@
+{
+  "events":[
+    {
+      "eventType":"dummy",
+      "scope":"dummy_scope",
+      "properties": {
+        "workspace": "dummy_workspace",
+        "path": "dummy/path"
+      }
+    }
+  ]
+}
diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 5f51a0561..b45e10e8f 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -230,6 +230,9 @@ 
http://dev.maxmind.com/geoip/geoip2/geolite2/[http://dev.maxmind.com/geoip/geoip
 
 Simply download the GeoLite2-City.mmdb file into the "etc" directory.
 
+`X-Forwarded-For` and the `remoteAddr` query parameter are used only when the 
direct peer is in
+`org.apache.unomi.ip.trustedProxies` (loopback and private ranges by default).
+
 [#_installing_geonames_database]
 === Installing Geonames database
 
@@ -1327,6 +1330,7 @@ Here are the default values for the location settings :
 ----
 # The following settings represent the default position that is used for 
localhost requests
 
org.apache.unomi.ip.database.location=${env:UNOMI_IP_DB:-${karaf.etc}/GeoLite2-City.mmdb}
+org.apache.unomi.ip.trustedProxies=${env:UNOMI_IP_TRUSTED_PROXIES:-127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fc00::/7,fe80::/10}
 org.apache.unomi.ip.default.countryCode=${env:UNOMI_IP_DEFAULT_COUNTRYCODE:-CH}
 
org.apache.unomi.ip.default.countryName=${env:UNOMI_IP_DEFAULT_COUNTRYNAME:-Switzerland}
 org.apache.unomi.ip.default.city=${env:UNOMI_IP_DEFAULT_CITY:-Geneva}
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index 4dd435a78..d7f343232 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -395,6 +395,7 @@ 
org.apache.unomi.groovy.actions.refresh.interval=${env:UNOMI_GROOVY_ACTION_REFRE
 ## MaxMind IP Database settings                                                
                                      ##
 
#######################################################################################################################
 
org.apache.unomi.ip.database.location=${env:UNOMI_IP_DB:-${karaf.etc}/GeoLite2-City.mmdb}
+org.apache.unomi.ip.trustedProxies=${env:UNOMI_IP_TRUSTED_PROXIES:-127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fc00::/7,fe80::/10}
 org.apache.unomi.ip.default.countryCode=${env:UNOMI_IP_DEFAULT_COUNTRYCODE:-CH}
 
org.apache.unomi.ip.default.countryName=${env:UNOMI_IP_DEFAULT_COUNTRYNAME:-Switzerland}
 org.apache.unomi.ip.default.city=${env:UNOMI_IP_DEFAULT_CITY:-Geneva}
diff --git 
a/plugins/request/src/main/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoAction.java
 
b/plugins/request/src/main/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoAction.java
index aa55033a2..a4d155eb8 100644
--- 
a/plugins/request/src/main/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoAction.java
+++ 
b/plugins/request/src/main/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoAction.java
@@ -39,14 +39,26 @@ import java.net.InetAddress;
 import java.net.NetworkInterface;
 import java.net.SocketException;
 import java.net.UnknownHostException;
+import java.util.ArrayList;
 import java.util.HashMap;
+import java.util.List;
 import java.util.Map;
 
 public class SetRemoteHostInfoAction implements ActionExecutor {
     private static final Logger LOGGER = 
LoggerFactory.getLogger(SetRemoteHostInfoAction.class.getName());
 
+    /**
+     * Default trusted proxies: loopback, RFC 1918 private ranges, link-local 
and IPv6 unique-local/link-local.
+     * Client-supplied address hints (remoteAddr parameter, X-Forwarded-For) 
are only honored when the direct
+     * peer of the connection is in this list.
+     */
+    static final String DEFAULT_TRUSTED_PROXIES =
+            
"127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fc00::/7,fe80::/10";
+
     private UserAgentDetectorServiceImpl userAgentDetectorService;
 
+    private List<String> trustedProxies = 
parseTrustedProxies(DEFAULT_TRUSTED_PROXIES);
+
     private DatabaseReader databaseReader;
     private String pathToGeoLocationDatabase;
 
@@ -71,6 +83,10 @@ public class SetRemoteHostInfoAction implements 
ActionExecutor {
         this.pathToGeoLocationDatabase = pathToGeoLocationDatabase;
     }
 
+    public void setTrustedProxies(String trustedProxies) {
+        this.trustedProxies = parseTrustedProxies(trustedProxies);
+    }
+
     public void setDefaultSessionCountryCode(String defaultSessionCountryCode) 
{
         this.defaultSessionCountryCode = defaultSessionCountryCode;
     }
@@ -116,17 +132,26 @@ public class SetRemoteHostInfoAction implements 
ActionExecutor {
 
         String remoteAddr = httpServletRequest.getRemoteAddr();
         LOGGER.debug("Remote address is {}", remoteAddr);
-        String remoteAddrParameter = 
httpServletRequest.getParameter("remoteAddr");
-        LOGGER.debug("Remote address param is {}", remoteAddrParameter);
-        String xff = httpServletRequest.getHeader("X-Forwarded-For");
-        LOGGER.debug("X-Forwarded-For is {}", xff);
-        if (remoteAddrParameter != null && !remoteAddrParameter.isEmpty()) {
-            remoteAddr = remoteAddrParameter;
-        } else if (xff != null && !xff.isEmpty()) {
-            if (xff.indexOf(',') > -1) {
-                xff = xff.substring(0, xff.indexOf(','));
+        // Honor X-Forwarded-For and remoteAddr only when the direct peer is a 
configured proxy.
+        if (isTrustedProxy(remoteAddr)) {
+            String remoteAddrParameter = 
httpServletRequest.getParameter("remoteAddr");
+            LOGGER.debug("Remote address param is {}", remoteAddrParameter);
+            String xff = httpServletRequest.getHeader("X-Forwarded-For");
+            LOGGER.debug("X-Forwarded-For is {}", xff);
+            if (remoteAddrParameter != null && !remoteAddrParameter.isEmpty()) 
{
+                remoteAddr = remoteAddrParameter;
+            } else if (xff != null && !xff.isEmpty()) {
+                // Walk from the last (proxy-appended, closest) entry 
backwards and use the first address
+                // that is not itself a trusted proxy.
+                String[] xffEntries = xff.split(",");
+                for (int i = xffEntries.length - 1; i >= 0; i--) {
+                    String candidate = xffEntries[i].trim();
+                    if (!isTrustedProxy(candidate)) {
+                        remoteAddr = candidate;
+                        break;
+                    }
+                }
             }
-            remoteAddr = xff;
         }
         LOGGER.debug("Remote address used to localized is {}", remoteAddr);
 
@@ -230,6 +255,71 @@ public class SetRemoteHostInfoAction implements 
ActionExecutor {
         return false;
     }
 
+    private static List<String> parseTrustedProxies(String value) {
+        List<String> result = new ArrayList<>();
+        if (value != null) {
+            for (String entry : value.split(",")) {
+                if (!entry.trim().isEmpty()) {
+                    result.add(entry.trim());
+                }
+            }
+        }
+        return result;
+    }
+
+    boolean isTrustedProxy(String address) {
+        if (address == null || address.isEmpty()) {
+            return false;
+        }
+        if (!InetAddressUtils.isIPv4Address(address) && 
!InetAddressUtils.isIPv6Address(address)) {
+            return false;
+        }
+        InetAddress addr;
+        try {
+            addr = InetAddress.getByName(address);
+        } catch (UnknownHostException e) {
+            return false;
+        }
+        for (String trusted : trustedProxies) {
+            if (matchesAddressOrCidr(addr, trusted)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    static boolean matchesAddressOrCidr(InetAddress addr, String entry) {
+        try {
+            int slash = entry.indexOf('/');
+            if (slash < 0) {
+                return InetAddress.getByName(entry).equals(addr);
+            }
+            InetAddress network = InetAddress.getByName(entry.substring(0, 
slash));
+            int prefixLength = Integer.parseInt(entry.substring(slash + 
1).trim());
+            byte[] addressBytes = addr.getAddress();
+            byte[] networkBytes = network.getAddress();
+            if (addressBytes.length != networkBytes.length || prefixLength < 0 
|| prefixLength > addressBytes.length * 8) {
+                return false;
+            }
+            int fullBytes = prefixLength / 8;
+            for (int i = 0; i < fullBytes; i++) {
+                if (addressBytes[i] != networkBytes[i]) {
+                    return false;
+                }
+            }
+            int remainingBits = prefixLength % 8;
+            if (remainingBits > 0) {
+                int mask = (0xFF << (8 - remainingBits)) & 0xFF;
+                if ((addressBytes[fullBytes] & mask) != 
(networkBytes[fullBytes] & mask)) {
+                    return false;
+                }
+            }
+            return true;
+        } catch (UnknownHostException | NumberFormatException e) {
+            return false;
+        }
+    }
+
     private static boolean isAValidIPAddress(String remoteAddr) {
         if (InetAddressUtils.isIPv4Address(remoteAddr) || 
InetAddressUtils.isIPv6Address(remoteAddr)) {
             InetAddress addr;
diff --git 
a/plugins/request/src/main/resources/OSGI-INF/blueprint/blueprint.xml 
b/plugins/request/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index b2ca19089..1f89c6117 100644
--- a/plugins/request/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/plugins/request/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -26,6 +26,8 @@
                              update-strategy="reload">
         <cm:default-properties>
             <cm:property name="request.ipDatabase.location" 
value="file:${karaf.etc}/GeoIP2-City.mmdb"/>
+            <cm:property name="request.trustedProxies"
+                         
value="127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fc00::/7,fe80::/10"/>
 
             <cm:property name="defaultSessionCountryCode" value="CH"/>
             <cm:property name="defaultSessionCountryName" value="Switzerland"/>
@@ -70,6 +72,7 @@
             <property name="userAgentDetectorService" 
ref="userAgentDetectorServiceImpl"/>
 
             <property name="pathToGeoLocationDatabase" 
value="${request.ipDatabase.location}"/>
+            <property name="trustedProxies" value="${request.trustedProxies}"/>
 
             <property name="defaultSessionCountryCode" 
value="${defaultSessionCountryCode}"/>
             <property name="defaultSessionCountryName" 
value="${defaultSessionCountryName}"/>
diff --git 
a/plugins/request/src/main/resources/org.apache.unomi.plugins.request.cfg 
b/plugins/request/src/main/resources/org.apache.unomi.plugins.request.cfg
index ac247ff4a..b8310110c 100644
--- a/plugins/request/src/main/resources/org.apache.unomi.plugins.request.cfg
+++ b/plugins/request/src/main/resources/org.apache.unomi.plugins.request.cfg
@@ -17,6 +17,11 @@
 
 
request.ipDatabase.location=${org.apache.unomi.ip.database.location:-${karaf.etc}/GeoLite2-City.mmdb}
 
+# Comma-separated list of IP addresses or CIDR ranges of reverse proxies that 
may supply
+# the client address via the X-Forwarded-For header or the remoteAddr query 
parameter.
+# Address hints are ignored when the direct peer is not in this list.
+request.trustedProxies=${org.apache.unomi.ip.trustedProxies:-127.0.0.0/8,::1,10.0.0.0/8,172.16.0.0/12,192.168.0.0/16,169.254.0.0/16,fc00::/7,fe80::/10}
+
 # The following settings represent the default position that is used for 
localhost requests
 defaultSessionCountryCode=${org.apache.unomi.ip.default.countryCode:-CH}
 
defaultSessionCountryName=${org.apache.unomi.ip.default.countryName:-Switzerland}
diff --git 
a/plugins/request/src/test/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoActionTest.java
 
b/plugins/request/src/test/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoActionTest.java
new file mode 100644
index 000000000..c6bf32ae8
--- /dev/null
+++ 
b/plugins/request/src/test/java/org/apache/unomi/plugins/request/actions/SetRemoteHostInfoActionTest.java
@@ -0,0 +1,86 @@
+/*
+ * 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.plugins.request.actions;
+
+import org.junit.Before;
+import org.junit.Test;
+
+import java.net.InetAddress;
+
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Tests for the trusted-proxy gating of client-supplied address hints in 
{@link SetRemoteHostInfoAction}.
+ */
+public class SetRemoteHostInfoActionTest {
+
+    private SetRemoteHostInfoAction action;
+
+    @Before
+    public void init() {
+        this.action = new SetRemoteHostInfoAction();
+    }
+
+    @Test
+    public void defaultTrustedProxies_includeLoopbackAndPrivateRanges() {
+        assertTrue(action.isTrustedProxy("127.0.0.1"));
+        assertTrue(action.isTrustedProxy("10.20.30.40"));
+        assertTrue(action.isTrustedProxy("172.16.5.5"));
+        assertTrue(action.isTrustedProxy("192.168.1.254"));
+        assertTrue(action.isTrustedProxy("::1"));
+    }
+
+    @Test
+    public void defaultTrustedProxies_excludePublicAddresses() {
+        assertFalse(action.isTrustedProxy("203.0.113.50"));
+        assertFalse(action.isTrustedProxy("8.8.8.8"));
+        assertFalse(action.isTrustedProxy("2001:4860:4860::8888"));
+        // 172.32.x.x is just outside 172.16.0.0/12
+        assertFalse(action.isTrustedProxy("172.32.0.1"));
+    }
+
+    @Test
+    public void isTrustedProxy_rejectsNonIpValues() {
+        assertFalse(action.isTrustedProxy(null));
+        assertFalse(action.isTrustedProxy(""));
+        assertFalse(action.isTrustedProxy("evil.example.com"));
+        assertFalse(action.isTrustedProxy("not-an-ip"));
+    }
+
+    @Test
+    public void setTrustedProxies_overridesDefaults() {
+        action.setTrustedProxies("198.51.100.7,203.0.113.0/24");
+        assertTrue(action.isTrustedProxy("198.51.100.7"));
+        assertTrue(action.isTrustedProxy("203.0.113.99"));
+        assertFalse(action.isTrustedProxy("127.0.0.1"));
+        assertFalse(action.isTrustedProxy("10.0.0.1"));
+    }
+
+    @Test
+    public void matchesAddressOrCidr_handlesExactAndPrefixMatches() throws 
Exception {
+        
assertTrue(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("192.0.2.1"),
 "192.0.2.1"));
+        
assertFalse(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("192.0.2.2"),
 "192.0.2.1"));
+        
assertTrue(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("192.0.2.130"),
 "192.0.2.128/25"));
+        
assertFalse(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("192.0.2.1"),
 "192.0.2.128/25"));
+        
assertTrue(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("fc00::1234"),
 "fc00::/7"));
+        // an IPv4 address never matches an IPv6 range
+        
assertFalse(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("10.0.0.1"),
 "fc00::/7"));
+        // malformed entries never match
+        
assertFalse(SetRemoteHostInfoAction.matchesAddressOrCidr(InetAddress.getByName("10.0.0.1"),
 "10.0.0.0/abc"));
+    }
+}
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventsCollectorEndpoint.java
 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventsCollectorEndpoint.java
index 48e7e5c7f..1d8d4d31d 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/endpoints/EventsCollectorEndpoint.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/endpoints/EventsCollectorEndpoint.java
@@ -24,7 +24,9 @@ import org.apache.unomi.api.EventsCollectorRequest;
 import org.apache.unomi.api.security.UnomiRoles;
 import org.apache.unomi.rest.exception.InvalidRequestException;
 import org.apache.unomi.rest.models.EventCollectorResponse;
+import org.apache.unomi.rest.service.RequestIdentifierValidator;
 import org.apache.unomi.rest.service.RestServiceUtils;
+import org.apache.unomi.schema.api.SchemaService;
 import org.apache.unomi.tracing.api.TracerService;
 import org.apache.unomi.utils.EventsRequestContext;
 import org.osgi.service.component.annotations.Component;
@@ -63,6 +65,9 @@ public class EventsCollectorEndpoint {
     @Reference
     private TracerService tracerService;
 
+    @Reference
+    private SchemaService schemaService;
+
     @Context
     HttpServletRequest request;
     @Context
@@ -153,6 +158,7 @@ public class EventsCollectorEndpoint {
             String sessionId = eventsCollectorRequest.getSessionId();
             if (sessionId == null) {
                 sessionId = request.getParameter("sessionId");
+                
RequestIdentifierValidator.requireValidSessionId(schemaService, sessionId);
             }
 
             String profileId = eventsCollectorRequest.getProfileId();
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/service/RequestIdentifierValidator.java
 
b/rest/src/main/java/org/apache/unomi/rest/service/RequestIdentifierValidator.java
new file mode 100644
index 000000000..e2c6d4095
--- /dev/null
+++ 
b/rest/src/main/java/org/apache/unomi/rest/service/RequestIdentifierValidator.java
@@ -0,0 +1,50 @@
+/*
+ * 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.service;
+
+import com.fasterxml.jackson.databind.node.JsonNodeFactory;
+import com.fasterxml.jackson.databind.node.ObjectNode;
+import org.apache.unomi.rest.exception.InvalidRequestException;
+import org.apache.unomi.schema.api.SchemaService;
+
+/**
+ * Shared identifier checks used by public tracker endpoints.
+ */
+public final class RequestIdentifierValidator {
+
+    static final String REQUEST_IDS_SCHEMA = 
"https://unomi.apache.org/schemas/json/rest/requestIds/1-0-0";;
+
+    private RequestIdentifierValidator() {
+    }
+
+    /**
+     * Validates a session id that arrived outside the JSON body (query 
parameter fallback).
+     *
+     * @param schemaService schema service used to apply the requestIds grammar
+     * @param sessionId     session identifier, may be {@code null}
+     */
+    public static void requireValidSessionId(SchemaService schemaService, 
String sessionId) {
+        if (sessionId == null) {
+            return;
+        }
+        ObjectNode paramsAsJson = JsonNodeFactory.instance.objectNode();
+        paramsAsJson.put("sessionId", sessionId);
+        if (!schemaService.isValid(paramsAsJson.toString(), 
REQUEST_IDS_SCHEMA)) {
+            throw new InvalidRequestException("Invalid sessionId query 
parameter", "Invalid received data");
+        }
+    }
+}
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/service/RequestIdentifierValidatorTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/service/RequestIdentifierValidatorTest.java
new file mode 100644
index 000000000..33bef8ff5
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/service/RequestIdentifierValidatorTest.java
@@ -0,0 +1,57 @@
+/*
+ * 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.service;
+
+import org.apache.unomi.rest.exception.InvalidRequestException;
+import org.apache.unomi.schema.api.SchemaService;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.verifyNoInteractions;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class RequestIdentifierValidatorTest {
+
+    @Mock
+    private SchemaService schemaService;
+
+    @Test
+    void requireValidSessionId_skipsNull() {
+        assertDoesNotThrow(() -> 
RequestIdentifierValidator.requireValidSessionId(schemaService, null));
+        verifyNoInteractions(schemaService);
+    }
+
+    @Test
+    void requireValidSessionId_acceptsSchemaMatch() {
+        when(schemaService.isValid(anyString(), 
eq(RequestIdentifierValidator.REQUEST_IDS_SCHEMA))).thenReturn(true);
+        assertDoesNotThrow(() -> 
RequestIdentifierValidator.requireValidSessionId(schemaService, 
"dummy-session-id"));
+    }
+
+    @Test
+    void requireValidSessionId_rejectsSchemaMismatch() {
+        when(schemaService.isValid(anyString(), 
eq(RequestIdentifierValidator.REQUEST_IDS_SCHEMA))).thenReturn(false);
+        assertThrows(InvalidRequestException.class,
+                () -> 
RequestIdentifierValidator.requireValidSessionId(schemaService, 
"<script>alert();</script>"));
+    }
+}

Reply via email to