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

asf-gitbox-commits pushed a commit to branch 
UNOMI-972-credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git


The following commit(s) were added to 
refs/heads/UNOMI-972-credentials-profile-binding-privileged-rest by this push:
     new e196fa67e UNOMI-972: cover the context/eventcollector behaviour the 
hardening could have broken
e196fa67e is described below

commit e196fa67ec3ba80fb3caddf78e07fbd6c7e28642
Author: Serge Huber <[email protected]>
AuthorDate: Sun Aug 9 09:29:22 2026 +0200

    UNOMI-972: cover the context/eventcollector behaviour the hardening could 
have broken
    
    The hardening rewrote profile and session binding, which every client of
    /cxs/context.json and /cxs/eventcollector goes through, and large parts of 
that
    surface had no test at all. This adds the missing coverage and a 
before/after
    baseline, so "we did not break existing clients" is a measurement rather 
than an
    argument.
    
    ContextEndpointBaselineIT is written to compile and run against both master 
and
    this branch, and is split into two groups with opposite expectations. The 
compat_*
    group is legacy client behaviour that must be identical on both; the 
hardened_*
    group is behaviour the fix intentionally changes and must fail on master. 
Running
    it on both sides gives:
    
      compat_* (7)                              master pass / branch pass
      hardened_publicBodyProfileIdIsIgnored     master FAIL / branch pass
      hardened_publicCallerCannotAdoptAForeign* master FAIL / branch pass
    
    On master the first hardened test returns the victim's profile id and their
    property to an unauthenticated caller holding only the public API key, 
which is
    the reported IDOR reproduced end to end; the branch answers 400.
    
    The compat group deliberately covers the client entry points that had none: 
the
    GET forms carrying ?payload=, which is how a script tag or image beacon 
tracks and
    which route through exactly the same binding code as POST.
    
    That hardened test took four attempts to become trustworthy - it asserted 
the
    wrong marker, then was masked by session recovery switching the profile 
back, then
    by a retrying HTTP client swallowing the deliberate 400. It looked like a 
passing
    test every time. A security test that has never been observed failing 
against
    unfixed code is an assumption, so the master run is the point, not a 
formality.
    
    Also pins the two areas a future ownership check is most likely to break, 
neither
    of which was covered anywhere: all four branches of the anonymous-browsing
    handling, and persona binding. Personas short-circuit binding entirely and
    profileOverrides only apply to a Persona, so both are structurally isolated 
from
    the security changes - now asserted rather than assumed.
    
    Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
---
 .../test/java/org/apache/unomi/itests/AllITs.java  |   1 +
 .../unomi/itests/ContextEndpointBaselineIT.java    | 289 +++++++++++++++++++++
 .../org/apache/unomi/itests/ContextServletIT.java  | 129 +++++++++
 .../apache/unomi/itests/CorePersistenceITs.java    |   1 +
 .../RestServiceUtilsImplProfileBindingTest.java    | 217 ++++++++++++++++
 5 files changed, 637 insertions(+)

diff --git a/itests/src/test/java/org/apache/unomi/itests/AllITs.java 
b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
index 7f640a588..a17afe29a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -56,6 +56,7 @@ import org.junit.runners.Suite.SuiteClasses;
         ModifyConsentIT.class,
         PatchIT.class,
         ContextServletIT.class,
+        ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
         PrivacyServiceIT.class,
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
new file mode 100644
index 000000000..52da5035f
--- /dev/null
+++ 
b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
@@ -0,0 +1,289 @@
+/*
+ * 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.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.EventsCollectorRequest;
+import org.apache.unomi.api.CustomItem;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Objects;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Before/after behavioural baseline for the two public client endpoints, 
{@code /cxs/context.json}
+ * (plus its {@code /cxs/context.js} sibling) and {@code /cxs/eventcollector}.
+ * <p>
+ * This class is deliberately written to compile and run against <em>both</em> 
the pre-hardening
+ * baseline and the hardened branch, so the same suite can be executed on each 
and the results
+ * diffed. It is split into two groups with opposite expectations:
+ * <ul>
+ *   <li><b>compat_*</b> — legacy client behaviour that MUST be identical 
before and after. A
+ *       failure here on the hardened branch is a compatibility regression, 
full stop.</li>
+ *   <li><b>hardened_*</b> — behaviour the hardening intentionally changes. 
These are expected to
+ *       FAIL on the pre-hardening baseline and PASS after; that contrast is 
the evidence the
+ *       security fix actually does something.</li>
+ * </ul>
+ * The compat group covers the client entry points that had no coverage at 
all: the {@code GET}
+ * forms carrying a {@code ?payload=} query parameter, which is how a script 
tag or image beacon
+ * tracks, and which route through exactly the same binding code as the POST 
forms.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class ContextEndpointBaselineIT extends BaseIT {
+
+    private static final String UNOMI_API_KEY_HTTP_HEADER_KEY = 
"X-Unomi-Api-Key";
+    private static final String CONTEXT_JSON_URL = "/cxs/context.json";
+    private static final String CONTEXT_JS_URL = "/cxs/context.js";
+    private static final String EVENT_COLLECTOR_URL = "/cxs/eventcollector";
+    private static final String TEST_SCOPE = "baseline-scope";
+
+    // ------------------------------------------------------------------ 
compatibility group
+
+    /** A brand new visitor with no cookie and no session must still be issued 
a profile. */
+    @Test
+    public void compat_firstVisitIssuesAProfileAndCookie() throws Exception {
+        String sessionId = "baseline-first-" + System.currentTimeMillis();
+        TestUtils.RequestResponse response = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        assertEquals(200, response.getStatusCode());
+        assertNotNull("a first visit must be issued a profile id", 
response.getContextResponse().getProfileId());
+        assertNotNull("a first visit must be issued the profile cookie", 
response.getCookieHeaderValue());
+    }
+
+    /** A returning visitor presenting the cookie must be recognised as the 
same profile. */
+    @Test
+    public void compat_returningVisitorKeepsItsProfile() throws Exception {
+        String sessionId = "baseline-returning-" + System.currentTimeMillis();
+        TestUtils.RequestResponse first = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+        String profileId = first.getContextResponse().getProfileId();
+
+        TestUtils.RequestResponse second = 
postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), 
sessionId);
+
+        assertEquals(200, second.getStatusCode());
+        assertEquals("a returning visitor must keep its profile", profileId, 
second.getContextResponse().getProfileId());
+        assertEquals("and its session", sessionId, 
second.getContextResponse().getSessionId());
+    }
+
+    /** The GET form with ?payload= must behave like the POST form. This entry 
point had no coverage. */
+    @Test
+    public void compat_getWithPayloadBehavesLikePost() throws Exception {
+        String sessionId = "baseline-get-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+        String profileId = established.getContextResponse().getProfileId();
+
+        HttpGet get = new HttpGet(getFullUrl(CONTEXT_JSON_URL) + "?payload=" + 
encode(newContextRequest(sessionId)));
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        get.addHeader("Cookie", established.getCookieHeaderValue());
+        TestUtils.RequestResponse response = 
TestUtils.executeContextJSONRequest(get, sessionId, getObjectMapper());
+
+        assertEquals(200, response.getStatusCode());
+        assertEquals("GET ?payload= must resolve the same profile as POST", 
profileId,
+                response.getContextResponse().getProfileId());
+    }
+
+    /** /cxs/context.js must keep serving JavaScript to script-tag clients. */
+    @Test
+    public void compat_contextJsServesJavaScript() throws Exception {
+        String sessionId = "baseline-js-" + System.currentTimeMillis();
+        HttpGet get = new HttpGet(getFullUrl(CONTEXT_JS_URL) + "?sessionId=" + 
sessionId);
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(get)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+            String body = EntityUtils.toString(response.getEntity());
+            // Same marker BasicIT asserts on: context.js emits the 
digitalData bootstrap that
+            // script-tag clients rely on. Asserting the marker, not just a 
200, so an empty or
+            // error body cannot pass as success.
+            assertTrue("context.js must return the tracker javascript, got: "
+                            + body.substring(0, Math.min(200, body.length())),
+                    body.contains("window.digitalData"));
+        }
+    }
+
+    /** Event collection over POST must keep working and report the event as 
processed. */
+    @Test
+    public void compat_eventCollectorAcceptsEvents() throws Exception {
+        String sessionId = "baseline-ec-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL));
+        post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        post.addHeader("Cookie", established.getCookieHeaderValue());
+        post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(newEventsRequest(sessionId)),
+                ContentType.APPLICATION_JSON));
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(post)) {
+            assertEquals("the eventcollector must keep accepting events from a 
cookie-bearing client",
+                    200, response.getStatusLine().getStatusCode());
+        }
+    }
+
+    /** The eventcollector GET form with ?payload= — another entry point that 
had no coverage. */
+    @Test
+    public void compat_eventCollectorGetWithPayload() throws Exception {
+        String sessionId = "baseline-ecget-" + System.currentTimeMillis();
+        TestUtils.RequestResponse established = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        HttpGet get = new HttpGet(getFullUrl(EVENT_COLLECTOR_URL) + 
"?payload=" + encode(newEventsRequest(sessionId)));
+        get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        get.addHeader("Cookie", established.getCookieHeaderValue());
+
+        try (CloseableHttpResponse response = 
HttpClientThatWaitsForUnomi.doRequest(get)) {
+            assertEquals(200, response.getStatusLine().getStatusCode());
+        }
+    }
+
+    /** A client may continue its own session across requests without 
re-establishing it. */
+    @Test
+    public void compat_sessionContinuityAcrossRequests() throws Exception {
+        String sessionId = "baseline-cont-" + System.currentTimeMillis();
+        TestUtils.RequestResponse first = 
postContextJson(newContextRequest(sessionId), null, sessionId);
+
+        for (int i = 0; i < 3; i++) {
+            TestUtils.RequestResponse next = 
postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), 
sessionId);
+            assertEquals(200, next.getStatusCode());
+            assertEquals("the client's own session must never be refused", 
sessionId,
+                    next.getContextResponse().getSessionId());
+        }
+    }
+
+    // ------------------------------------------------------------------ 
hardened group
+    // Expected to FAIL on the pre-hardening baseline and PASS after. That 
contrast is the point.
+
+    /**
+     * A public caller must not be able to read another visitor's profile by 
naming it in the body.
+     * <p>
+     * The attack request carries ONLY the body profileId - no cookie and no 
session - because that is
+     * what makes this discriminating. An earlier version of this test also 
sent a session owned by the
+     * caller, and on the pre-hardening baseline the session-recovery logic 
switched the profile back
+     * to the session owner, masking the body profileId entirely and making 
the test pass on both
+     * sides. Asserting on the victim's actual data rather than on an echoed 
id keeps it honest.
+     */
+    @Test
+    public void hardened_publicBodyProfileIdIsIgnored() throws Exception {
+        String victimProfileId = "baseline-victim-" + 
System.currentTimeMillis();
+        String victimSecret = "baseline-secret-" + System.currentTimeMillis();
+        Profile victim = new Profile(victimProfileId);
+        victim.setProperty("baselineSecret", victimSecret);
+        profileService.save(victim);
+        keepTrying("Victim profile should be saved", () -> 
profileService.load(victimProfileId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            ContextRequest claim = new ContextRequest();
+            claim.setProfileId(victimProfileId);
+            claim.setRequiredProfileProperties(Collections.singletonList("*"));
+            CustomItem source = new CustomItem("baseline-page", "page");
+            source.setScope(TEST_SCOPE);
+            claim.setSource(source);
+
+            HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+            post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+            post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(claim), 
ContentType.APPLICATION_JSON));
+
+            // Plain client, not HttpClientThatWaitsForUnomi: the hardened 
branch answers 400 here
+            // (nothing left to bind once the body profileId is ignored), and 
that helper retries
+            // non-2xx and then throws, which would mask the very behaviour 
under test.
+            try (CloseableHttpResponse response = httpClient.execute(post)) {
+                String body = response.getEntity() == null ? "" : 
EntityUtils.toString(response.getEntity());
+                assertFalse("a public caller must not receive the victim's 
profile properties, got: "
+                                + body.substring(0, Math.min(300, 
body.length())),
+                        body.contains(victimSecret));
+                assertFalse("a public caller must not be bound to the victim's 
profile id",
+                        body.contains(victimProfileId));
+            }
+        } finally {
+            profileService.delete(victimProfileId, false);
+        }
+    }
+
+    /** A public caller must not be able to adopt a session belonging to 
someone else. */
+    @Test
+    public void hardened_publicCallerCannotAdoptAForeignSession() throws 
Exception {
+        String victimSessionId = "baseline-victim-sess-" + 
System.currentTimeMillis();
+        TestUtils.RequestResponse victim = 
postContextJson(newContextRequest(victimSessionId), null, victimSessionId);
+        String victimProfileId = victim.getContextResponse().getProfileId();
+
+        String attackerSessionId = "baseline-attacker-sess-" + 
System.currentTimeMillis();
+        TestUtils.RequestResponse attacker = 
postContextJson(newContextRequest(attackerSessionId), null, attackerSessionId);
+
+        // Attacker presents the victim's session id with its own cookie.
+        TestUtils.RequestResponse hijack = 
postContextJson(newContextRequest(victimSessionId),
+                attacker.getCookieHeaderValue(), victimSessionId);
+
+        assertEquals(200, hijack.getStatusCode());
+        assertTrue("the attacker must not end up on the victim's profile",
+                
!victimProfileId.equals(hijack.getContextResponse().getProfileId()));
+    }
+
+    // ------------------------------------------------------------------ 
helpers
+
+    private ContextRequest newContextRequest(String sessionId) {
+        ContextRequest contextRequest = new ContextRequest();
+        contextRequest.setSessionId(sessionId);
+        CustomItem source = new CustomItem("baseline-page", "page");
+        source.setScope(TEST_SCOPE);
+        contextRequest.setSource(source);
+        return contextRequest;
+    }
+
+    private EventsCollectorRequest newEventsRequest(String sessionId) {
+        Event event = new Event();
+        event.setEventType("view");
+        event.setScope(TEST_SCOPE);
+        EventsCollectorRequest eventsRequest = new EventsCollectorRequest();
+        eventsRequest.setSessionId(sessionId);
+        eventsRequest.setEvents(Collections.singletonList(event));
+        return eventsRequest;
+    }
+
+    private TestUtils.RequestResponse postContextJson(ContextRequest 
contextRequest, String cookie, String sessionId)
+            throws Exception {
+        HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+        post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+        if (cookie != null) {
+            post.addHeader("Cookie", cookie);
+        }
+        post.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+        return TestUtils.executeContextJSONRequest(post, sessionId, 
getObjectMapper());
+    }
+
+    private String encode(Object payload) throws Exception {
+        return 
URLEncoder.encode(getObjectMapper().writeValueAsString(payload), 
StandardCharsets.UTF_8.name());
+    }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java 
b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index be91a3654..3e8d6e8f9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -472,6 +472,135 @@ public class ContextServletIT extends BaseIT {
         assertNull("Attacker-supplied profileId must not be created", 
profileService.load(attackerProfileId));
     }
 
+    /**
+     * End-to-end guard for anonymous browsing. The session-ownership check 
added for public callers
+     * deliberately skips anonymous profiles today; any future tightening of 
it must not detach the
+     * session of a visitor who is legitimately browsing anonymously. That 
failure would be invisible
+     * at unit level in the endpoint wiring, hence this IT: it asserts the 
visitor's own session id is
+     * still echoed back (a refused session is suppressed from the response) 
after anonymisation.
+     */
+    @Test
+    public void testAnonymousBrowsing_visitorKeepsItsOwnSession() throws 
Exception {
+        String sessionId = "anon-browsing-session-" + 
System.currentTimeMillis();
+
+        ContextRequest firstRequest = new ContextRequest();
+        firstRequest.setSessionId(sessionId);
+        HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+        addPublicTenantAuth(establish);
+        establish.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(firstRequest), 
ContentType.APPLICATION_JSON));
+        RequestResponse established = executeContextJSONRequest(establish, 
sessionId);
+        assertEquals(200, established.getStatusCode());
+        String profileId = established.getContextResponse().getProfileId();
+        assertNotNull(profileId);
+        assertNotNull(established.getCookieHeaderValue());
+
+        // Turn on anonymous browsing for this visitor, exactly as the privacy 
endpoint would.
+        privacyService.setRequireAnonymousBrowsing(profileId, true, 
TEST_SCOPE);
+        keepTrying("Anonymous browsing should be enabled for the profile",
+                () -> privacyService.isRequireAnonymousBrowsing(profileId),
+                Boolean.TRUE::equals, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            // Same visitor, same cookie, same session: must still be served, 
and the session kept.
+            ContextRequest secondRequest = new ContextRequest();
+            secondRequest.setSessionId(sessionId);
+            HttpPost anonymous = new HttpPost(getFullUrl(CONTEXT_URL));
+            addPublicTenantAuth(anonymous);
+            anonymous.addHeader("Cookie", established.getCookieHeaderValue());
+            anonymous.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(secondRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse anonymousResponse = 
executeContextJSONRequest(anonymous, sessionId);
+
+            assertEquals(200, anonymousResponse.getStatusCode());
+            assertNotNull("An anonymous visitor's own session must not be 
refused",
+                    anonymousResponse.getContextResponse().getSessionId());
+            assertEquals(sessionId, 
anonymousResponse.getContextResponse().getSessionId());
+
+            // And turning anonymity back off must keep working too (the 
de-anonymising branch).
+            privacyService.setRequireAnonymousBrowsing(profileId, false, 
TEST_SCOPE);
+            keepTrying("Anonymous browsing should be disabled again",
+                    () -> privacyService.isRequireAnonymousBrowsing(profileId),
+                    Boolean.FALSE::equals, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+            ContextRequest thirdRequest = new ContextRequest();
+            thirdRequest.setSessionId(sessionId);
+            HttpPost deanonymised = new HttpPost(getFullUrl(CONTEXT_URL));
+            addPublicTenantAuth(deanonymised);
+            deanonymised.addHeader("Cookie", 
established.getCookieHeaderValue());
+            deanonymised.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(thirdRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse deanonymisedResponse = 
executeContextJSONRequest(deanonymised, sessionId);
+
+            assertEquals(200, deanonymisedResponse.getStatusCode());
+            assertNotNull("Leaving anonymous browsing must not refuse the 
visitor's own session",
+                    deanonymisedResponse.getContextResponse().getSessionId());
+        } finally {
+            privacyService.setRequireAnonymousBrowsing(profileId, false, 
TEST_SCOPE);
+        }
+    }
+
+    /**
+     * Personas short-circuit profile binding entirely: the profile and 
session both come from the
+     * persona and none of the cookie/body binding logic runs. Nothing covered 
that path end to end,
+     * so a change to the binding code could silently break persona preview.
+     */
+    @Test
+    public void testPersona_contextJsonBindsToThePersona() throws Exception {
+        String personaId = "it-persona-" + System.currentTimeMillis();
+        profileService.createPersona(personaId);
+        keepTrying("Persona should be created", () -> 
profileService.loadPersonaWithSessions(personaId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            ContextRequest contextRequest = new ContextRequest();
+            HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + 
"?personaId=" + personaId);
+            addPublicTenantAuth(request);
+            request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse response = executeContextJSONRequest(request, 
null);
+
+            assertEquals(200, response.getStatusCode());
+            assertEquals("The context response must be bound to the persona, 
not a live profile",
+                    personaId, response.getContextResponse().getProfileId());
+        } finally {
+            profileService.delete(personaId, true);
+        }
+    }
+
+    /**
+     * profileOverrides / sessionPropertiesOverrides are the preview-UI 
feature that lets a caller
+     * temporarily substitute segments, scores and properties. They had no 
coverage at all, and they
+     * are only honoured when the active profile is a Persona ({@code 
ContextJsonEndpoint#processOverrides}),
+     * which is exactly what keeps a public caller from overriding a real 
profile. Pin both halves:
+     * the override applies for a persona, and the persona path is unaffected 
by the binding rules.
+     */
+    @Test
+    public void testPersona_profileOverridesAreApplied() throws Exception {
+        String personaId = "it-persona-overrides-" + 
System.currentTimeMillis();
+        profileService.createPersona(personaId);
+        keepTrying("Persona should be created", () -> 
profileService.loadPersonaWithSessions(personaId),
+                Objects::nonNull, DEFAULT_TRYING_TIMEOUT, 
DEFAULT_TRYING_TRIES);
+
+        try {
+            Profile overrides = new Profile();
+            overrides.setSegments(new 
HashSet<>(Arrays.asList("override-segment-a", "override-segment-b")));
+
+            ContextRequest contextRequest = new ContextRequest();
+            contextRequest.setRequireSegments(true);
+            contextRequest.setProfileOverrides(overrides);
+
+            HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + 
"?personaId=" + personaId);
+            addPublicTenantAuth(request);
+            request.setEntity(new 
StringEntity(getObjectMapper().writeValueAsString(contextRequest), 
ContentType.APPLICATION_JSON));
+            RequestResponse response = executeContextJSONRequest(request, 
null);
+
+            assertEquals(200, response.getStatusCode());
+            assertEquals(personaId, 
response.getContextResponse().getProfileId());
+            assertNotNull("requireSegments must return the segment set", 
response.getContextResponse().getProfileSegments());
+            assertTrue("profileOverrides segments must be reflected for a 
persona",
+                    
response.getContextResponse().getProfileSegments().contains("override-segment-a"));
+        } finally {
+            profileService.delete(personaId, true);
+        }
+    }
+
     @Test
     public void 
testPublicCaller_sessionProfileSwitchWithoutMatchingCookie_refused() throws 
Exception {
         String sessionOwnerId = "session-owner-" + System.currentTimeMillis();
diff --git 
a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java 
b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index e7a135b00..23c7b219b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -57,6 +57,7 @@ import org.junit.runners.Suite.SuiteClasses;
         ModifyConsentIT.class,
         PatchIT.class,
         ContextServletIT.class,
+        ContextEndpointBaselineIT.class,
         SecurityIT.class,
         RuleServiceIT.class,
         PrivacyServiceIT.class,
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 86d3aee80..91c2d606e 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
@@ -16,6 +16,9 @@
  */
 package org.apache.unomi.rest.service.impl;
 
+import org.apache.unomi.api.Persona;
+import org.apache.unomi.api.PersonaSession;
+import org.apache.unomi.api.PersonaWithSessions;
 import org.apache.unomi.api.Profile;
 import org.apache.unomi.api.Session;
 import org.apache.unomi.api.security.SecurityService;
@@ -38,6 +41,7 @@ import javax.servlet.http.Cookie;
 import javax.servlet.http.HttpServletRequest;
 import javax.servlet.http.HttpServletResponse;
 import java.lang.reflect.Field;
+import java.util.Collections;
 import java.util.Date;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
@@ -275,6 +279,219 @@ class RestServiceUtilsImplProfileBindingTest {
         assertEquals("own-sess", ctx.getSession().getItemId());
     }
 
+    // 
---------------------------------------------------------------------------------------
+    // Anonymous browsing. All four branches of the anonymity handling in 
initEventsRequest are
+    // pinned here BEFORE any change to the session-ownership check, because 
the ownership check
+    // currently skips anonymous profiles entirely: tightening it without this 
safety net would
+    // silently detach the session of every legitimately anonymous visitor on 
every request.
+    // 
---------------------------------------------------------------------------------------
+
+    /**
+     * Branch 1: the visitor wants anonymity and the session already carries 
an anonymous profile,
+     * so nothing changes. This is the steady state of an anonymous visitor 
and must stay a no-op.
+     */
+    @Test
+    void anonymousBrowsing_alreadyAnonymousSession_isLeftUntouched() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        Profile anonymousProfile = anonymous();
+        Session session = new Session("anon-sess", anonymousProfile, new 
Date(), "systemscope");
+
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+        when(profileService.loadSession("anon-sess")).thenReturn(session);
+        
when(privacyService.isRequireAnonymousBrowsing(cookieProfile)).thenReturn(true);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", "anon-sess", null, null,
+                false, false, request, response, new Date());
+
+        assertFalse(ctx.isSessionRefused(), "an anonymous visitor's own 
session must not be refused");
+        assertNotNull(ctx.getSession());
+        assertTrue(ctx.getSession().getProfile().isAnonymousProfile(),
+                "the session must keep its anonymous profile");
+        assertEquals("cookie-profile", ctx.getProfile().getItemId(),
+                "the request profile stays the real cookie profile");
+    }
+
+    /**
+     * Branch 2: the visitor has just asked for anonymity while their session 
still carries the real
+     * profile, so the session is switched to an anonymous profile. This is 
how anonymity is entered.
+     */
+    @Test
+    void anonymousBrowsing_entering_replacesSessionProfileWithAnonymous() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        Session session = new Session("sess", cookieProfile, new Date(), 
"systemscope");
+        Profile anonymousProfile = anonymous();
+
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+        when(profileService.loadSession("sess")).thenReturn(session);
+        
when(privacyService.isRequireAnonymousBrowsing(cookieProfile)).thenReturn(true);
+        
when(privacyService.getAnonymousProfile(cookieProfile)).thenReturn(anonymousProfile);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", "sess", null, null,
+                false, false, request, response, new Date());
+
+        assertFalse(ctx.isSessionRefused());
+        assertTrue(ctx.getSession().getProfile().isAnonymousProfile(),
+                "entering anonymity must swap the session profile for an 
anonymous one");
+        assertTrue((ctx.getChanges() & EventService.SESSION_UPDATED) != 0,
+                "the session change must be flagged so it is persisted");
+    }
+
+    /**
+     * Branch 3: the visitor has turned anonymity off, so their anonymous 
session is bound back to
+     * their real profile. This is the branch an ownership check would most 
easily break, and it is
+     * also the branch an attacker reaches with a stolen anonymous session id 
— so it must keep
+     * working for the legitimate case while the fix is designed.
+     */
+    @Test
+    void anonymousBrowsing_leaving_rebindsSessionToTheRealProfile() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        Session session = new Session("anon-sess", anonymous(), new Date(), 
"systemscope");
+
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+        when(profileService.loadSession("anon-sess")).thenReturn(session);
+        
when(privacyService.isRequireAnonymousBrowsing(cookieProfile)).thenReturn(false);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", "anon-sess", null, null,
+                false, false, request, response, new Date());
+
+        assertFalse(ctx.isSessionRefused());
+        assertNotNull(ctx.getSession());
+        assertEquals("cookie-profile", 
ctx.getSession().getProfile().getItemId(),
+                "leaving anonymity must bind the session back to the visitor's 
real profile");
+    }
+
+    /** Branch 4: the ordinary non-anonymous case — the session is bound to 
the caller's profile. */
+    @Test
+    void anonymousBrowsing_notAnonymousAtAll_bindsSessionToCallerProfile() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        Session session = new Session("sess", cookieProfile, new Date(), 
"systemscope");
+
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+        when(profileService.loadSession("sess")).thenReturn(session);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", "sess", null, null,
+                false, false, request, response, new Date());
+
+        assertFalse(ctx.isSessionRefused());
+        assertEquals("cookie-profile", 
ctx.getSession().getProfile().getItemId());
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // Personas. A personaId short-circuits binding entirely: the profile and 
session both come
+    // from the persona, and the cookie/body binding logic below it never 
runs. Nothing covered
+    // this before, so a change to the binding code could have silently broken 
persona preview.
+    // 
---------------------------------------------------------------------------------------
+
+    /** A persona overrides the cookie profile outright, and brings its own 
session with it. */
+    @Test
+    void persona_overridesCookieProfileAndSuppliesItsOwnSession() {
+        Persona persona = new Persona("persona-1");
+        PersonaSession personaSession = new PersonaSession("persona-sess", 
persona, new Date());
+        PersonaWithSessions personaWithSessions =
+                new PersonaWithSessions(persona, 
Collections.singletonList(personaSession));
+
+        lenient().when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        
when(profileService.loadPersonaWithSessions("persona-1")).thenReturn(personaWithSessions);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, "persona-1",
+                false, false, request, response, new Date());
+
+        assertEquals("persona-1", ctx.getProfile().getItemId(), "the persona 
must win over the cookie");
+        assertNotNull(ctx.getSession(), "the persona's own session must be 
used");
+        assertEquals("persona-sess", ctx.getSession().getItemId());
+        verify(profileService, never()).load("cookie-profile");
+    }
+
+    /** A persona also wins over an explicitly supplied body profileId. */
+    @Test
+    void persona_winsOverBodyProfileId() {
+        Persona persona = new Persona("persona-1");
+        PersonaWithSessions personaWithSessions =
+                new PersonaWithSessions(persona, Collections.singletonList(
+                        new PersonaSession("persona-sess", persona, new 
Date())));
+
+        
when(profileService.loadPersonaWithSessions("persona-1")).thenReturn(personaWithSessions);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, "some-other-profile", "persona-1",
+                false, false, request, response, new Date());
+
+        assertEquals("persona-1", ctx.getProfile().getItemId());
+        verify(profileService, never()).load("some-other-profile");
+    }
+
+    /**
+     * An unknown persona must not blow up the request: the persona is simply 
not applied and the
+     * normal cookie binding takes over, so a stale persona id degrades to 
ordinary tracking.
+     */
+    @Test
+    void persona_unknownId_fallsBackToNormalCookieBinding() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+        
when(profileService.loadPersonaWithSessions("missing-persona")).thenReturn(null);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, "missing-persona",
+                false, false, request, response, new Date());
+
+        assertEquals("cookie-profile", ctx.getProfile().getItemId());
+    }
+
+    // 
---------------------------------------------------------------------------------------
+    // invalidateProfile. Untested at IT level, and it sits inside the same 
block the security
+    // changes rewrote, so pin it: it must still hand the visitor a brand new 
profile rather than
+    // reusing the cookie one.
+    // 
---------------------------------------------------------------------------------------
+
+    /** invalidateProfile discards the cookie profile and issues a fresh one. 
*/
+    @Test
+    void invalidateProfile_issuesANewProfileInsteadOfTheCookieOne() {
+        Profile cookieProfile = new Profile("cookie-profile");
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        
lenient().when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                true, false, request, response, new Date());
+
+        assertNotNull(ctx.getProfile());
+        assertFalse("cookie-profile".equals(ctx.getProfile().getItemId()),
+                "invalidateProfile must not reuse the cookie profile");
+    }
+
+    /** invalidateProfile is honoured for a trusted caller too, not silently 
swallowed by the trust path. */
+    @Test
+    void invalidateProfile_alsoAppliesForTrustedCallers() {
+        when(securityService.hasSystemAccess()).thenReturn(true);
+        Profile cookieProfile = new Profile("cookie-profile");
+        when(request.getCookies()).thenReturn(new Cookie[]{new 
Cookie(COOKIE_NAME, "cookie-profile")});
+        
lenient().when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+
+        EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+                "systemscope", null, null, null,
+                true, false, request, response, new Date());
+
+        assertNotNull(ctx.getProfile());
+        assertFalse("cookie-profile".equals(ctx.getProfile().getItemId()));
+    }
+
+    /** A profile carrying the anonymous marker, as {@code 
PrivacyService#getAnonymousProfile} builds it. */
+    private static Profile anonymous() {
+        Profile anonymousProfile = new Profile();
+        anonymousProfile.getSystemProperties().put("isAnonymousProfile", true);
+        return anonymousProfile;
+    }
+
     private static void setField(Object target, String fieldName, Object 
value) throws Exception {
         Field field = target.getClass().getDeclaredField(fieldName);
         field.setAccessible(true);

Reply via email to