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

sergehuber pushed a commit to branch UNOMI-1005-tracker-cookie-origins
in repository https://gitbox.apache.org/repos/asf/unomi.git

commit 3da34b6ad2ec4fe0d196a98da92b78ce4043528b
Author: Serge Huber <[email protected]>
AuthorDate: Fri Oct 16 14:32:24 2026 +0200

    UNOMI-1005: Allow visitor cookies only for listed website origins
    
    Public tracker endpoints stay open to any origin and do not send cookies
    unless the operator lists that origin. A listed origin is echoed exactly,
    with credentials and Vary: Origin. An unlisted origin is not bound to the
    visitor cookie. SameSite=None is used only with Secure and a non-empty list.
---
 manual/src/main/asciidoc/configuration.adoc        |   2 +-
 manual/src/main/asciidoc/web-tracker.adoc          |   2 +
 .../main/resources/etc/custom.system.properties    |   7 ++
 .../unomi/rest/cors/PublicTrackerCorsFilter.java   |  98 ++++++++++++++++
 .../unomi/rest/cors/PublicTrackerCorsPolicy.java   | 106 +++++++++++++++++
 .../org/apache/unomi/rest/server/RestServer.java   |   2 +
 .../rest/service/impl/RestServiceUtilsImpl.java    |  12 ++
 .../java/org/apache/unomi/utils/HttpUtils.java     |  11 +-
 .../config/ShippedProfileCookieConfigTest.java     |   6 +
 .../rest/cors/PublicTrackerCorsFilterTest.java     | 128 +++++++++++++++++++++
 .../rest/cors/PublicTrackerCorsPolicyTest.java     |  76 ++++++++++++
 .../RestServiceUtilsImplProfileBindingTest.java    |  32 ++++++
 .../unomi/utils/ProfileCookieSameSiteTest.java     |  71 ++++++++++++
 .../org/apache/unomi/web/servlets/WebConfig.java   |  12 ++
 .../src/main/resources/org.apache.unomi.web.cfg    |   6 +
 15 files changed, 569 insertions(+), 2 deletions(-)

diff --git a/manual/src/main/asciidoc/configuration.adoc 
b/manual/src/main/asciidoc/configuration.adoc
index 3ba40441e..4f2314d4a 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -467,7 +467,7 @@ curl -X GET "http://localhost:8181/cxs/tenants"; \
 
 Authentication rules (Unomi 4.0):
 
-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.
+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. Cookies are allowed only for origins 
listed in `org.apache.unomi.public.cors.allowedOrigins` (environment variable 
`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`). The list is exact scheme, host and port, 
with no wildcards. The default is  [...]
 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.
diff --git a/manual/src/main/asciidoc/web-tracker.adoc 
b/manual/src/main/asciidoc/web-tracker.adoc
index 807dbe7d5..e9716cef9 100644
--- a/manual/src/main/asciidoc/web-tracker.adoc
+++ b/manual/src/main/asciidoc/web-tracker.adoc
@@ -213,6 +213,8 @@ It is possible to change the name of these cookie by 
passing the following prope
 
 Please note however that the `contextServerCookieName` will also have to be 
changed in the server configuration in order for it to work. See the 
<<_configuration,Configuration>> section for details on how to do this.
 
+The tracker sends requests with cookies. When the website and Unomi are on 
different sites, list the website origin on the server 
(`UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS`). If the deployment does not need the 
visitor cookie, set `sendCredentials` to `false` in `wemInitConfig`. Requests 
are then sent without cookies and work with the default server settings.
+
 For session tracking, it is important to provide a value for the cookie 
otherwise the tracker will not initialize (a message is displayed in the 
console that explains that the session cookie is missing). Here is the code 
excerpt from the initialization code used in the tutorial that creates the 
initial cookie value.
 
 [source,javascript]
diff --git a/package/src/main/resources/etc/custom.system.properties 
b/package/src/main/resources/etc/custom.system.properties
index eb1aeed2d..05d0edbde 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -321,6 +321,13 @@ 
org.apache.unomi.profile.cookie.maxAgeInSeconds=${env:UNOMI_PROFILE_COOKIE_MAXAG
 # This setting controls if the cookie should be flagged as HttpOnly or not.
 # Default true so browser JavaScript cannot read the profile bearer cookie.
 
org.apache.unomi.profile.cookie.httpOnly=${env:UNOMI_PROFILE_COOKIE_HTTPONLY:-true}
+# SameSite for the visitor cookie. Keep Lax unless the sites really are 
different and the cookie is Secure.
+# None is refused unless Secure is also set and 
UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS is not empty.
+org.apache.unomi.profile.cookie.sameSite=${env:UNOMI_PROFILE_COOKIE_SAMESITE:-Lax}
+# Website origins that may send the visitor cookie to the public tracker 
endpoints.
+# Example: https://www.example.com,https://shop.example.com:8443
+# Leave empty (the default) so no other origin may send cookies. Do not use * .
+org.apache.unomi.public.cors.allowedOrigins=${env:UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS:-}
 #Allowed profile download formats, actually only csv (horizontal and 
vertical), json, text and yaml are allowed.
 
org.apache.unomi.profile.download.formats=${env:UNOMI_PROFILE_DOWNLOAD_FORMATS:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking 
that the requests do not exceed the limit.
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java 
b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
new file mode 100644
index 000000000..c84ee1bdd
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilter.java
@@ -0,0 +1,98 @@
+/*
+ * 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.cors;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+
+import jakarta.annotation.Priority;
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerRequestFilter;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.container.ContainerResponseFilter;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.Response;
+import java.io.IOException;
+
+/**
+ * Allows cookies on public tracker responses only for origins the operator 
listed.
+ * Priority 1 runs first on the request (so a listed preflight is answered 
here) and last on the
+ * response (so it can replace the broad no-credentials CORS headers).
+ */
+@Priority(1)
+public class PublicTrackerCorsFilter implements ContainerRequestFilter, 
ContainerResponseFilter {
+
+    private final ConfigSharingService configSharingService;
+
+    public PublicTrackerCorsFilter(ConfigSharingService configSharingService) {
+        this.configSharingService = configSharingService;
+    }
+
+    @Override
+    public void filter(ContainerRequestContext requestContext) throws 
IOException {
+        if (!isPublic(requestContext) || 
!"OPTIONS".equalsIgnoreCase(requestContext.getMethod())) {
+            return;
+        }
+        String origin = requestContext.getHeaderString("Origin");
+        if (!policy().allows(origin)) {
+            return;
+        }
+        Response response = Response.ok().build();
+        applyCredentialedHeaders(response.getHeaders(), origin);
+        response.getHeaders().putSingle("Access-Control-Allow-Methods", "GET, 
POST, OPTIONS");
+        response.getHeaders().putSingle("Access-Control-Allow-Headers",
+                "Origin, X-Requested-With, Content-Type, Accept, 
X-Unomi-Api-Key");
+        requestContext.abortWith(response);
+    }
+
+    @Override
+    public void filter(ContainerRequestContext requestContext, 
ContainerResponseContext responseContext) {
+        if (!isPublic(requestContext)) {
+            return;
+        }
+        String origin = requestContext.getHeaderString("Origin");
+        MultivaluedMap<String, Object> headers = responseContext.getHeaders();
+        if (policy().allows(origin)) {
+            applyCredentialedHeaders(headers, origin);
+            return;
+        }
+        // Never echo an unlisted origin and never advertise cookies with a 
wildcard origin.
+        if (origin != null) {
+            headers.putSingle("Access-Control-Allow-Origin", "*");
+            headers.remove("Access-Control-Allow-Credentials");
+        }
+    }
+
+    private PublicTrackerCorsPolicy policy() {
+        Object configured = configSharingService == null ? null : 
configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY);
+        return PublicTrackerCorsPolicy.parse(configured == null ? null : 
configured.toString());
+    }
+
+    private static boolean isPublic(ContainerRequestContext requestContext) {
+        if (requestContext.getUriInfo() == null || 
requestContext.getUriInfo().getPath() == null) {
+            return false;
+        }
+        return 
PublicTrackerCorsPolicy.isPublicTrackerPath(requestContext.getUriInfo().getPath());
+    }
+
+    private static void applyCredentialedHeaders(MultivaluedMap<String, 
Object> headers, String origin) {
+        headers.putSingle("Access-Control-Allow-Origin", origin);
+        headers.putSingle("Access-Control-Allow-Credentials", "true");
+        if (!headers.containsKey("Vary") || 
headers.get("Vary").stream().noneMatch(value -> 
String.valueOf(value).contains("Origin"))) {
+            headers.add("Vary", "Origin");
+        }
+    }
+}
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java 
b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
new file mode 100644
index 000000000..2409189bb
--- /dev/null
+++ b/rest/src/main/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicy.java
@@ -0,0 +1,106 @@
+/*
+ * 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.cors;
+
+import java.util.Collections;
+import java.util.LinkedHashSet;
+import java.util.Set;
+import java.util.regex.Pattern;
+
+/**
+ * Operator allow-list of website origins that may send the visitor cookie to 
public tracker endpoints.
+ * Comparison is exact (scheme, host and port). Wildcards and the {@code null} 
origin are never allowed.
+ * An empty list is the secure default: no cross-site cookies.
+ */
+public final class PublicTrackerCorsPolicy {
+
+    public static final String ALLOWED_ORIGINS_PROPERTY = 
"publicCorsAllowedOrigins";
+    public static final String SAME_SITE_PROPERTY = "profileIdCookieSameSite";
+
+    private static final Pattern ORIGIN = 
Pattern.compile("^https?://[A-Za-z0-9.-]+(:[0-9]{1,5})?$");
+
+    private final Set<String> allowedOrigins;
+
+    public PublicTrackerCorsPolicy(Set<String> allowedOrigins) {
+        this.allowedOrigins = allowedOrigins;
+    }
+
+    public static PublicTrackerCorsPolicy parse(String configured) {
+        if (configured == null || configured.isBlank()) {
+            return new PublicTrackerCorsPolicy(Collections.emptySet());
+        }
+        Set<String> origins = new LinkedHashSet<>();
+        for (String part : configured.split(",")) {
+            String origin = part.trim();
+            if (origin.isEmpty() || !isExactOrigin(origin)) {
+                continue;
+            }
+            origins.add(origin);
+        }
+        return new 
PublicTrackerCorsPolicy(Collections.unmodifiableSet(origins));
+    }
+
+    /**
+     * An origin is exact when it is {@code http} or {@code https}, a host, 
and an optional port,
+     * with no path, query, user info, or wildcard.
+     */
+    public static boolean isExactOrigin(String origin) {
+        return origin != null && ORIGIN.matcher(origin).matches();
+    }
+
+    public boolean isEmpty() {
+        return allowedOrigins.isEmpty();
+    }
+
+    public boolean allows(String origin) {
+        return origin != null && allowedOrigins.contains(origin);
+    }
+
+    /**
+     * {@code SameSite=None} is only valid together with {@code Secure} and a 
non-empty origin list.
+     * Anything else falls back to {@code Lax}.
+     */
+    public String resolveSameSite(String requested, boolean secure) {
+        if (requested == null || requested.isBlank()) {
+            return "Lax";
+        }
+        String value = requested.trim();
+        if ("None".equalsIgnoreCase(value)) {
+            if (secure && !allowedOrigins.isEmpty()) {
+                return "None";
+            }
+            return "Lax";
+        }
+        if ("Strict".equalsIgnoreCase(value)) {
+            return "Strict";
+        }
+        return "Lax";
+    }
+
+    public static boolean isPublicTrackerPath(String path) {
+        if (path == null) {
+            return false;
+        }
+        String normalized = path.startsWith("/") ? path.substring(1) : path;
+        return normalized.equals("context.json")
+                || normalized.startsWith("context.json/")
+                || normalized.equals("eventcollector")
+                || normalized.startsWith("eventcollector/")
+                || normalized.equals("client")
+                || normalized.startsWith("client/");
+    }
+}
diff --git a/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java 
b/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
index 0f98aac21..fade79ec1 100644
--- a/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
+++ b/rest/src/main/java/org/apache/unomi/rest/server/RestServer.java
@@ -39,6 +39,7 @@ import org.apache.unomi.api.services.ExecutionContextManager;
 import org.apache.unomi.api.tenants.ApiKey;
 import org.apache.unomi.api.tenants.TenantService;
 import org.apache.unomi.persistence.spi.CustomObjectMapper;
+import org.apache.unomi.rest.cors.PublicTrackerCorsFilter;
 import org.apache.unomi.rest.authentication.AuthenticationFilter;
 import org.apache.unomi.rest.authentication.AuthorizingInterceptor;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
@@ -401,6 +402,7 @@ public class RestServer {
         jaxrsServerFactoryBean.setBus(serverBus);
         jaxrsServerFactoryBean.setProvider(new 
JacksonJaxbJsonProvider(objectMapper, 
JacksonJaxbJsonProvider.DEFAULT_ANNOTATIONS));
         jaxrsServerFactoryBean.setProvider(new 
CrossOriginResourceSharingFilter());
+        jaxrsServerFactoryBean.setProvider(new 
PublicTrackerCorsFilter(configSharingService));
         jaxrsServerFactoryBean.setProvider(new 
RetroCompatibilityParamConverterProvider(objectMapper));
 
         // Authentication and Security filters in order of priority
diff --git 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index 438ae8e20..788faddea 100644
--- 
a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ 
b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -32,6 +32,7 @@ import org.apache.unomi.api.services.PrivacyService;
 import org.apache.unomi.api.services.ProfileService;
 import org.apache.unomi.api.tenants.Tenant;
 import org.apache.unomi.api.tenants.TenantService;
+import org.apache.unomi.rest.cors.PublicTrackerCorsPolicy;
 import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
 import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
 import org.apache.unomi.services.common.security.SecurityUtils;
@@ -100,6 +101,12 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
 
     @Override
     public String getProfileIdCookieValue(HttpServletRequest 
httpServletRequest) {
+        String origin = httpServletRequest.getHeader("Origin");
+        if (origin != null && !publicCorsPolicy().allows(origin)) {
+            // A browser may still send a SameSite=None cookie. Do not bind 
that visitor
+            // when the page origin is not on the operator list.
+            return null;
+        }
         String cookieProfileId = null;
 
         Cookie[] cookies = httpServletRequest.getCookies();
@@ -119,6 +126,11 @@ public class RestServiceUtilsImpl implements 
RestServiceUtils {
         return cookieProfileId;
     }
 
+    private PublicTrackerCorsPolicy publicCorsPolicy() {
+        Object configured = 
configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY);
+        return PublicTrackerCorsPolicy.parse(configured == null ? null : 
configured.toString());
+    }
+
     @Override
     public EventsRequestContext initEventsRequest(String scope, String 
sessionId, String profileId, String personaId,
                                                   boolean invalidateProfile, 
boolean invalidateSession,
diff --git a/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java 
b/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
index 8242c39c1..88d1c1012 100644
--- a/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
+++ b/rest/src/main/java/org/apache/unomi/utils/HttpUtils.java
@@ -25,6 +25,7 @@ import org.apache.commons.lang3.StringUtils;
 import org.apache.unomi.api.Event;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.rest.cors.PublicTrackerCorsPolicy;
 import org.apache.unomi.schema.api.SchemaService;
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
@@ -130,11 +131,19 @@ public class HttpUtils {
         final String profileIdCookieName = (String) 
configSharingService.getProperty("profileIdCookieName");
         final Integer profileIdCookieMaxAgeInSeconds = (Integer) 
configSharingService.getProperty("profileIdCookieMaxAgeInSeconds");
         final Boolean profileIdCookieHttpOnly = (Boolean) 
configSharingService.getProperty("profileIdCookieHttpOnly");
+        Object configuredOrigins = 
configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY);
+        Object configuredSameSite = 
configSharingService.getProperty(PublicTrackerCorsPolicy.SAME_SITE_PROPERTY);
+        PublicTrackerCorsPolicy policy = 
PublicTrackerCorsPolicy.parse(configuredOrigins == null ? null : 
configuredOrigins.toString());
+        String requestedSameSite = configuredSameSite == null ? "Lax" : 
configuredSameSite.toString();
+        String sameSite = policy.resolveSameSite(requestedSameSite, isSecure);
+        if ("None".equalsIgnoreCase(requestedSameSite) && 
!"None".equals(sameSite)) {
+            LOGGER.warn("Refusing SameSite=None for the profile cookie; Secure 
and a non-empty public CORS origin list are both required. Using 
SameSite=Lax.");
+        }
         return profileIdCookieName + "=" + profile.getItemId() +
                 "; Path=/" +
                 "; Max-Age=" + profileIdCookieMaxAgeInSeconds +
                 (StringUtils.isNotBlank(profileIdCookieDomain) ? ("; Domain=" 
+ profileIdCookieDomain) : "") +
-                "; SameSite=Lax" +
+                "; SameSite=" + sameSite +
                 (isSecure ? "; Secure" : "") +
                 (profileIdCookieHttpOnly ? "; HttpOnly" : "");
     }
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
index fe27728e9..f38e541a7 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
@@ -51,6 +51,12 @@ class ShippedProfileCookieConfigTest {
         String systemProperties = 
Files.readString(repoFile(SYSTEM_PROPERTIES_PATH));
         
assertTrue(systemProperties.matches("(?s).*org\\.apache\\.unomi\\.profile\\.cookie\\.httpOnly=\\$\\{[^}]*:-true}.*"),
                 "profile cookie HttpOnly should default to true in " + 
SYSTEM_PROPERTIES_PATH);
+        
assertTrue(systemProperties.contains("UNOMI_PUBLIC_CORS_ALLOWED_ORIGINS"),
+                "public CORS origin list should be reachable from the 
environment");
+        assertTrue(systemProperties.contains("UNOMI_PROFILE_COOKIE_SAMESITE"),
+                "profile cookie SameSite should be reachable from the 
environment");
+        
assertTrue(webCfg.contains("public.cors.allowedOrigins=${org.apache.unomi.public.cors.allowedOrigins:-}"),
+                "web config should read the public CORS origin list");
     }
 
     private static Path repoFile(String relativePath) throws IOException {
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
new file mode 100644
index 000000000..39dadd5e9
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsFilterTest.java
@@ -0,0 +1,128 @@
+/*
+ * 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.cors;
+
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.container.ContainerResponseContext;
+import javax.ws.rs.core.MultivaluedHashMap;
+import javax.ws.rs.core.MultivaluedMap;
+import javax.ws.rs.core.UriInfo;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicTrackerCorsFilterTest {
+
+    @Mock
+    private ConfigSharingService configSharingService;
+    @Mock
+    private ContainerRequestContext request;
+    @Mock
+    private ContainerResponseContext response;
+    @Mock
+    private UriInfo uriInfo;
+
+    private PublicTrackerCorsFilter filter;
+    private MultivaluedMap<String, Object> headers;
+
+    @BeforeEach
+    void setUp() {
+        filter = new PublicTrackerCorsFilter(configSharingService);
+        headers = new MultivaluedHashMap<>();
+        when(request.getUriInfo()).thenReturn(uriInfo);
+        when(uriInfo.getPath()).thenReturn("context.json");
+    }
+
+    @Test
+    void listedOriginNamesOnlyThatOriginAndVaries() {
+        
when(configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY))
+                .thenReturn("https://shop.example";);
+        
when(request.getHeaderString("Origin")).thenReturn("https://shop.example";);
+        when(response.getHeaders()).thenReturn(headers);
+
+        filter.filter(request, response);
+
+        assertEquals("https://shop.example";, 
headers.getFirst("Access-Control-Allow-Origin"));
+        assertEquals("true", 
headers.getFirst("Access-Control-Allow-Credentials"));
+        assertEquals("Origin", headers.getFirst("Vary"));
+    }
+
+    @Test
+    void listedOriginPreflightDoesTheSame() throws Exception {
+        
when(configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY))
+                .thenReturn("https://shop.example";);
+        when(request.getMethod()).thenReturn("OPTIONS");
+        
when(request.getHeaderString("Origin")).thenReturn("https://shop.example";);
+
+        filter.filter(request);
+
+        
verify(request).abortWith(org.mockito.ArgumentMatchers.argThat(response -> {
+            MultivaluedMap<String, Object> preflight = response.getHeaders();
+            return 
"https://shop.example".equals(preflight.getFirst("Access-Control-Allow-Origin"))
+                    && 
"true".equals(preflight.getFirst("Access-Control-Allow-Credentials"))
+                    && 
"Origin".equals(String.valueOf(preflight.getFirst("Vary")));
+        }));
+    }
+
+    @Test
+    void unlistedOriginDoesNotGetCredentials() {
+        
when(configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY))
+                .thenReturn("https://shop.example";);
+        
when(request.getHeaderString("Origin")).thenReturn("https://other.example";);
+        when(response.getHeaders()).thenReturn(headers);
+        headers.putSingle("Access-Control-Allow-Credentials", "true");
+
+        filter.filter(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertFalse(headers.containsKey("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void defaultSettingsAllowNoCredentials() {
+        
when(configSharingService.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY)).thenReturn(null);
+        
when(request.getHeaderString("Origin")).thenReturn("https://shop.example";);
+        when(response.getHeaders()).thenReturn(headers);
+
+        filter.filter(request, response);
+
+        assertEquals("*", headers.getFirst("Access-Control-Allow-Origin"));
+        assertNull(headers.getFirst("Access-Control-Allow-Credentials"));
+    }
+
+    @Test
+    void requestWithoutOriginIsLeftAlone() {
+        when(request.getHeaderString("Origin")).thenReturn(null);
+        when(response.getHeaders()).thenReturn(headers);
+
+        filter.filter(request, response);
+
+        assertFalse(headers.containsKey("Access-Control-Allow-Origin"));
+        assertFalse(headers.containsKey("Access-Control-Allow-Credentials"));
+    }
+}
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
new file mode 100644
index 000000000..283c17db7
--- /dev/null
+++ 
b/rest/src/test/java/org/apache/unomi/rest/cors/PublicTrackerCorsPolicyTest.java
@@ -0,0 +1,76 @@
+/*
+ * 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.cors;
+
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+class PublicTrackerCorsPolicyTest {
+
+    @Test
+    void defaultSettingsAllowNoOrigin() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(null);
+        assertTrue(policy.isEmpty());
+        assertFalse(policy.allows("https://shop.example";));
+        assertFalse(policy.allows(null));
+    }
+
+    @Test
+    void listedOriginIsExact() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse(" 
https://shop.example , https://shop.example:8443 ");
+        assertTrue(policy.allows("https://shop.example";));
+        assertTrue(policy.allows("https://shop.example:8443";));
+        assertFalse(policy.allows("http://shop.example";));
+        assertFalse(policy.allows("https://shop.example:443";));
+        assertFalse(policy.allows("https://other.shop.example";));
+        assertFalse(policy.allows("https://shop.example.evil";));
+        assertFalse(policy.allows("null"));
+        assertFalse(policy.allows("*"));
+        assertFalse(policy.allows("https://shop.example/path";));
+    }
+
+    @Test
+    void wildcardIsNeverAnAllowedOrigin() {
+        PublicTrackerCorsPolicy policy = PublicTrackerCorsPolicy.parse("*");
+        assertTrue(policy.isEmpty());
+        assertFalse(policy.allows("*"));
+    }
+
+    @Test
+    void sameSiteNoneRequiresSecureAndOrigins() {
+        PublicTrackerCorsPolicy empty = PublicTrackerCorsPolicy.parse("");
+        assertEquals("Lax", empty.resolveSameSite(null, false));
+        assertEquals("Lax", empty.resolveSameSite("None", true));
+        assertEquals("Lax", empty.resolveSameSite("Lax", false));
+
+        PublicTrackerCorsPolicy listed = 
PublicTrackerCorsPolicy.parse("https://shop.example";);
+        assertEquals("Lax", listed.resolveSameSite("None", false));
+        assertEquals("None", listed.resolveSameSite("None", true));
+        assertEquals("Strict", listed.resolveSameSite("Strict", false));
+    }
+
+    @Test
+    void publicTrackerPaths() {
+        
assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("context.json"));
+        
assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("/eventcollector"));
+        
assertTrue(PublicTrackerCorsPolicy.isPublicTrackerPath("client/myapp/status"));
+        
assertFalse(PublicTrackerCorsPolicy.isPublicTrackerPath("profiles/test"));
+    }
+}
diff --git 
a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
index 293e09c95..ff1ba0ec7 100644
--- 
a/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
+++ 
b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -51,6 +51,7 @@ import java.util.Optional;
 import java.util.Set;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
 import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNotNull;
 import static org.junit.jupiter.api.Assertions.assertNull;
@@ -103,6 +104,37 @@ class RestServiceUtilsImplProfileBindingTest {
         
lenient().when(privacyService.isRequireAnonymousBrowsing(org.mockito.ArgumentMatchers.any(Profile.class))).thenReturn(false);
     }
 
+    @Test
+    void initEventsRequest_unlistedOriginDoesNotUseVisitorCookie() {
+        
when(configSharingService.getProperty("publicCorsAllowedOrigins")).thenReturn("https://shop.example";);
+        when(request.getHeader("Origin")).thenReturn("https://other.example";);
+
+        try {
+            restServiceUtils.initEventsRequest(
+                    "systemscope", null, null, null,
+                    false, false, request, response, new Date());
+            fail("An unlisted origin must not be served as the visitor named 
by the cookie");
+        } catch (javax.ws.rs.BadRequestException expected) {
+            assertTrue(expected.getMessage().contains("profileId"));
+        }
+
+        verify(profileService, never()).load("cookie-profile");
+    }
+
+    @Test
+    void initEventsRequest_listedOriginUsesVisitorCookie() {
+        
when(configSharingService.getProperty("publicCorsAllowedOrigins")).thenReturn("https://shop.example";);
+        when(request.getHeader("Origin")).thenReturn("https://shop.example";);
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(new 
Profile("cookie-profile"));
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+    }
+
     @Test
     void initEventsRequest_doesNotAuthenticatePeerWhenCompatOff() {
         Profile cookieProfile = new Profile("cookie-profile");
diff --git 
a/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java 
b/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java
new file mode 100644
index 000000000..25d30d35d
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/utils/ProfileCookieSameSiteTest.java
@@ -0,0 +1,71 @@
+/*
+ * 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.utils;
+
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.rest.cors.PublicTrackerCorsPolicy;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+class ProfileCookieSameSiteTest {
+
+    @Test
+    void defaultCookieIsSameSiteLax() {
+        String cookie = HttpUtils.getProfileCookieString(profile(), 
config(null, null), false);
+        assertTrue(cookie.contains("SameSite=Lax"));
+        assertFalse(cookie.contains("SameSite=None"));
+    }
+
+    @Test
+    void sameSiteNoneIsRefusedWithoutSecureOrOrigins() {
+        String withoutSecure = HttpUtils.getProfileCookieString(profile(), 
config("None", "https://shop.example";), false);
+        assertTrue(withoutSecure.contains("SameSite=Lax"));
+
+        String withoutOrigins = HttpUtils.getProfileCookieString(profile(), 
config("None", ""), true);
+        assertTrue(withoutOrigins.contains("SameSite=Lax"));
+        assertTrue(withoutOrigins.contains("Secure"));
+    }
+
+    @Test
+    void sameSiteNoneIsAcceptedWithSecureAndOrigins() {
+        String cookie = HttpUtils.getProfileCookieString(profile(), 
config("None", "https://shop.example";), true);
+        assertTrue(cookie.contains("SameSite=None"));
+        assertTrue(cookie.contains("Secure"));
+    }
+
+    private static Profile profile() {
+        Profile profile = new Profile();
+        profile.setItemId("profile-1");
+        return profile;
+    }
+
+    private static ConfigSharingService config(String sameSite, String 
origins) {
+        ConfigSharingService config = mock(ConfigSharingService.class);
+        
when(config.getProperty("profileIdCookieName")).thenReturn("context-profile-id");
+        when(config.getProperty("profileIdCookieDomain")).thenReturn("");
+        
when(config.getProperty("profileIdCookieMaxAgeInSeconds")).thenReturn(3600);
+        
when(config.getProperty("profileIdCookieHttpOnly")).thenReturn(Boolean.TRUE);
+        
when(config.getProperty(PublicTrackerCorsPolicy.SAME_SITE_PROPERTY)).thenReturn(sameSite);
+        
when(config.getProperty(PublicTrackerCorsPolicy.ALLOWED_ORIGINS_PROPERTY)).thenReturn(origins);
+        return config;
+    }
+}
diff --git 
a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java 
b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
index 8932eac7d..c9b251b09 100644
--- a/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
+++ b/web-servlets/src/main/java/org/apache/unomi/web/servlets/WebConfig.java
@@ -39,6 +39,8 @@ public class WebConfig {
     private String contexserverProfileIdCookieName;
     private int contextserverProfileIdCookieMaxAgeInSeconds;
     private boolean contextserverProfileIdCookieHttpOnly;
+    private String contextserverProfileIdCookieSameSite;
+    private String publicCorsAllowedOrigins;
     private String allowedProfileDownloadFormats;
     private int publicPostRequestBytesLimit;
 
@@ -60,6 +62,12 @@ public class WebConfig {
         @AttributeDefinition
         boolean contextserver_profileIdCookieHttpOnly() default true;
 
+        @AttributeDefinition
+        String contextserver_profileIdCookieSameSite() default "Lax";
+
+        @AttributeDefinition
+        String public_cors_allowedOrigins() default "";
+
         @AttributeDefinition
         String allowed_profile_download_formats() default "csv,yaml,json,text";
 
@@ -78,6 +86,8 @@ public class WebConfig {
         this.contexserverProfileIdCookieName = 
config.contexserver_profileIdCookieName();
         this.contextserverProfileIdCookieMaxAgeInSeconds = 
config.contextserver_profileIdCookieMaxAgeInSeconds();
         this.contextserverProfileIdCookieHttpOnly = 
config.contextserver_profileIdCookieHttpOnly();
+        this.contextserverProfileIdCookieSameSite = 
config.contextserver_profileIdCookieSameSite();
+        this.publicCorsAllowedOrigins = config.public_cors_allowedOrigins();
         this.allowedProfileDownloadFormats = 
config.allowed_profile_download_formats();
         this.publicPostRequestBytesLimit = 
config.public_post_request_bytes_limit();
 
@@ -85,6 +95,8 @@ public class WebConfig {
         configSharingService.setProperty("profileIdCookieDomain", 
contextserverDomain);
         configSharingService.setProperty("profileIdCookieMaxAgeInSeconds", 
contextserverProfileIdCookieMaxAgeInSeconds);
         configSharingService.setProperty("profileIdCookieHttpOnly", 
contextserverProfileIdCookieHttpOnly);
+        configSharingService.setProperty("profileIdCookieSameSite", 
contextserverProfileIdCookieSameSite);
+        configSharingService.setProperty("publicCorsAllowedOrigins", 
publicCorsAllowedOrigins);
         configSharingService.setProperty("publicPostRequestBytesLimit", 
publicPostRequestBytesLimit);
         configSharingService.setProperty("allowedProfileDownloadFormats", 
allowedProfileDownloadFormats);
         LOGGER.info("WebConfig activated.");
diff --git a/web-servlets/src/main/resources/org.apache.unomi.web.cfg 
b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
index ad620bc11..159468317 100644
--- a/web-servlets/src/main/resources/org.apache.unomi.web.cfg
+++ b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
@@ -24,6 +24,12 @@ 
contextserver.profileIdCookieName=${org.apache.unomi.profile.cookie.name:-contex
 
contextserver.profileIdCookieMaxAgeInSeconds=${org.apache.unomi.profile.cookie.maxAgeInSeconds:-31536000}
 # This setting controls if the cookie should be flagged as HttpOnly or not.
 
contextserver.profileIdCookieHttpOnly=${org.apache.unomi.profile.cookie.httpOnly:-true}
+# SameSite for the visitor cookie. Lax is the default. None is used only when 
the cookie is also Secure
+# and public.cors.allowedOrigins lists at least one origin; otherwise it falls 
back to Lax.
+contextserver.profileIdCookieSameSite=${org.apache.unomi.profile.cookie.sameSite:-Lax}
+# Comma-separated website origins (scheme, host and port) that may send the 
visitor cookie
+# to context.json, eventcollector and client. Empty means no other origin may 
send cookies.
+public.cors.allowedOrigins=${org.apache.unomi.public.cors.allowedOrigins:-}
 #Allowed profile download formats, actually only csv (horizontal and 
vertical), json, text and yaml are allowed.
 
allowed.profile.download.formats=${org.apache.unomi.profile.download.formats:-csv,yaml,json,text}
 # This setting allow for request size (Content-length) protection. Checking 
that the requests do not exceed the limit.

Reply via email to