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 <[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 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>")); + } +}
