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 60edd20c9 Drop credentialed CORS on the public tracker endpoints (#878)
60edd20c9 is described below
commit 60edd20c9f00c7ff612004a334332b106a52aeab
Author: Serge Huber <[email protected]>
AuthorDate: Thu Sep 24 09:28:01 2026 +0200
Drop credentialed CORS on the public tracker endpoints (#878)
---
.../org/apache/unomi/itests/InputValidationIT.java | 28 ++++++
.../validation/eventcollector_noSessionId.json | 12 +++
manual/src/main/asciidoc/configuration.adoc | 6 +-
.../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 ++++++++++++++++
.../unomi/rest/endpoints/ClientEndpoint.java | 2 +-
.../unomi/rest/endpoints/ContextJsonEndpoint.java | 2 +-
.../rest/endpoints/EventsCollectorEndpoint.java | 8 +-
.../rest/service/RequestIdentifierValidator.java | 50 ++++++++++
.../rest/endpoints/PublicTrackerCorsTest.java | 52 ++++++++++
.../service/RequestIdentifierValidatorTest.java | 57 +++++++++++
14 files changed, 408 insertions(+), 14 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 f77684741..4c5a885bb 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
@@ -452,7 +455,7 @@ curl -X GET "http://localhost:8181/cxs/tenants" \
Authentication rules (Unomi 3.1):
-1. Public paths (for example `/cxs/context.json`, `/cxs/eventcollector`,
`/cxs/client/*`) require a valid **public** API key via `X-Unomi-Api-Key`
(unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled).
+1. Public paths (for example `/cxs/context.json`, `/cxs/eventcollector`,
`/cxs/client/*`) require a valid **public** API key via `X-Unomi-Api-Key`
(unless <<_v2_compatibility_mode,V2 compatibility mode>> is enabled). Browser
clients may call them from any origin; those responses do not include
credentialed CORS headers.
2. Private paths accept `tenantId:privateApiKey` Basic auth, or JAAS admin
credentials (often with `X-Unomi-Tenant-Id` when a tenant context is required).
3. Tenant administration (`/cxs/tenants`) requires system administrator JAAS
credentials.
4. JAAS admin does **not** replace the public API key on public endpoints.
@@ -1344,6 +1347,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 5bc1c152c..1b7625aa5 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -399,6 +399,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/ClientEndpoint.java
b/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
index 6fae685b0..7a8be14df 100644
--- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ClientEndpoint.java
@@ -43,7 +43,7 @@ import java.util.Set;
/**
* A servlet filter to serve a context-specific Javascript containing the
current request context object.
*/
-@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true)
+@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = false)
@Path("/")
@Component(service = ClientEndpoint.class, property =
"osgi.jaxrs.resource=true")
public class ClientEndpoint {
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 8fa5d56d3..0531750b8 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
@@ -57,7 +57,7 @@ import java.util.stream.Collectors;
* events, evaluate personalization filters, and return consented profile data.
*/
@Consumes(MediaType.APPLICATION_JSON)
-@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true)
+@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = false)
@Path("/")
@Component(service = ContextJsonEndpoint.class, property =
"osgi.jaxrs.resource=true")
public class ContextJsonEndpoint {
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..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;
@@ -49,7 +51,7 @@ import java.util.List;
*/
@Produces(MediaType.APPLICATION_JSON + ";charset=UTF-8")
@Consumes(MediaType.APPLICATION_JSON)
-@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true)
+@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = false)
@Path("/")
@Component(service = EventsCollectorEndpoint.class, property =
"osgi.jaxrs.resource=true")
public class EventsCollectorEndpoint {
@@ -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/endpoints/PublicTrackerCorsTest.java
b/rest/src/test/java/org/apache/unomi/rest/endpoints/PublicTrackerCorsTest.java
new file mode 100644
index 000000000..d9b0bc594
--- /dev/null
+++
b/rest/src/test/java/org/apache/unomi/rest/endpoints/PublicTrackerCorsTest.java
@@ -0,0 +1,52 @@
+/*
+ * 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.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Public tracker endpoints keep a broad origin policy and do not advertise
credentialed CORS.
+ */
+class PublicTrackerCorsTest {
+
+ @Test
+ void contextJsonAllowsAnyOriginWithoutCredentials() {
+ assertPublicTrackerCors(ContextJsonEndpoint.class);
+ }
+
+ @Test
+ void eventsCollectorAllowsAnyOriginWithoutCredentials() {
+ assertPublicTrackerCors(EventsCollectorEndpoint.class);
+ }
+
+ @Test
+ void clientAllowsAnyOriginWithoutCredentials() {
+ assertPublicTrackerCors(ClientEndpoint.class);
+ }
+
+ private static void assertPublicTrackerCors(Class<?> endpoint) {
+ CrossOriginResourceSharing cors =
endpoint.getAnnotation(CrossOriginResourceSharing.class);
+ assertNotNull(cors, endpoint.getSimpleName() + " must declare CORS");
+ assertTrue(cors.allowAllOrigins());
+ assertFalse(cors.allowCredentials());
+ }
+}
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>"));
+ }
+}