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.
