This is an automated email from the ASF dual-hosted git repository.
asf-gitbox-commits pushed a commit to branch UNOMI-978-action-identity-ownership
in repository https://gitbox.apache.org/repos/asf/unomi.git
The following commit(s) were added to
refs/heads/UNOMI-978-action-identity-ownership by this push:
new 34e6e5277 UNOMI-978: Allowlist property writes and refuse untrusted
identity claims
34e6e5277 is described below
commit 34e6e5277466da41ffad822d9f3198c18e37e2a0
Author: Serge Huber <[email protected]>
AuthorDate: Tue Sep 22 20:42:43 2026 +0200
UNOMI-978: Allowlist property writes and refuse untrusted identity claims
Two ways remained for a public-key caller to reach the identity state the
previous commit gates on:
- UpdatePropertiesAction matched systemProperties by literal prefix, but
commons-beanutils also accepts the mapped form systemProperties(key),
which has no dot and writes into the same map. Reserved bean fields
(itemId, itemType, tenantId, version, mergedWith) were reachable too,
and a rewritten itemId is then saved over the other profile's document.
- MergeProfilesOnPropertyAction refused the merge for untrusted callers
but still recorded the caller-chosen merge identifier. A later trusted
login for that identifier merged the planted profile in and aliased it
onto the master.
Replace the systemProperties check with one allowlist, isWritable: the
name must be a plain dotted path (no mapped/indexed syntax, no empty
segments, no control characters) and its first segment must be
'properties' for a public caller, or additionally systemProperties,
segments, scores or consents for a trusted caller. Identity fields are
never writable. Move the merge trust gate ahead of the identifier write so
an untrusted login claims nothing, and share the trust policy in
IdentityTrust.
Add a table-driven intrusion suite covering the classic injection
families (beanutils gadget, escapes and encodings, mapped/indexed syntax,
reserved fields, path shape, hostile merge values), doPost coverage for
the login sample against an HTTP stub, a BaseIT helper for switching the
test subject, and an IT for the identity-field rewrite. BasicIT's first
login is now a trusted call, since a public login no longer records a
merge identifier. Update the manual pages that still described the
pre-gate model, remove the duplicate hardening anchor, drop the dead
--version flag from setup.sh, and correct the sample's servlet comment on
what the server-side session id does and does not link.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
---
.../test/java/org/apache/unomi/itests/BaseIT.java | 17 ++
.../test/java/org/apache/unomi/itests/BasicIT.java | 7 +-
.../org/apache/unomi/itests/ContextServletIT.java | 4 +
.../org/apache/unomi/itests/ProfileMergeIT.java | 20 +-
.../unomi/itests/PropertiesUpdateActionIT.java | 69 ++++--
manual/src/main/asciidoc/builtin-event-types.adoc | 11 +-
.../asciidoc/migrations/migrate-3.0-to-3.1.adoc | 3 +-
manual/src/main/asciidoc/recipes.adoc | 6 +-
manual/src/main/asciidoc/samples/login-sample.adoc | 6 +-
.../plugins/baseplugin/actions/IdentityTrust.java | 44 ++++
.../actions/MergeProfilesOnPropertyAction.java | 38 ++-
.../baseplugin/actions/UpdatePropertiesAction.java | 68 +++---
.../actions/ActionIdentityIntrusionTest.java | 271 +++++++++++++++++++++
.../actions/MergeProfilesOnPropertyActionTest.java | 44 +++-
.../actions/UpdatePropertiesActionTest.java | 88 +++++++
samples/login-integration/setup.sh | 6 +-
.../apache/unomi/samples/login/LoginServlet.java | 10 +-
.../unomi/samples/login/LoginServletTest.java | 149 +++++++++++
18 files changed, 757 insertions(+), 104 deletions(-)
diff --git a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
index 1940d3c79..f699cf9c9 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -877,6 +877,23 @@ public abstract class BaseIT extends KarafTestSupport {
return value;
}
+ /**
+ * Runs {@code action} with the request subject temporarily replaced by a
tenant subject for
+ * {@link #TEST_TENANT_ID}: a tenant-admin (private key) subject when
{@code systemAccess} is
+ * true, a public-key subject otherwise. The suite {@code @Before}
installs a tenant-admin
+ * subject, so tests that need to behave as a public caller must downgrade
it, and must restore
+ * it afterwards or every later test in the shared container runs
unprivileged.
+ */
+ protected void runAsTenantSubject(boolean systemAccess, Runnable action) {
+ javax.security.auth.Subject previous =
securityService.getCurrentSubject();
+ try {
+
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
systemAccess));
+ action.run();
+ } finally {
+ securityService.setCurrentSubject(previous);
+ }
+ }
+
protected void waitForProfileProperty(String profileId, String
propertyName, Object expected)
throws InterruptedException {
keepTrying("Profile " + profileId + " property " + propertyName + "
not updated",
diff --git a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
index ff4e12f06..682ff5a1b 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
@@ -199,12 +199,15 @@ public class BasicIT extends BaseIT {
// Create login event with VISITOR_1
ContextRequest contextRequestLoginVisitor1 =
getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor1,
EMAIL_VISITOR_1, SESSION_ID_3);
+ // Login events come from the trusted server side: only a trusted
caller may record the
+ // merge identifier that the later identity switch below relies on.
HttpPost requestLoginVisitor1 = new
HttpPost(getFullUrl("/cxs/context.json"));
requestLoginVisitor1.addHeader("Cookie",
requestResponsePageView1.getCookieHeaderValue());
- requestLoginVisitor1.addHeader("X-Unomi-Api-Key", testPublicKeyValue);
+ requestLoginVisitor1.setHeader("Authorization", "Basic " +
Base64.getEncoder().encodeToString(
+ (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes()));
requestLoginVisitor1.setEntity(new
StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor1),
ContentType.create("application/json")));
- TestUtils.RequestResponse requestResponseLoginVisitor1 =
executeContextJSONRequest(requestLoginVisitor1, SESSION_ID_3);
+ TestUtils.RequestResponse requestResponseLoginVisitor1 =
executeContextJSONRequest(requestLoginVisitor1, SESSION_ID_3, -1, false);
Assert.assertEquals("Context profile id should be the same",
profileIdVisitor1,
requestResponseLoginVisitor1.getContextResponse().getProfileId());
checkVisitor1ResponseProperties(requestResponseLoginVisitor1.getContextResponse().getProfileProperties());
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 247460e26..609cdd5be 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -580,6 +580,10 @@ public class ContextServletIT extends BaseIT {
assertEquals("Public login must not take over the other profile",
publicCallerId,
afterLogin.getContextResponse().getProfileId());
assertNotNull(profileService.load(otherId));
+ shouldBeTrueUntilEnd("Public login must not record the claimed
identifier on the caller profile",
+ () -> profileService.load(publicCallerId),
+ p -> p == null ||
p.getSystemProperties().get("mergeIdentifier") == null,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
rulesService.removeRule("testLogin");
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
index 68853c2b4..f6952e67f 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
@@ -197,17 +197,15 @@ public class ProfileMergeIT extends BaseIT {
Session session = new Session("untrustedMergeSession", publicCaller,
new Date(), null);
Event event = new Event(TEST_EVENT_TYPE, session, publicCaller, null,
null, publicCaller, new Date());
- javax.security.auth.Subject previous =
securityService.getCurrentSubject();
- try {
-
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
false));
- eventService.send(event);
- } finally {
- securityService.setCurrentSubject(previous);
- }
+ runAsTenantSubject(false, () -> eventService.send(event));
Assert.assertEquals("publicCallerProfileID",
event.getProfile().getItemId());
Assert.assertEquals("publicCallerProfileID",
event.getSession().getProfile().getItemId());
Assert.assertNotNull(profileService.load("otherProfileID"));
+ // The identity claim must not be recorded either: a planted
mergeIdentifier is what a later
+ // trusted login for [email protected] would merge on, pulling this
profile into that merge.
+ Assert.assertNull("untrusted caller must not record a merge
identifier",
+ publicCaller.getSystemProperties().get("mergeIdentifier"));
}
@Test
@@ -229,13 +227,7 @@ public class ProfileMergeIT extends BaseIT {
Session session = new Session("trustedMergeSession", caller, new
Date(), null);
Event event = new Event(TEST_EVENT_TYPE, session, caller, null, null,
caller, new Date());
- javax.security.auth.Subject previous =
securityService.getCurrentSubject();
- try {
-
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
true));
- eventService.send(event);
- } finally {
- securityService.setCurrentSubject(previous);
- }
+ runAsTenantSubject(true, () -> eventService.send(event));
Assert.assertEquals("trustedOtherProfileID",
event.getProfile().getItemId());
Assert.assertEquals("trustedOtherProfileID",
event.getSession().getProfile().getItemId());
diff --git
a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
index 319fabc0f..405440bae 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -133,14 +133,7 @@ public class PropertiesUpdateActionIT extends BaseIT {
updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY,
PROFILE_TEST_ID);
updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY,
"profile");
- javax.security.auth.Subject previous =
securityService.getCurrentSubject();
- try {
-
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
false));
- int changes = eventService.send(updateProperties);
- Assert.assertEquals(EventService.NO_CHANGE, changes);
- } finally {
- securityService.setCurrentSubject(previous);
- }
+ runAsTenantSubject(false, () ->
Assert.assertEquals(EventService.NO_CHANGE,
eventService.send(updateProperties)));
shouldBeTrueUntilEnd("Other profile must remain unchanged",
() -> profileService.load(PROFILE_TEST_ID),
@@ -159,17 +152,55 @@ public class PropertiesUpdateActionIT extends BaseIT {
propertyToUpdate.put("systemProperties.mergeIdentifier", "reused");
updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE,
propertyToUpdate);
- javax.security.auth.Subject previous =
securityService.getCurrentSubject();
- try {
-
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
false));
- eventService.send(updateProperties);
- } finally {
- securityService.setCurrentSubject(previous);
- }
+ runAsTenantSubject(false, () -> eventService.send(updateProperties));
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier"));
}
+ /**
+ * Same write through the commons-beanutils mapped syntax, which has no
dot and therefore
+ * escaped a prefix match on {@code systemProperties.}.
+ */
+ @Test
+ public void
testUntrustedCaller_cannotWriteSystemPropertiesViaMappedSyntax() throws
InterruptedException {
+ Profile caller = profileService.load(PROFILE_TEST_ID);
+ Assert.assertNull(caller.getSystemProperties().get("mergeIdentifier"));
+
+ Event updateProperties = new Event("updateProperties", null, caller,
null, null, null, new Date());
+ updateProperties.setPersistent(false);
+ Map<String, Object> propertyToUpdate = new HashMap<>();
+ propertyToUpdate.put("systemProperties(mergeIdentifier)",
"[email protected]");
+ updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE,
propertyToUpdate);
+
+ runAsTenantSubject(false, () ->
Assert.assertEquals(EventService.NO_CHANGE,
eventService.send(updateProperties)));
+
+
Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier"));
+ }
+
+ /**
+ * Reserved bean fields are reachable through the same property path as
ordinary properties;
+ * rewriting {@code itemId} would make the next save overwrite another
profile's document.
+ */
+ @Test
+ public void testUntrustedCaller_cannotRewriteProfileIdentityFields()
throws InterruptedException {
+ Profile caller = profileService.load(PROFILE_TEST_ID);
+
+ Event updateProperties = new Event("updateProperties", null, caller,
null, null, null, new Date());
+ updateProperties.setPersistent(false);
+ Map<String, Object> propertyToUpdate = new HashMap<>();
+ propertyToUpdate.put("itemId", PROFILE_TARGET_TEST_ID);
+ propertyToUpdate.put("mergedWith", PROFILE_TARGET_TEST_ID);
+ propertyToUpdate.put("segments", new
java.util.ArrayList<>(java.util.Collections.singletonList("vip")));
+ updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE,
propertyToUpdate);
+
+ runAsTenantSubject(false, () ->
Assert.assertEquals(EventService.NO_CHANGE,
eventService.send(updateProperties)));
+
+ Assert.assertEquals(PROFILE_TEST_ID, caller.getItemId());
+ Assert.assertNull(caller.getMergedWith());
+ Assert.assertTrue(caller.getSegments().isEmpty());
+
Assert.assertNull(profileService.load(PROFILE_TARGET_TEST_ID).getProperty("firstName"));
+ }
+
@Test
public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws
InterruptedException {
Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
@@ -183,13 +214,7 @@ public class PropertiesUpdateActionIT extends BaseIT {
updateProperties.setProperty(UpdatePropertiesAction.TARGET_ID_KEY,
PROFILE_TEST_ID);
updateProperties.setProperty(UpdatePropertiesAction.TARGET_TYPE_KEY,
"profile");
- javax.security.auth.Subject previous =
securityService.getCurrentSubject();
- try {
-
securityService.setCurrentSubject(securityService.createSubject(TEST_TENANT_ID,
true));
- eventService.send(updateProperties);
- } finally {
- securityService.setCurrentSubject(previous);
- }
+ runAsTenantSubject(true, () -> eventService.send(updateProperties));
waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE");
}
diff --git a/manual/src/main/asciidoc/builtin-event-types.adoc
b/manual/src/main/asciidoc/builtin-event-types.adoc
index 8a4402a31..0067a6055 100644
--- a/manual/src/main/asciidoc/builtin-event-types.adoc
+++ b/manual/src/main/asciidoc/builtin-event-types.adoc
@@ -245,9 +245,14 @@ This event is usually used by user interfaces that make it
possible to modify pr
Note that this event type is a protected event type that is only accepted from
configured third-party servers, which are
authorized by source IP address (and in V2 compatibility mode by an
`X-Unomi-Peer` key valid for that address). That
gate is separate from the public-versus-trusted caller distinction that
governs which profile and session a request may
-bind to — see <<_client_facing_hardening_3_1,client-facing hardening>>. Once
the event is accepted, its `targetId`
-cross-profile update and its `systemProperties.*` writes are not subject to a
further caller check, so the address
-allowlist is what protects them.
+bind to — see <<_client_facing_hardening_3_1,client-facing hardening>>. Once
the event is accepted, the property
+names in `add`, `update`, `addToSet` and `delete` are checked against an
allowlist. A public-key caller may only write
+plain dotted paths under `properties` (for example `properties.address.city`).
A trusted caller (system administrator
+or tenant private key) may also write `systemProperties`, `segments`, `scores`
and `consents`, and may target another
+profile through `targetId`. Identity and bookkeeping fields of the profile
(`itemId`, `itemType`, `tenantId`,
+`version`, `mergedWith`, ...) are never writable, and names using beanutils
mapped or indexed syntax (`a(b)`, `a[0]`),
+empty path segments or control characters are rejected for everyone. Refused
names are skipped with a `WARN` log entry;
+the rest of the event still applies.
===== Structure definition
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index 59350e8ec..56edd5da1 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -256,7 +256,6 @@ When migrating to 3.1, you need to understand that:
- Public `/context.json` and `/eventcollector` no longer treat body/query
`profileId` as identity — see below
- Endpoint paths and most payload shapes remain the same; authentication
and public profile-binding rules change
-[#_client_facing_hardening_3_1]
==== Client-facing hardening (profile cookie, passwords, privileged APIs)
In addition to tenant API keys, Unomi 3.1 hardens several contracts that can
break older clients and scripts. See also <<_how_profile_tracking_works,How
profile tracking works>> and
https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972].
@@ -293,7 +292,7 @@ In addition to tenant API keys, Unomi 3.1 hardens several
contracts that can bre
|`mergeProfilesOnProperty` / cross-profile `updateProperties` /
`systemProperties.*`
|Sometimes driven from public context events
-|Cross-profile merge/update and `systemProperties` writes require a trusted
caller (system or tenant admin). Public callers may still update the
**current** cookie-bound profile's normal properties when the event type allows
it.
+|Cross-profile merge/update and `systemProperties` writes require a trusted
caller (system or tenant admin); this includes recording the merge identifier
on the current profile, so a public `login` event no longer claims an identity
at all. `updateProperties` now applies an allowlist to property names: public
callers may only write plain dotted `properties.*` paths on the **current**
cookie-bound profile; `systemProperties`, `segments`, `scores` and `consents`
need a trusted caller; iden [...]
|===
[WARNING]
diff --git a/manual/src/main/asciidoc/recipes.adoc
b/manual/src/main/asciidoc/recipes.adoc
index 84b74a1ab..f2a704791 100644
--- a/manual/src/main/asciidoc/recipes.adoc
+++ b/manual/src/main/asciidoc/recipes.adoc
@@ -137,8 +137,10 @@ defining the corresponding JSON schema and you're ready to
update profiles using
only. Being a protected event type, it is only accepted from an authorized
source address — a configured third-party
server, or in V2 compatibility mode an `X-Unomi-Peer` key valid for that
source address. Note that this is an
address-based gate: it is independent of the public-versus-trusted caller
distinction that governs profile and session
-binding (see <<_client_facing_hardening_3_1,client-facing hardening>>), and
once the event is accepted its `targetId`
-and `systemProperties.*` writes are not further restricted. Prefer custom
events for public visitors. Again, prefer the
+binding (see <<_client_facing_hardening_3_1,client-facing hardening>>), and
once the event is accepted a public-key
+caller may only write plain `properties.*` paths on the current profile;
`targetId`, `systemProperties`, `segments`,
+`scores` and `consents` require a trusted caller (system administrator or
tenant private key), and identity fields such
+as `itemId` are never writable. Refused names are skipped with a `WARN` log
entry. Prefer custom events for public visitors. Again, prefer the
custom events solution because as this is a
protected event it will require sending trusted credentials, and as Unomi only
supports a single key for the moment it
could be problematic if the key is intercepted. But at least by using an event
you will get the benefits of auditing and
diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc
b/manual/src/main/asciidoc/samples/login-sample.adoc
index 8e2ae12fb..93e93015c 100644
--- a/manual/src/main/asciidoc/samples/login-sample.adoc
+++ b/manual/src/main/asciidoc/samples/login-sample.adoc
@@ -95,7 +95,11 @@ trusted credentials hands that power straight back to the
browser: anyone who gu
visitor's session id could rebind or merge that visitor's profile.
`LoginServlet` therefore derives the session id from state it controls — a
UUID stored on the
-browser's container `HttpSession` — and never reads it from a request
parameter. Apply the same rule
+browser's container `HttpSession` — and never reads it from a request
parameter. The consequence is
+that the anonymous profile the browser built up through the public tracker
before logging in is
+*not* linked by this sample: doing so requires forwarding a browser-chosen
identifier (the profile
+cookie or the tracker's session id) under trusted credentials, which your
server must only do once
+it has established that the identifier belongs to the caller. Apply the same
rule
in your own integration:
* Derive the `sessionId` from your authenticated server-side session, not from
the request body.
diff --git
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java
new file mode 100644
index 000000000..c4052042e
--- /dev/null
+++
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/IdentityTrust.java
@@ -0,0 +1,44 @@
+/*
+ * 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.plugins.baseplugin.actions;
+
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+
+/**
+ * Single definition of the trust policy for identity claims made through
actions: merging
+ * profiles, recording a merge identifier, updating another profile, or writing
+ * {@code systemProperties}.
+ * <p>
+ * This is a role check, not a check of the credential that produced it: a
tenant private key
+ * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore
passes, while a tenant
+ * public API key or an unauthenticated context event does not. A missing
+ * {@link SecurityService} fails closed.
+ */
+final class IdentityTrust {
+
+ private IdentityTrust() {
+ }
+
+ /**
+ * @param securityService the security service, possibly null while OSGi
wiring is in flux
+ * @return whether the caller holds system access (administrator or tenant
administrator)
+ */
+ static boolean isTrustedIdentityCaller(SecurityService securityService) {
+ return securityService != null && securityService.hasSystemAccess();
+ }
+}
diff --git
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
index ff4832cc3..7ef891b24 100644
---
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
+++
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyAction.java
@@ -26,7 +26,6 @@ import org.apache.unomi.api.actions.Action;
import org.apache.unomi.api.actions.ActionExecutor;
import org.apache.unomi.api.conditions.Condition;
import org.apache.unomi.api.security.SecurityService;
-import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.*;
import org.apache.unomi.persistence.spi.PersistenceService;
import org.slf4j.Logger;
@@ -112,6 +111,20 @@ public class MergeProfilesOnPropertyAction implements
ActionExecutor {
return EventService.PROFILE_UPDATED +
EventService.SESSION_UPDATED;
}
+ // Recording the merge identifier is itself an identity claim, not
just the merge that
+ // may follow it: a value persisted here is what a later, trusted
login for the same
+ // identifier queries on, so an untrusted caller that could plant
it would have its
+ // profile pulled into that merge (and aliased onto the master)
once the real owner logs
+ // in. Refuse the whole step, not only the merge, for callers
without system access.
+ if (!isTrustedIdentityCaller()) {
+ LOGGER.warn("Refusing merge identity claim for untrusted
caller (mergeProp={}, candidates={})",
+ mergePropName, profilesToBeMerge.size());
+ if (tracer != null) {
+ tracer.endOperation(false, "Untrusted caller cannot claim
a merge identity");
+ }
+ return EventService.NO_CHANGE;
+ }
+
// Store merge prop on current profile
boolean profileUpdated = false;
if (StringUtils.isEmpty(currentProfileMergeValue)) {
@@ -127,18 +140,6 @@ public class MergeProfilesOnPropertyAction implements
ActionExecutor {
return profileUpdated ? EventService.PROFILE_UPDATED :
EventService.NO_CHANGE;
}
- // Merging into another existing profile rebinds the session —
require a caller holding
- // system access (see isTrustedIdentityCaller), not a
public/unauthenticated event.
- if (!isTrustedIdentityCaller()) {
- LOGGER.warn("Refusing profile merge for untrusted caller
(mergeProp={}, candidates={})",
- mergePropName, profilesToBeMerge.size());
- if (tracer != null) {
- tracer.endOperation(false, "Untrusted caller cannot merge
into another profile");
- }
- // Keep only the merge identifier write on the current profile
when it was empty
- return profileUpdated ? EventService.PROFILE_UPDATED :
EventService.NO_CHANGE;
- }
-
// add current Profile to profiles to be merged
if (profilesToBeMerge.stream().noneMatch(p ->
StringUtils.equals(p.getItemId(), eventProfile.getItemId()))) {
profilesToBeMerge.add(eventProfile);
@@ -348,15 +349,12 @@ public class MergeProfilesOnPropertyAction implements
ActionExecutor {
}
/**
- * Whether the caller holds system access, i.e. the administrator or
tenant administrator role.
- * <p>
- * This is a role check, not a check of the credential that produced it: a
tenant private key
- * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore
passes, while a tenant
- * public API key or an unauthenticated context event does not. Identity
merges rebind sessions,
- * so they are restricted to callers that hold that role.
+ * Identity claims (switching, recording or merging on a merge identifier)
are restricted to
+ * callers holding system access; see {@link IdentityTrust}. This applies
to a switch even when
+ * no candidate profile exists yet: the switch still rebinds the session
to a new profile.
*/
private boolean isTrustedIdentityCaller() {
- return securityService != null && securityService.hasSystemAccess();
+ return IdentityTrust.isTrustedIdentityCaller(securityService);
}
public void setProfileService(ProfileService profileService) {
diff --git
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
index 78070d144..005a258c7 100644
---
a/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
+++
b/plugins/baseplugin/src/main/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesAction.java
@@ -26,7 +26,6 @@ import org.apache.unomi.api.actions.Action;
import org.apache.unomi.api.actions.ActionExecutor;
import org.apache.unomi.api.security.SecurityService;
import org.apache.unomi.api.utils.LogSanitizer;
-import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.EventService;
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.persistence.spi.PropertyHelper;
@@ -36,6 +35,7 @@ import org.apache.unomi.tracing.api.TracerService;
import org.apache.unomi.tracing.api.RequestTracer;
import java.util.*;
+import java.util.regex.Pattern;
public class UpdatePropertiesAction implements ActionExecutor {
@@ -49,10 +49,21 @@ public class UpdatePropertiesAction implements
ActionExecutor {
public static final String TARGET_TYPE_PROFILE = "profile";
- /** The reserved profile field that only a caller with system access may
write. */
- private static final String SYSTEM_PROPERTIES_KEY = "systemProperties";
- /** Prefix of the profile properties that only a caller with system access
may write. */
- private static final String SYSTEM_PROPERTIES_PREFIX =
SYSTEM_PROPERTIES_KEY + ".";
+ /**
+ * The only profile areas an event may write, by caller trust. Everything
else on the bean
+ * ({@code itemId}, {@code itemType}, {@code tenantId}, {@code version},
{@code mergedWith},
+ * {@code scope}, {@code systemMetadata}, ...) is identity or bookkeeping
and is never writable
+ * through an event: an allowlist needs no knowledge of what a future
field might be called.
+ */
+ private static final Set<String> PUBLIC_WRITABLE_AREAS =
Set.of("properties");
+ private static final Set<String> TRUSTED_WRITABLE_AREAS =
Set.of("properties", "systemProperties", "segments", "scores", "consents");
+ /**
+ * A plain dotted path: non-empty segments, no control characters, and
none of the
+ * commons-beanutils mapped/indexed syntax ({@code a(b)}, {@code a[0]})
that
+ * {@link PropertyHelper#setProperty} would otherwise interpret.
Everything this action
+ * legitimately writes is expressible as {@code area.key.subkey}.
+ */
+ private static final Pattern PROPERTY_PATH =
Pattern.compile("[^.()\\[\\]\\p{Cntrl}]+(?:\\.[^.()\\[\\]\\p{Cntrl}]+)*");
private static final Logger LOGGER =
LoggerFactory.getLogger(UpdatePropertiesAction.class.getName());
@@ -124,8 +135,9 @@ public class UpdatePropertiesAction implements
ActionExecutor {
List<String> propsToDelete = (List<String>)
event.getProperties().get(PROPS_TO_DELETE);
if (propsToDelete != null) {
for (String prop : propsToDelete) {
- if (!trustedCaller && isSystemPropertiesWrite(prop)) {
- LOGGER.warn("Refusing systemProperties delete for
untrusted caller: {}", LogSanitizer.forLogging(prop));
+ if (!isWritable(prop, trustedCaller)) {
+ LOGGER.warn("Refusing property delete for {} caller:
{}", trustedCaller ? "trusted" : "untrusted",
+ LogSanitizer.forLogging(prop));
continue;
}
isProfileOrPersonaUpdated |=
PropertyHelper.setProperty(target, prop, null, "remove");
@@ -166,12 +178,13 @@ public class UpdatePropertiesAction implements
ActionExecutor {
private boolean processProperties(Profile target, Map<String, Object>
propsMap, String strategy, boolean trustedCaller) {
boolean isProfileOrPersonaUpdated = false;
for (String prop : propsMap.keySet()) {
- if (!trustedCaller && isSystemPropertiesWrite(prop)) {
- LOGGER.warn("Refusing systemProperties update for untrusted
caller: {}", LogSanitizer.forLogging(prop));
+ if (!isWritable(prop, trustedCaller)) {
+ LOGGER.warn("Refusing property write for {} caller: {}",
trustedCaller ? "trusted" : "untrusted",
+ LogSanitizer.forLogging(prop));
continue;
}
PropertyType propType = null;
- if (prop.startsWith("properties.") ||
prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) {
+ if (prop.startsWith("properties.") ||
prop.startsWith("systemProperties.")) {
propType =
profileService.getPropertyType(prop.substring(prop.indexOf('.') + 1));
} else {
propType = profileService.getPropertyType(prop);
@@ -191,30 +204,29 @@ public class UpdatePropertiesAction implements
ActionExecutor {
/**
- * Whether a property name writes the reserved {@code systemProperties}
area.
+ * Whether an event-supplied property name may be written by this caller.
* <p>
- * Matching the {@code systemProperties.} prefix alone was not enough: the
bare key
- * {@code systemProperties} has no dot, so it slipped through, and for a
flat name
- * {@link org.apache.unomi.persistence.spi.PropertyHelper#setProperty}
falls through to
- * {@code BeanUtils.setProperty}, which invokes {@code
Profile#setSystemProperties(Map)} and
- * replaces the entire map. That is strictly more than the per-key write
this gate blocks — it
- * is enough to plant {@code mergeIdentifier} and drive the profile-merge
action.
+ * One rule covers every injection shape seen against this action: the
name must be a plain
+ * dotted path (so beanutils mapped/indexed syntax, empty segments and
control characters are
+ * out), and its first segment must be one of the areas allowed for the
caller's trust level
+ * (so identity fields such as {@code itemId} or {@code mergedWith}, and
for public callers
+ * {@code systemProperties}, {@code segments}, {@code scores} and {@code
consents}, are out).
*
* @param propertyName the event-supplied property name
- * @return true when the name targets systemProperties, whether wholesale
or a single entry
+ * @param trustedCaller whether the caller holds system access
+ * @return true when the write is allowed
*/
- private static boolean isSystemPropertiesWrite(String propertyName) {
- return SYSTEM_PROPERTIES_KEY.equals(propertyName) ||
propertyName.startsWith(SYSTEM_PROPERTIES_PREFIX);
+ static boolean isWritable(String propertyName, boolean trustedCaller) {
+ if (propertyName == null ||
!PROPERTY_PATH.matcher(propertyName).matches()) {
+ return false;
+ }
+ int dot = propertyName.indexOf('.');
+ String area = dot < 0 ? propertyName : propertyName.substring(0, dot);
+ return (trustedCaller ? TRUSTED_WRITABLE_AREAS :
PUBLIC_WRITABLE_AREAS).contains(area);
}
- /**
- * Whether the caller holds system access, i.e. the administrator or
tenant administrator role.
- * <p>
- * Cross-profile updates and {@code systemProperties.*} writes are
restricted to such callers.
- * A tenant private key authenticates as {@link
UnomiRoles#TENANT_ADMINISTRATOR} and passes; a
- * tenant public API key or an unauthenticated context event does not.
- */
+
private boolean isTrustedIdentityCaller() {
- return securityService != null && securityService.hasSystemAccess();
+ return IdentityTrust.isTrustedIdentityCaller(securityService);
}
public void setProfileService(ProfileService profileService) {
diff --git
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/ActionIdentityIntrusionTest.java
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/ActionIdentityIntrusionTest.java
new file mode 100644
index 000000000..ac03cb3ff
--- /dev/null
+++
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/ActionIdentityIntrusionTest.java
@@ -0,0 +1,271 @@
+/*
+ * 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.plugins.baseplugin.actions;
+
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.PartialList;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.DefinitionsService;
+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.persistence.spi.PersistenceService;
+import org.junit.Before;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnitRunner;
+
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNull;
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyInt;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Intrusion scenarios against the two identity-bearing actions, run the way a
public-key caller
+ * would drive them: every classic injection shape for a property path, every
reserved field of the
+ * profile bean, and hostile merge values. Each scenario asserts on the
resulting profile state, not
+ * on the return code alone, so a regression in either action (or in
PropertyHelper, which resolves
+ * the names through commons-beanutils) shows up as a concrete unwanted write.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class ActionIdentityIntrusionTest {
+
+ @Mock private ProfileService profileService;
+ @Mock private PersistenceService persistenceService;
+ @Mock private EventService eventService;
+ @Mock private DefinitionsService definitionsService;
+ @Mock private PrivacyService privacyService;
+ @Mock private SecurityService securityService;
+
+ private UpdatePropertiesAction updateAction;
+ private MergeProfilesOnPropertyAction mergeAction;
+
+ /** Property paths a public caller must never be able to write through,
whatever the value. */
+ private static final List<String> HOSTILE_PATHS = Arrays.asList(
+ // identity and bookkeeping fields of the bean
+ "itemId", "itemType", "tenantId", "version", "mergedWith",
"scope", "systemMetadata",
+ // trust-bearing areas reserved to trusted callers
+ "systemProperties", "systemProperties.mergeIdentifier",
"systemProperties.mergedWith",
+ "segments", "scores.vip", "consents.newsletter",
+ // beanutils mapped / indexed / chained syntax
+ "systemProperties(mergeIdentifier)", "systemProperties[0]",
"systemProperties(a).b",
+ "systemProperties(a)(b)", "properties(a.b)", "properties[0]",
+ // path shape tricks: empty segments, leading/trailing dots,
control characters
+ "properties..x", "properties.",
".systemProperties.mergeIdentifier",
+ "properties.a\tb", "properties.a\nb", "properties\u0000.x",
+ // the classic commons-beanutils gadget
+ "class.classLoader.URLs", "class",
+ // encodings and lookalikes that never resolve, listed so a future
decoder cannot
+ // quietly turn them into the real thing
+ "systemProperties%2EmergeIdentifier",
"systemProperties\\.mergeIdentifier",
+ "SystemProperties.mergeIdentifier",
"\uFF53ystemProperties.mergeIdentifier",
+ "systemProperties\u200B.mergeIdentifier");
+
+ /** Values covering the shapes JSON can deliver: scalars, collections, and
nested maps. */
+ private static final List<Object> HOSTILE_VALUES = Arrays.asList(
+ "victim-profile-id",
+ Collections.singletonMap("mergeIdentifier", "[email protected]"),
+ new ArrayList<>(Arrays.asList("vip", "admin")),
+ Long.valueOf(99),
+ Boolean.TRUE);
+
+ @Before
+ public void setUp() {
+ updateAction = new UpdatePropertiesAction();
+ updateAction.setProfileService(profileService);
+ updateAction.setEventService(eventService);
+ updateAction.setSecurityService(securityService);
+
+ mergeAction = new MergeProfilesOnPropertyAction();
+ mergeAction.setProfileService(profileService);
+ mergeAction.setPersistenceService(persistenceService);
+ mergeAction.setEventService(eventService);
+ mergeAction.setDefinitionsService(definitionsService);
+ mergeAction.setPrivacyService(privacyService);
+ mergeAction.bindSecurityService(securityService);
+ mergeAction.setMaxProfilesInOneMerge("50");
+
+ when(securityService.hasSystemAccess()).thenReturn(false);
+ }
+
+ @Test
+ public void
publicCaller_hostilePathsLeaveEveryReservedFieldUntouched_add_update_addToSet()
{
+ for (String strategyKey : new String[] {
+ UpdatePropertiesAction.PROPS_TO_ADD,
UpdatePropertiesAction.PROPS_TO_UPDATE,
UpdatePropertiesAction.PROPS_TO_ADD_TO_SET}) {
+ for (String path : HOSTILE_PATHS) {
+ for (Object value : HOSTILE_VALUES) {
+ Profile caller = freshCaller();
+ Map<String, Object> props = new HashMap<>();
+ props.put(path, value);
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(strategyKey, props);
+
+ int changes = updateAction.execute(new Action(),
event(caller, eventProps));
+
+ String scenario = strategyKey + " " + printable(path) + "
= " + value;
+ assertEquals(scenario, EventService.NO_CHANGE, changes);
+ assertUntouched(scenario, caller);
+ }
+ }
+ }
+ }
+
+ @Test
+ public void publicCaller_hostilePathsCannotDeleteReservedFields() {
+ for (String path : HOSTILE_PATHS) {
+ Profile caller = freshCaller();
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_DELETE,
Collections.singletonList(path));
+
+ int changes = updateAction.execute(new Action(), event(caller,
eventProps));
+
+ assertEquals("delete " + printable(path), EventService.NO_CHANGE,
changes);
+ assertUntouched("delete " + printable(path), caller);
+ }
+ }
+
+ @Test
+ public void publicCaller_mixedBatchAppliesOnlyTheOrdinaryProperty() {
+ Profile caller = freshCaller();
+ Map<String, Object> props = new LinkedHashMap<>();
+ props.put("properties.firstName", "Alice");
+ props.put("systemProperties.mergeIdentifier", "[email protected]");
+ props.put("itemId", "victim-profile-id");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, props);
+
+ int changes = updateAction.execute(new Action(), event(caller,
eventProps));
+
+ assertEquals(EventService.PROFILE_UPDATED, changes);
+ assertEquals("Alice", caller.getProperty("firstName"));
+ assertUntouched("mixed batch", caller);
+ }
+
+ @Test
+ public void
publicCaller_cannotReachAnotherProfileThroughTargetIdVariants() {
+ for (String targetId : Arrays.asList("victim-profile-id", "
victim-profile-id", "victim-profile-id\n", "../victim")) {
+ for (String targetType : Arrays.asList("profile", "persona", "",
"PROFILE", null)) {
+ Profile caller = freshCaller();
+ Map<String, Object> props = new HashMap<>();
+ props.put("properties.firstName", "Mallory");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, targetId);
+ eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY,
targetType);
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, props);
+
+ int changes = updateAction.execute(new Action(), event(caller,
eventProps));
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ assertNull("caller's own profile must not be written either",
caller.getProperty("firstName"));
+ }
+ }
+ verify(profileService, never()).load(anyString());
+ verify(profileService, never()).loadPersona(anyString());
+ verify(profileService, never()).save(any(Profile.class));
+ }
+
+ @Test
+ public void trustedCaller_reservedIdentityFieldsAreNeverWritable() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ for (String path : Arrays.asList("itemId", "itemType", "tenantId",
"version", "mergedWith", "scope", "class.classLoader.URLs",
+ "systemProperties(mergeIdentifier)", "properties(a.b)",
"properties..x")) {
+ Profile caller = freshCaller();
+ Map<String, Object> props = new HashMap<>();
+ props.put(path, "victim-profile-id");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, props);
+
+ int changes = updateAction.execute(new Action(), event(caller,
eventProps));
+
+ assertEquals("trusted " + printable(path), EventService.NO_CHANGE,
changes);
+ assertUntouched("trusted " + printable(path), caller);
+ }
+ }
+
+ @Test
+ public void publicCaller_hostileMergeValuesNeverRecordOrMergeAnIdentity() {
+
when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(new
ConditionType());
+ Profile other = new Profile("victim-profile-id");
+ other.getSystemProperties().put("mergeIdentifier",
"[email protected]");
+ when(persistenceService.query(any(), anyString(), eq(Profile.class),
anyInt(), anyInt()))
+ .thenReturn(new PartialList<>(new
ArrayList<>(Collections.singletonList(other)), 0, 1, 1,
PartialList.Relation.EQUAL));
+
+ for (String value : Arrays.asList("[email protected]", "*",
"[email protected] OR 1=1", "[email protected]\n",
+ "{\"query\":{\"match_all\":{}}}", "victim-profile-id")) {
+ Profile caller = freshCaller();
+ Action action = new Action();
+ Map<String, Object> params = new HashMap<>();
+ params.put("mergeProfilePropertyName", "mergeIdentifier");
+ params.put("mergeProfilePropertyValue", value);
+ action.setParameterValues(params);
+ Event event = new Event("login", null, caller, "systemscope",
null, null, null, new Date(), true);
+
+ int changes = mergeAction.execute(action, event);
+
+ assertEquals(printable(value), EventService.NO_CHANGE, changes);
+ assertEquals(printable(value), "attacker",
event.getProfile().getItemId());
+ assertUntouched(printable(value), caller);
+ }
+ verify(profileService, never()).mergeProfiles(any(), any());
+ }
+
+ private static Profile freshCaller() {
+ Profile caller = new Profile("attacker");
+ caller.getSystemProperties().put("existing", "keep");
+ return caller;
+ }
+
+ private static Event event(Profile caller, Map<String, Object> eventProps)
{
+ return new Event("updateProperties", null, caller, "systemscope",
null, null, eventProps, new Date(), true);
+ }
+
+ /** Everything a public caller must not be able to change on its own
profile. */
+ private static void assertUntouched(String scenario, Profile caller) {
+ assertEquals(scenario, "attacker", caller.getItemId());
+ assertEquals(scenario, Profile.ITEM_TYPE, caller.getItemType());
+ assertNull(scenario, caller.getMergedWith());
+ assertNull(scenario, caller.getTenantId());
+ assertEquals(scenario, Collections.singletonMap("existing", "keep"),
caller.getSystemProperties());
+ assertTrue(scenario, caller.getSegments().isEmpty());
+ assertTrue(scenario, caller.getScores() == null ||
caller.getScores().isEmpty());
+ assertTrue(scenario, caller.getConsents() == null ||
caller.getConsents().isEmpty());
+ assertNull(scenario + " (map key 'class' would mean the gadget path
was reached)", caller.getProperty("class"));
+ }
+
+ private static String printable(String s) {
+ return s.replace("\t", "\\t").replace("\n", "\\n").replace("\u0000",
"\\0");
+ }
+}
diff --git
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
index 0ae1783aa..95a51a680 100644
---
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
+++
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
@@ -41,7 +41,7 @@ import java.util.HashMap;
import java.util.Map;
import static org.junit.Assert.assertEquals;
-import static org.junit.Assert.assertNotEquals;
+import static org.junit.Assert.assertNull;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyInt;
import static org.mockito.ArgumentMatchers.anyString;
@@ -94,12 +94,50 @@ public class MergeProfilesOnPropertyActionTest {
int changes = actionExecutor.execute(action, event);
- // May write mergeIdentifier onto the untrusted caller profile, but
must not rebind to other
- assertNotEquals(EventService.PROFILE_UPDATED +
EventService.SESSION_UPDATED, changes);
+ assertEquals(EventService.NO_CHANGE, changes);
assertEquals("public-caller", event.getProfile().getItemId());
+ assertNull("the untrusted caller must not record the identity claim
either",
+ publicCaller.getSystemProperties().get("mergeIdentifier"));
verify(profileService, never()).mergeProfiles(any(), any());
}
+ /**
+ * The identifier write is the poisoning primitive, independently of
whether a candidate exists
+ * today: a value planted here is what a later trusted login for the same
identifier merges on,
+ * which would pull the planter's profile into the real owner's merge and
alias it onto the
+ * master. So the empty-candidates path must refuse too, not only the
merge path.
+ */
+ @Test
+ public void
untrustedCaller_cannotRecordMergeIdentifierEvenWithoutCandidates() {
+ Profile publicCaller = new Profile("public-caller");
+
+ when(persistenceService.query(any(), anyString(), eq(Profile.class),
anyInt(), anyInt()))
+ .thenReturn(new PartialList<>(new ArrayList<>(), 0, 0, 0,
PartialList.Relation.EQUAL));
+
+ Event event = new Event("login", null, publicCaller, "systemscope",
null, null, null, new Date(), true);
+
+ int changes =
actionExecutor.execute(mergeAction("[email protected]"), event);
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ assertNull(publicCaller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void
trustedTenantAdmin_recordsMergeIdentifierWhenNoCandidateExists() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+ Profile caller = new Profile("caller");
+
+ when(persistenceService.query(any(), anyString(), eq(Profile.class),
anyInt(), anyInt()))
+ .thenReturn(new PartialList<>(new ArrayList<>(), 0, 0, 0,
PartialList.Relation.EQUAL));
+
+ Event event = new Event("login", null, caller, "systemscope", null,
null, null, new Date(), true);
+
+ int changes = actionExecutor.execute(mergeAction("[email protected]"),
event);
+
+ assertEquals(EventService.PROFILE_UPDATED, changes);
+ assertEquals("[email protected]",
caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
@Test
public void trustedTenantAdmin_canMergeIntoExistingProfile() {
when(securityService.hasSystemAccess()).thenReturn(true);
diff --git
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
index f1e33efd2..b0e9a3fe7 100644
---
a/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
+++
b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
@@ -190,4 +190,92 @@ public class UpdatePropertiesActionTest {
verify(profileService).load("other");
verify(profileService).save(other);
}
+
+ /**
+ * commons-beanutils also accepts the mapped syntax {@code
systemProperties(key)}. It has no
+ * dot, so PropertyHelper hands it to {@code BeanUtils.setProperty}
unchanged, and beanutils
+ * resolves it as {@code getSystemProperties().put(key, value)}: the same
write as the dotted
+ * form, which a prefix check on {@code systemProperties.} never saw.
+ */
+ @Test
+ public void untrustedCaller_cannotWriteSystemPropertiesViaMappedSyntax() {
+ Profile caller = new Profile("caller");
+ Map<String, Object> updateMap = new HashMap<>();
+ updateMap.put("systemProperties(mergeIdentifier)",
"[email protected]");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+ Event event = new Event("updateProperties", null, caller,
"systemscope", null, null, eventProps, new Date(), true);
+
+ int changes = actionExecutor.execute(new Action(), event);
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ assertEquals(null,
caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void untrustedCaller_cannotAddToSystemPropertiesSet() {
+ Profile caller = new Profile("caller");
+ Map<String, Object> addToSetMap = new HashMap<>();
+ addToSetMap.put("systemProperties.mergeIdentifier",
"[email protected]");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_ADD_TO_SET,
addToSetMap);
+
+ Event event = new Event("updateProperties", null, caller,
"systemscope", null, null, eventProps, new Date(), true);
+
+ int changes = actionExecutor.execute(new Action(), event);
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ assertEquals(null,
caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void untrustedCaller_cannotDeleteASystemPropertiesEntry() {
+ Profile caller = new Profile("caller");
+ caller.getSystemProperties().put("mergeIdentifier",
"[email protected]");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_DELETE,
+
java.util.Collections.singletonList("systemProperties.mergeIdentifier"));
+
+ Event event = new Event("updateProperties", null, caller,
"systemscope", null, null, eventProps, new Date(), true);
+
+ int changes = actionExecutor.execute(new Action(), event);
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ assertEquals("[email protected]",
caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void writableCheck_isAnAllowlistOnAreaAndPathShape() {
+ // public callers: only plain dotted paths under properties
+ assertEquals(true,
UpdatePropertiesAction.isWritable("properties.firstName", false));
+ assertEquals(true,
UpdatePropertiesAction.isWritable("properties.address.city", false));
+ assertEquals(true,
UpdatePropertiesAction.isWritable("properties.j:nodename", false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("systemProperties.mergeIdentifier", false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("systemProperties(mergeIdentifier)", false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("systemProperties", false));
+ assertEquals(false, UpdatePropertiesAction.isWritable("segments",
false));
+ assertEquals(false, UpdatePropertiesAction.isWritable("scores.vip",
false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("consents.newsletter", false));
+ // identity and bookkeeping fields: nobody
+ assertEquals(false, UpdatePropertiesAction.isWritable("itemId", true));
+ assertEquals(false, UpdatePropertiesAction.isWritable("mergedWith",
true));
+ assertEquals(false, UpdatePropertiesAction.isWritable("tenantId",
true));
+ assertEquals(false, UpdatePropertiesAction.isWritable("version",
true));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("class.classLoader", true));
+ // trusted callers: the reserved areas, still as plain paths only
+ assertEquals(true,
UpdatePropertiesAction.isWritable("systemProperties.mergeIdentifier", true));
+ assertEquals(true,
UpdatePropertiesAction.isWritable("systemProperties", true));
+ assertEquals(true, UpdatePropertiesAction.isWritable("segments",
true));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("systemProperties(mergeIdentifier)", true));
+ assertEquals(false, UpdatePropertiesAction.isWritable("properties[0]",
true));
+ // path shape
+ assertEquals(false, UpdatePropertiesAction.isWritable("properties..x",
false));
+ assertEquals(false, UpdatePropertiesAction.isWritable("properties.",
false));
+ assertEquals(false, UpdatePropertiesAction.isWritable(".properties.x",
false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("properties.a\tb", false));
+ assertEquals(false,
UpdatePropertiesAction.isWritable("properties.a\nb", false));
+ assertEquals(false, UpdatePropertiesAction.isWritable("", false));
+ assertEquals(false, UpdatePropertiesAction.isWritable(null, false));
+ }
}
diff --git a/samples/login-integration/setup.sh
b/samples/login-integration/setup.sh
index ea56b13eb..37b15f89d 100755
--- a/samples/login-integration/setup.sh
+++ b/samples/login-integration/setup.sh
@@ -25,7 +25,7 @@
#
# Usage:
# export UNOMI_ROOT_PASSWORD='your-admin-password'
-# ./setup.sh [--version <sample-version>]
+# ./setup.sh
#
# Optional environment overrides:
# UNOMI_URL base URL of the running Unomi (default
http://localhost:8181)
@@ -41,14 +41,13 @@ TENANT_ID="${UNOMI_TENANT_ID:-default}"
SCOPE="${UNOMI_SCOPE:-default}"
PID="org.apache.unomi.samples.login"
SCRIPT_DIR=$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)
-SAMPLE_VERSION=""
usage() {
cat <<'USAGE'
Provisions and configures the Apache Unomi login sample on a local instance.
export UNOMI_ROOT_PASSWORD='your-admin-password'
- ./setup.sh [--version <sample-version>]
+ ./setup.sh
Environment overrides: UNOMI_URL, UNOMI_TENANT_ID, UNOMI_SCOPE, KARAF_HOME,
DEMO_PASSWORD.
USAGE
@@ -67,7 +66,6 @@ properties_escape() {
while [ $# -gt 0 ]; do
case "$1" in
- --version) SAMPLE_VERSION="${2:?--version needs a value}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
*) echo "Unknown argument: $1 (try --help)" >&2; exit 2 ;;
esac
diff --git
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
index b603ce6ed..6618041b8 100644
---
a/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
+++
b/samples/login-integration/src/main/java/org/apache/unomi/samples/login/LoginServlet.java
@@ -90,7 +90,7 @@ public class LoginServlet extends HttpServlet {
@ObjectClassDefinition(
name = "Unomi login sample",
- description = "Trusted credentials used by /login/authenticate to
call Unomi (UNOMI-972)"
+ description = "Trusted credentials used by /login/authenticate to
call Unomi (UNOMI-978)"
)
public @interface Config {
@@ -206,8 +206,12 @@ public class LoginServlet extends HttpServlet {
// would launder untrusted client input across the trust boundary and
let anyone who guesses
// another visitor's session id rebind or merge that visitor's
profile. Storing a generated
// id on the container's own HttpSession keeps it unreachable from the
page while staying stable
- // across requests from the same browser, which is what lets Unomi
recover the visitor's
- // pre-login anonymous profile.
+ // across requests from the same browser, so repeated logins from one
browser hit the same
+ // Unomi session. The trade-off is deliberate: nothing from the
visitor's own tracking
+ // (the context-profile-id cookie, the tracker's session id) is
forwarded, so the anonymous
+ // profile the browser built up before logging in is NOT linked here.
Linking it means
+ // forwarding a browser-chosen identifier under trusted credentials,
and an integration
+ // that wants that must first prove the identifier belongs to the
caller.
String sessionId = resolveSessionId(req);
// Only a tenant private key. A system administrator credential would
also satisfy the merge
diff --git
a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
index 9a220fdc4..499ecb913 100644
---
a/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
+++
b/samples/login-integration/src/test/java/org/apache/unomi/samples/login/LoginServletTest.java
@@ -19,10 +19,22 @@ package org.apache.unomi.samples.login;
import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Test;
+import com.fasterxml.jackson.databind.JsonNode;
+import com.fasterxml.jackson.databind.ObjectMapper;
+import com.sun.net.httpserver.HttpServer;
+
+import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
+import java.io.ByteArrayOutputStream;
+import java.io.InputStream;
import java.net.HttpURLConnection;
+import java.net.InetSocketAddress;
import java.net.URL;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.concurrent.atomic.AtomicReference;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.HashMap;
@@ -350,4 +362,141 @@ class LoginServletTest {
private static int intThatIsAShortTimeout() {
return org.mockito.ArgumentMatchers.intThat(seconds -> seconds > 0 &&
seconds <= 600);
}
+
+ //
------------------------------------------------------------------------------------------
+ // doPost — the trusted call itself: right credentials, right event,
nothing from the caller
+ // beyond the form fields, and Unomi's cookies handed back to the browser.
+ //
------------------------------------------------------------------------------------------
+
+ @Test
+ @DisplayName("doPost calls Unomi with the configured tenant private key
and forwards the response and cookies")
+ void doPostCallsUnomiWithTrustedCredentialsAndForwardsCookies() throws
Exception {
+ AtomicReference<String> authorization = new AtomicReference<>();
+ AtomicReference<String> query = new AtomicReference<>();
+ AtomicReference<byte[]> body = new AtomicReference<>();
+ HttpServer unomi = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ unomi.createContext("/cxs/context.json", exchange -> {
+
authorization.set(exchange.getRequestHeaders().getFirst("Authorization"));
+ query.set(exchange.getRequestURI().getRawQuery());
+ try (InputStream in = exchange.getRequestBody()) {
+ body.set(in.readAllBytes());
+ }
+ byte[] out =
"{\"profileId\":\"master\"}".getBytes(StandardCharsets.UTF_8);
+ exchange.getResponseHeaders().add("Set-Cookie",
"context-profile-id=master; Path=/");
+ exchange.getResponseHeaders().add("Set-Cookie",
"context-session-id=s; Path=/");
+ exchange.getResponseHeaders().add("Content-Type",
"application/json");
+ exchange.sendResponseHeaders(200, out.length);
+ exchange.getResponseBody().write(out);
+ exchange.close();
+ });
+ unomi.start();
+ try {
+ LoginServlet servlet = new LoginServlet();
+ servlet.activate(config("http://127.0.0.1:" +
unomi.getAddress().getPort(), "acme", "web", "private-key", "demo-pass"));
+
+ HttpSession session = statefulSession();
+ HttpServletRequest req = requestWithSession(session);
+ when(req.getParameter("email")).thenReturn("[email protected]");
+ when(req.getParameter("firstName")).thenReturn("Alice");
+ when(req.getParameter("lastName")).thenReturn("Doe");
+ when(req.getParameter("password")).thenReturn("demo-pass");
+
when(req.getParameter("sessionId")).thenReturn("attacker-chosen-session");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ ByteArrayOutputStream written = new ByteArrayOutputStream();
+ when(resp.getOutputStream()).thenReturn(capturing(written));
+
+ servlet.doPost(req, resp);
+
+ String expectedToken =
Base64.getEncoder().encodeToString("acme:private-key".getBytes(StandardCharsets.UTF_8));
+ assertEquals("Basic " + expectedToken, authorization.get(), "the
tenant private key must authenticate the call");
+ String unomiSessionId = (String)
session.getAttribute("org.apache.unomi.samples.login.unomiSessionId");
+ assertEquals("sessionId=" + unomiSessionId, query.get(), "the
session id must be the servlet's own, never the caller's");
+ JsonNode sent = new ObjectMapper().readTree(body.get());
+ assertEquals("login",
sent.get("events").get(0).get("eventType").asText());
+ assertEquals("web",
sent.get("events").get(0).get("scope").asText());
+ assertEquals("[email protected]",
sent.get("events").get(0).get("target").get("properties").get("email").asText());
+ verify(resp).setStatus(200);
+ verify(resp).addHeader("Set-Cookie", "context-profile-id=master;
Path=/");
+ verify(resp).addHeader("Set-Cookie", "context-session-id=s;
Path=/");
+ assertEquals("master", new
ObjectMapper().readTree(written.toByteArray()).get("profileId").asText());
+ } finally {
+ unomi.stop(0);
+ }
+ }
+
+ @Test
+ @DisplayName("doPost rejects a wrong demo password before touching Unomi")
+ void doPostRejectsWrongPasswordWithoutCallingUnomi() throws Exception {
+ AtomicReference<Boolean> called = new AtomicReference<>(false);
+ HttpServer unomi = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ unomi.createContext("/", exchange -> { called.set(true);
exchange.sendResponseHeaders(500, -1); exchange.close(); });
+ unomi.start();
+ try {
+ LoginServlet servlet = new LoginServlet();
+ servlet.activate(config("http://127.0.0.1:" +
unomi.getAddress().getPort(), "acme", "web", "private-key", "demo-pass"));
+ HttpServletRequest req = requestWithSession(statefulSession());
+ when(req.getParameter("email")).thenReturn("[email protected]");
+ when(req.getParameter("password")).thenReturn("wrong");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ when(resp.getOutputStream()).thenReturn(capturing(new
ByteArrayOutputStream()));
+
+ servlet.doPost(req, resp);
+
+ verify(resp).setStatus(HttpServletResponse.SC_UNAUTHORIZED);
+ assertFalse(called.get(), "Unomi must not be called for a failed
login");
+ } finally {
+ unomi.stop(0);
+ }
+ }
+
+ @Test
+ @DisplayName("doPost answers 502 with a generic message when Unomi is
unreachable")
+ void doPostAnswersBadGatewayWhenUnomiIsUnreachable() throws Exception {
+ HttpServer probe = HttpServer.create(new
InetSocketAddress("127.0.0.1", 0), 0);
+ int closedPort = probe.getAddress().getPort();
+ probe.stop(0);
+ LoginServlet servlet = new LoginServlet();
+ servlet.activate(config("http://127.0.0.1:" + closedPort, "acme",
"web", "private-key", "demo-pass"));
+ HttpServletRequest req = requestWithSession(statefulSession());
+ when(req.getParameter("email")).thenReturn("[email protected]");
+ when(req.getParameter("password")).thenReturn("demo-pass");
+ HttpServletResponse resp = mock(HttpServletResponse.class);
+ ByteArrayOutputStream written = new ByteArrayOutputStream();
+ when(resp.getOutputStream()).thenReturn(capturing(written));
+
+ servlet.doPost(req, resp);
+
+ verify(resp).setStatus(HttpServletResponse.SC_BAD_GATEWAY);
+ String message = written.toString(StandardCharsets.UTF_8);
+ assertTrue(message.contains("Profile service unavailable"), message);
+ assertFalse(message.contains("127.0.0.1"), "the upstream address must
not leak: " + message);
+ }
+
+ private static LoginServlet.Config config(String baseUrl, String tenantId,
String scope, String privateKey, String demoPassword) {
+ LoginServlet.Config config = mock(LoginServlet.Config.class);
+ when(config.unomiBaseUrl()).thenReturn(baseUrl);
+ when(config.tenantId()).thenReturn(tenantId);
+ when(config.scope()).thenReturn(scope);
+ when(config.privateKey()).thenReturn(privateKey);
+ when(config.demoPassword()).thenReturn(demoPassword);
+ return config;
+ }
+
+ private static ServletOutputStream capturing(ByteArrayOutputStream sink) {
+ return new ServletOutputStream() {
+ @Override
+ public void write(int b) {
+ sink.write(b);
+ }
+
+ @Override
+ public boolean isReady() {
+ return true;
+ }
+
+ @Override
+ public void setWriteListener(javax.servlet.WriteListener
writeListener) {
+ }
+ };
+ }
}