You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/09/18 15:21:53 UTC
(unomi) branch validate-public-session-and-client-ip updated (3a8b785fc -> f3557a8af)
This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a change to branch validate-public-session-and-client-ip
in repository https://gitbox.apache.org/repos/asf/unomi.git
discard 3a8b785fc Validate eventcollector query session ids and honor client IPs only from configured proxies.
add 81504c791 UNOMI-978: Gate cross-profile merge and systemProperties writes on a trusted caller
new ecb2b3c82 Write filtered personalization contents back onto the request.
new cdf479c2e Keep personalization fallback when mixed contents include a script value.
new 1ee5c319d Deny public MVEL eval APIs from the scripting class loader.
new c0497920f Drop credentialed CORS on the public tracker endpoints.
new f3557a8af Validate eventcollector query session ids and honor client IPs only from configured proxies.
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (3a8b785fc)
\
N -- N -- N refs/heads/validate-public-session-and-client-ip (f3557a8af)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 5 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
.../test/java/org/apache/unomi/itests/BasicIT.java | 29 +-
.../org/apache/unomi/itests/ContextServletIT.java | 233 ++++++++++++
.../org/apache/unomi/itests/ProfileMergeIT.java | 65 ++++
.../unomi/itests/PropertiesUpdateActionIT.java | 76 ++++
.../mvel-payload-personalization-mixed.json | 32 ++
manual/src/main/asciidoc/configuration.adoc | 9 +-
manual/src/main/asciidoc/samples/login-sample.adoc | 203 +++++++++--
.../main/resources/etc/custom.system.properties | 4 +-
package/src/main/resources/etc/mvel-forbid.json | 2 +-
.../actions/MergeProfilesOnPropertyAction.java | 32 ++
.../baseplugin/actions/UpdatePropertiesAction.java | 71 +++-
.../resources/OSGI-INF/blueprint/blueprint.xml | 1 +
.../actions/MergeProfilesOnPropertyActionTest.java | 136 +++++++
.../actions/UpdatePropertiesActionTest.java | 193 ++++++++++
.../unomi/rest/endpoints/ClientEndpoint.java | 2 +-
.../unomi/rest/endpoints/ContextJsonEndpoint.java | 3 +-
.../rest/endpoints/EventsCollectorEndpoint.java | 2 +-
.../rest/endpoints/ContextJsonEndpointTest.java | 28 ++
.../rest/endpoints/PublicTrackerCorsTest.java | 52 +++
samples/login-integration/pom.xml | 44 ++-
samples/login-integration/setup.sh | 299 ++++++++++++++++
.../unomi/samples/login/LoginSampleResources.java | 29 +-
.../apache/unomi/samples/login/LoginServlet.java | 395 +++++++++++++++++++++
.../src/main/resources/static/index.html | 75 ++++
.../resources/static/javascript/login-example.js | 58 +++
.../src/main/webapp/WEB-INF/web.xml | 24 --
.../login-integration/src/main/webapp/index.html | 70 ----
.../src/main/webapp/javascript/login-example.js | 139 --------
.../unomi/samples/login/LoginServletTest.java | 353 ++++++++++++++++++
.../apache/unomi/scripting/ExpressionFilter.java | 15 +-
.../scripting/SecureFilteringClassLoader.java | 122 +++++--
.../unomi/scripting/ExpressionFilterTest.java | 46 +++
.../unomi/scripting/MvelScriptExecutorTest.java | 62 +++-
.../scripting/SecureFilteringClassLoaderTest.java | 91 +++++
34 files changed, 2659 insertions(+), 336 deletions(-)
create mode 100644 itests/src/test/resources/security/mvel-payload-personalization-mixed.json
create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
create mode 100644 rest/src/test/java/org/apache/unomi/rest/endpoints/PublicTrackerCorsTest.java
create mode 100755 samples/login-integration/setup.sh
copy rest/src/main/java/org/apache/unomi/rest/server/RestServerBus.java => samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginSampleResources.java (59%)
create mode 100644 samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
create mode 100644 samples/login-integration/src/main/resources/static/index.html
create mode 100644 samples/login-integration/src/main/resources/static/javascript/login-example.js
delete mode 100644 samples/login-integration/src/main/webapp/WEB-INF/web.xml
delete mode 100644 samples/login-integration/src/main/webapp/index.html
delete mode 100644 samples/login-integration/src/main/webapp/javascript/login-example.js
create mode 100644 samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
create mode 100644 scripting/src/test/java/org/apache/unomi/scripting/ExpressionFilterTest.java
create mode 100644 scripting/src/test/java/org/apache/unomi/scripting/SecureFilteringClassLoaderTest.java
(unomi) 01/01: Validate eventcollector query session ids and honor client IPs only from configured proxies.
Posted by sh...@apache.org.
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 f3557a8afe6a0d60130436c608f0c6eaa2a5ef7c
Author: Serge Huber <sh...@apache.org>
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 ce7b04dec..41fa45294 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
@@ -1330,6 +1333,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 645d742ba..2dee19c2a 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -397,6 +397,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 b6f646e24..ce0f579b0 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>"));
+ }
+}