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);