You are viewing a plain text version of this content. The canonical link for it is here.
Posted to dev@unomi.apache.org by sh...@apache.org on 2026/08/06 20:29:01 UTC
(unomi) branch hardening/credentials-profile-binding-privileged-rest updated (7883f98b8 -> bb2bffe92)
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a change to branch hardening/credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git
omit 7883f98b8 Require authentication for GraphQL WebSocket upgrade
new bb2bffe92 Hardening: require explicit passwords and tighten public profile binding
This update added new revisions after undoing existing revisions.
That is to say, some revisions that were in the old version of the
branch are not in the new version. This situation occurs
when a user --force pushes a change and generates a repository
containing something like this:
* -- * -- B -- O -- O -- O (7883f98b8)
\
N -- N -- N refs/heads/hardening/credentials-profile-binding-privileged-rest (bb2bffe92)
You should already have received notification emails for all of the O
revisions, and so the following emails describe only the N revisions
from the common base, B.
Any revisions marked "omit" are not gone; other references still
refer to them. Any revisions marked "discard" are gone forever.
The 1 revisions listed above as "new" are entirely new to this
repository and will be described in separate emails. The revisions
listed as "add" were already present in the repository and have only
been added to this reference.
Summary of changes:
SECURITY.md | 22 +-
THREAT_MODEL.md | 152 +++++-----
docker/README.md | 18 +-
docker/src/main/docker/docker-compose-build-es.yml | 2 +
docker/src/main/docker/docker-compose-build-os.yml | 2 +
docker/src/main/docker/docker-compose-cluster.yml | 4 +
docker/src/main/docker/docker-compose-es.yml | 3 +
docker/src/main/docker/docker-compose-os.yml | 3 +
docker/src/main/docker/entrypoint.sh | 30 ++
.../karaf-kar/src/main/feature/feature.xml | 2 +
extensions/groovy-actions/rest/pom.xml | 10 +
.../groovy/actions/rest/GroovyActionsEndPoint.java | 5 +
.../rest/GroovyActionsEndPointRoleTest.java} | 28 +-
.../src/main/feature/feature.xml | 2 +
extensions/router/router-rest/pom.xml | 10 +
.../rest/ExportConfigurationServiceEndPoint.java | 3 +
.../rest/ImportConfigurationServiceEndPoint.java | 3 +
.../rest/RouterConfigurationEndPointRoleTest.java | 44 +++
.../unomi/graphql/servlet/GraphQLServlet.java | 9 +-
.../auth/GraphQLServletSecurityValidator.java | 19 --
.../servlet/websocket/SubscriptionWebSocket.java | 96 ++-----
.../websocket/SubscriptionWebSocketFactory.java | 24 +-
.../auth/GraphQLServletSecurityValidatorTest.java | 34 ---
.../test/java/org/apache/unomi/itests/AllITs.java | 2 +
.../test/java/org/apache/unomi/itests/BaseIT.java | 6 +
.../test/java/org/apache/unomi/itests/BasicIT.java | 29 +-
.../org/apache/unomi/itests/ContextServletIT.java | 317 +++++++++++++++++++--
.../apache/unomi/itests/CorePersistenceITs.java | 2 +
.../org/apache/unomi/itests/HealthCheckIT.java | 11 +-
.../org/apache/unomi/itests/ProfileMergeIT.java | 65 +++++
.../unomi/itests/PropertiesUpdateActionIT.java | 76 +++++
.../unomi/itests/RestEndpointRoleSecurityIT.java | 177 ++++++++++++
.../java/org/apache/unomi/itests/TenantIT.java | 8 +-
.../apache/unomi/itests/V2CompatibilityModeIT.java | 4 +-
.../apache/unomi/itests/graphql/BaseGraphQLIT.java | 2 +-
.../unomi/itests/graphql/GraphQLWebSocketIT.java | 75 +----
itests/src/test/resources/etc/users.properties | 4 +-
manual/src/main/asciidoc/configuration.adoc | 38 ++-
manual/src/main/asciidoc/getting-started.adoc | 25 +-
package/src/main/resources/bin/setenv | 44 +++
package/src/main/resources/bin/setenv.bat | 43 +++
.../main/resources/etc/custom.system.properties | 8 +-
package/src/main/resources/etc/users.properties | 6 +-
.../actions/MergeProfilesOnPropertyAction.java | 29 ++
.../baseplugin/actions/UpdatePropertiesAction.java | 27 ++
.../resources/OSGI-INF/blueprint/blueprint.xml | 1 +
.../actions/MergeProfilesOnPropertyActionTest.java | 137 +++++++++
.../actions/UpdatePropertiesActionTest.java | 123 ++++++++
.../rest/service/impl/RestServiceUtilsImpl.java | 130 ++++++---
.../config/ShippedAdminPasswordConfigTest.java | 124 ++++++++
.../RestServiceUtilsImplProfileBindingTest.java | 213 ++++++++++++++
.../org/apache/unomi/web/servlets/WebConfig.java | 2 +-
.../src/main/resources/org.apache.unomi.web.cfg | 2 +-
53 files changed, 1855 insertions(+), 400 deletions(-)
copy extensions/{json-schema/services/src/main/java/org/apache/unomi/schema/api/ValidationException.java => groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java} (51%)
create mode 100644 extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java
create mode 100644 itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java
create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
create mode 100644 plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
create mode 100644 rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
create mode 100644 rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
(unomi) 01/01: Hardening: require explicit passwords and tighten public profile binding
Posted by sh...@apache.org.
This is an automated email from the ASF dual-hosted git repository.
sergehuber pushed a commit to branch hardening/credentials-profile-binding-privileged-rest
in repository https://gitbox.apache.org/repos/asf/unomi.git
commit bb2bffe924388b3067be0c1cfbbb90caeb6b6bc6
Author: Serge Huber <sh...@apache.org>
AuthorDate: Thu Aug 6 20:03:23 2026 +0200
Hardening: require explicit passwords and tighten public profile binding
Require UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD at startup,
treat the profile cookie as the public profile bearer, default HttpOnly,
restrict groovy/router REST to system administrator, and gate merge and
updateProperties for untrusted callers. Includes unit and IT coverage.
---
SECURITY.md | 22 +-
THREAT_MODEL.md | 152 +++++-----
docker/README.md | 18 +-
docker/src/main/docker/docker-compose-build-es.yml | 2 +
docker/src/main/docker/docker-compose-build-os.yml | 2 +
docker/src/main/docker/docker-compose-cluster.yml | 4 +
docker/src/main/docker/docker-compose-es.yml | 3 +
docker/src/main/docker/docker-compose-os.yml | 3 +
docker/src/main/docker/entrypoint.sh | 30 ++
.../karaf-kar/src/main/feature/feature.xml | 2 +
extensions/groovy-actions/rest/pom.xml | 10 +
.../groovy/actions/rest/GroovyActionsEndPoint.java | 5 +
.../rest/GroovyActionsEndPointRoleTest.java | 37 +++
.../src/main/feature/feature.xml | 2 +
extensions/router/router-rest/pom.xml | 10 +
.../rest/ExportConfigurationServiceEndPoint.java | 3 +
.../rest/ImportConfigurationServiceEndPoint.java | 3 +
.../rest/RouterConfigurationEndPointRoleTest.java | 44 +++
.../test/java/org/apache/unomi/itests/AllITs.java | 2 +
.../test/java/org/apache/unomi/itests/BaseIT.java | 6 +
.../test/java/org/apache/unomi/itests/BasicIT.java | 29 +-
.../org/apache/unomi/itests/ContextServletIT.java | 317 +++++++++++++++++++--
.../apache/unomi/itests/CorePersistenceITs.java | 2 +
.../org/apache/unomi/itests/HealthCheckIT.java | 11 +-
.../org/apache/unomi/itests/ProfileMergeIT.java | 65 +++++
.../unomi/itests/PropertiesUpdateActionIT.java | 76 +++++
.../unomi/itests/RestEndpointRoleSecurityIT.java | 177 ++++++++++++
.../java/org/apache/unomi/itests/TenantIT.java | 8 +-
.../apache/unomi/itests/V2CompatibilityModeIT.java | 4 +-
.../apache/unomi/itests/graphql/BaseGraphQLIT.java | 2 +-
itests/src/test/resources/etc/users.properties | 4 +-
manual/src/main/asciidoc/configuration.adoc | 38 ++-
manual/src/main/asciidoc/getting-started.adoc | 25 +-
package/src/main/resources/bin/setenv | 44 +++
package/src/main/resources/bin/setenv.bat | 43 +++
.../main/resources/etc/custom.system.properties | 8 +-
package/src/main/resources/etc/users.properties | 6 +-
.../actions/MergeProfilesOnPropertyAction.java | 29 ++
.../baseplugin/actions/UpdatePropertiesAction.java | 27 ++
.../resources/OSGI-INF/blueprint/blueprint.xml | 1 +
.../actions/MergeProfilesOnPropertyActionTest.java | 137 +++++++++
.../actions/UpdatePropertiesActionTest.java | 123 ++++++++
.../rest/service/impl/RestServiceUtilsImpl.java | 130 ++++++---
.../config/ShippedAdminPasswordConfigTest.java | 124 ++++++++
.../RestServiceUtilsImplProfileBindingTest.java | 213 ++++++++++++++
.../org/apache/unomi/web/servlets/WebConfig.java | 2 +-
.../src/main/resources/org.apache.unomi.web.cfg | 2 +-
47 files changed, 1842 insertions(+), 165 deletions(-)
diff --git a/SECURITY.md b/SECURITY.md
index 4a7e188b7..1fb715e55 100644
--- a/SECURITY.md
+++ b/SECURITY.md
@@ -19,8 +19,26 @@ limitations under the License.
## Reporting a Vulnerability
`apache/unomi` follows the [Apache Software Foundation security process](https://www.apache.org/security/). Please report suspected
-vulnerabilities privately to `security@apache.org` (the ASF security team routes Unomi reports to the project's private list, `private@unomi.apache.org`); do not open public
-GitHub issues or pull requests for security reports.
+vulnerabilities privately to `security@apache.org` (the ASF Security Team routes Unomi reports to the project's private PMC list,
+`private@unomi.apache.org`); do not open public GitHub issues or pull requests for security reports.
+
+## How the PMC handles reports
+
+Unomi follows the default ASF process in
+[ASF Project Security for Committers — Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling).
+Unomi does not currently maintain a dedicated `security@unomi.apache.org` list, so further private mail about an undisclosed issue
+should be copied to `security@apache.org` as that guide requires.
+
+Summary of the steps the PMC applies:
+
+1. **Work in private** — no public Jira/GitHub issues; commit messages must not call out the security nature of the fix until announcement.
+2. **Acknowledge** — email the reporter (cc `security@apache.org` / `private@unomi.apache.org`).
+3. **Investigate** — triage against [THREAT_MODEL.md](./THREAT_MODEL.md); **accept** or **reject** each distinct finding (a multi-issue report may be split).
+4. **If rejected** — write to the reporter explaining why (cc security lists). Rejection reasons include out-of-model / by-design findings and issues that affect **only unreleased** development code with no released-line impact (still fix before the next GA when appropriate).
+5. **If accepted** — tell the reporter we are working on a fix; request CVE ID(s) via [cveprocess.apache.org](https://cveprocess.apache.org) or `security@apache.org` (ASF Security can advise on splitting/merging CVEs).
+6. **Resolve** — agree the fix privately; document on the ASF CVE portal; share fix + draft announcement with the reporter; commit without security references; ship a release that includes the fix.
+7. **Announce** — with or after the release announcement (reporter, project lists, `security@apache.org`, `oss-security@lists.openwall.com`).
+8. **Complete** — update [unomi.apache.org/security/](https://unomi.apache.org/security/) and CVE references.
## Threat Model
diff --git a/THREAT_MODEL.md b/THREAT_MODEL.md
index f536032ef..91520316a 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -19,160 +19,184 @@ limitations under the License.
## §1 Header
- **Project:** Apache Unomi (`apache/unomi`), `master` branch, against which this draft was written. This model covers the **apache/unomi** server; `unomi-tracker` (browser tracking client) and `unomi-site` (website) are in the engagement scope but are treated here as satellites (see §2/§3).
-- **Date:** 2026-06-02. **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta threat-model rubric), for PMC ratification.
+- **Date:** 2026-06-02; **amended:** 2026-08-06 (PMC triage amendments — role split, GraphQL transports, tenant isolation, closed §14 Q1 / Q8a; default-password: documented today + 3.1 hardening commitment). **Status:** draft — for Apache Unomi PMC review. **Author:** ASF Security team (drafted via the Scovetta threat-model rubric), for PMC ratification; amendments by Unomi PMC.
- **Version binding:** versioned with the project; a report against version *N* is triaged against the model as it stood at *N*.
-- **Reporting cross-reference:** §8-property violations → report privately per ASF process (`security@apache.org` → `private@unomi.apache.org`); §3/§9 findings are closed citing this document.
+- **ASF vulnerability handling:** the PMC follows [Handling a possible vulnerability](https://apache.org/security/committers.html#vulnerability-handling) (see also `SECURITY.md`): work in private → acknowledge → investigate → **accept or reject** (with written reasons) → for accepted issues: CVE via `cveprocess.apache.org` / `security@apache.org` → private fix → release → announce → update project security pages. Unomi has no dedicated `security@unomi.apache.org`; copy private vulnerabil [...]
+- **Accept vs reject (released vs unreleased):** **Accept** (and continue through CVE/fix/release/announce) findings that are in-model vulnerabilities in **released** Unomi lines (e.g. 2.x, 3.0.x). **Reject** as a security vulnerability in released Apache software — with a clear explanation to the reporter — findings that (a) are `OUT-OF-MODEL` / `BY-DESIGN` / `KNOWN-NON-FINDING` per this document, or (b) exist **only** on unreleased development code (e.g. `master` / `3.1.0-SNAPSHOT` bef [...]
+- **Reporting cross-reference:** suspected §8 violations → `security@apache.org` → `private@unomi.apache.org`; dispositions cite this document.
- **Provenance legend:** *(documented)* = Unomi's own docs/repo/CVE advisories; *(maintainer)* = confirmed by an Unomi PMC member through this process; *(inferred)* = reasoned from architecture/history, not yet confirmed — each has a matching §14 open question.
-- **Draft confidence:** ~16 documented / 0 maintainer / ~30 inferred.
-- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs. It persists to Elasticsearch/OpenSearch. *(documented — README, manual)*
+- **Draft confidence:** ~16 documented / several maintainer (Q1, Q2, Q7, Q8a) / remaining inferred.
+- **What Unomi is:** Apache Unomi is a Java reference implementation of the OASIS Context Server (CXS) spec — a Customer Data Platform. It collects behavioural events about visitors (typically from a browser via the `unomi-tracker` JavaScript over a public **context** endpoint), builds and stores profiles + segments, evaluates rules/conditions, and exposes data via REST and GraphQL APIs (HTTP and WebSocket). It persists to Elasticsearch/OpenSearch. *(documented — README, manual)*
## §2 Scope and intended use
- **Primary use:** an operator-deployed **context server** that ingests visitor events over the network and serves profile/segmentation data to web properties and back-office tools. *(documented — manual)*
- **Caller roles** (network service — the role splits):
- - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/context.json` and `/eventcollector` — `EventsCollectorServlet`) **unauthenticated**, from the open internet. The highest-value untrusted surface. *(inferred — confirm the public-endpoint exposure model)*
- - **integrator / API client** — calls the REST / GraphQL APIs, authenticated; may author conditions, rules, segments, scopes. **Trusted to its credential's authority.** *(inferred)*
- - **operator/admin** — controls config, the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. **Trusted.** *(inferred)*
+ - **public web client** — a browser running `unomi-tracker`, hitting the **public context endpoint** (`/cxs/context.json` and `/cxs/eventcollector`) with a tenant **public** API key (`X-Unomi-Api-Key`), from the open internet. Highest-value untrusted surface. (Temporary exception: V2 compatibility mode may omit the public key while migrating.) *(documented — `security.adoc`)*
+ - **tenant administrator** — authenticates with Basic `tenantId:privateApiKey`. Trusted **only within that tenant’s data plane** (profiles, rules, segments, schemas for that tenant). **Not** trusted for host-level side effects (unsandboxed script execution, arbitrary filesystem or unconstrained Camel endpoints) or for other tenants’ data. *(maintainer — §14 Q8a)*
+ - **system administrator / operator** — JAAS (e.g. `karaf`) and control of the Karaf container, plugins, and the Elasticsearch/OpenSearch backend. Trusted for tenant CRUD, key minting, and host-impacting configuration. *(documented — manual; maintainer)*
+ - **integrator / API client** — any authenticated REST/GraphQL caller; **trusted only to its credential’s authority** (public key, tenant private key, or system admin). *(maintainer)*
- **cluster peer** — another Unomi node. *(inferred)*
**Component-family table:**
| Family | Entry point | Touches outside process | In model? |
| --- | --- | --- | --- |
-| Public context ingestion | `/context.json` + `/eventcollector` (`EventsCollectorServlet`, `wab`) | network (public listen) | **In — primary boundary** *(inferred)* |
+| Public context ingestion | `/cxs/context.json` + `/cxs/eventcollector` | network (public listen) | **In — primary boundary** *(documented)* |
| Rule / condition / segment engine + scripting | `services`, `scripting` (MVEL/OGNL expression eval) | evaluates expressions | **In — historically the RCE surface (§11)** *(documented: CVEs)* |
| JSON-Schema event validation | schema validation of incoming events | — | **In — the input-validation defense** *(documented: manual `jsonSchema`)* |
-| Admin REST + GraphQL APIs | `rest`, `graphql` | network (authenticated) | **In** *(documented: modules)* |
+| Admin REST APIs | `rest` | network (authenticated) | **In** *(documented: modules)* |
+| GraphQL HTTP + WebSocket | `/graphql` (queries/mutations over HTTP; subscriptions over WebSocket) | network (authenticated) | **In — auth must apply to every transport, including WebSocket upgrade/subscribe** *(maintainer)* |
+| Groovy actions extension | script upload/compile/dispatch | process (script exec) | **In — host-impacting ops require system administrator** *(maintainer — §14 Q8a)* |
+| Router import/export | Camel source/destination URIs | filesystem / remote endpoints | **In — path/host confinement required for multi-tenant safety** *(maintainer — §14 Q8a)* |
| Persistence | `persistence-elasticsearch` / `persistence-opensearch` | network → ES/OS backend | **In (Unomi's use of it); the backend's own security is operator's** *(inferred)* |
| Plugins / extensions / connectors | `plugins`, `extensions`, `connectors` | varies | **In core ones; third-party/`samples` out** *(inferred)* |
-| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(inferred)* |
-| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3)* |
+| `unomi-tracker` (JS client) | browser | — | **Satellite — discoverability pointer; client-side, lower trust surface** *(maintainer — §14 Q2)* |
+| `unomi-site`, `samples`, `itests` | website / demos / tests | — | **Out** *(see §3; maintainer — §14 Q2)* |
## §3 Out of scope (explicit non-goals)
- **Attackers who already control the host, the Karaf container, the config, the plugins, or the Elasticsearch/OpenSearch backend.** Operator-trusted. *(inferred)*
-- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(inferred)*
+- **`unomi-site`, `samples/`, `itests/`** — website + demo + test code, not production trust surface. *(maintainer — §14 Q2)* A **core** action or endpoint that is unsafe when an operator follows a documented sample pattern can still be `VALID` / `VALID-HARDENING`; only the sample artifact itself is out of scope.
- **Confidentiality of profile data at rest / in the search backend** when the operator has not secured Elasticsearch/OpenSearch and the network — that is deployment hardening, not an Unomi code property, unless Unomi claims otherwise. *(inferred)*
-- **Arbitrary expression evaluation by a *trusted admin*** who authors a malicious condition/rule — an authenticated privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input can reach expression evaluation (see §8/§11). *(inferred — confirm)*
+- **Arbitrary expression evaluation by a *system administrator*** who authors a malicious condition/rule — an authenticated system-privileged user defining server-side logic is the intended (if powerful) feature, not an attack on Unomi. The boundary is whether *public/untrusted* input, or a *lower-privilege* credential (e.g. tenant administrator), can reach that power (see §8/§11). *(maintainer — §14 Q7 / Q8a)*
## §4 Trust boundaries and data flow
-- **Primary boundary: the public context endpoint.** Event payloads arriving unauthenticated from browsers are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step is the gate. *(inferred; schema validation documented)*
-- **Secondary boundary: the authenticated REST/GraphQL admin surface**, where conditions/rules/scopes are defined — trusted to the credential. *(inferred)*
+- **Primary boundary: the public context endpoint.** Event payloads arriving from browsers (public API key at most) are **untrusted**. They flow → JSON-Schema validation → event/condition processing → profile update → persistence. The schema-validation step and the public-event allow-list are the gates. *(documented; schema validation documented)*
+- **Secondary boundary: the authenticated REST/GraphQL surface** (HTTP **and** WebSocket), where conditions/rules/scopes are defined and subscriptions are opened — trusted only to the credential’s authority. A transport that skips the auth gate that other transports enforce is an in-model break. *(maintainer)*
+- **Tertiary boundary: tenant credential ↔ host / cross-tenant.** A tenant-administrator subject must not escape to host process powers (unsandboxed script compile/exec, absolute filesystem read/write, unconstrained Camel endpoints) or to another tenant’s data. *(maintainer — §14 Q8a)*
- **The historical break (load-bearing):** the public surface must **not** allow attacker-controlled input to reach OGNL/MVEL expression evaluation that can instantiate/call arbitrary Java — that was CVE-2020-11975 / CVE-2020-13942 / CVE-2021-31164, fixed by constraining the public surface. The model treats a regression of this kind as `VALID`/critical. *(documented — CVE advisories)*
-- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/unauthenticated** input (or from a lower privilege than the operation requires). Expression power available only to a trusted authenticated author is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(inferred)*
+- **Reachability precondition:** a finding in `scripting`/condition-evaluation is **in-model** only if reachable from **public/untrusted** input **or from a lower privilege than the operation requires** (e.g. tenant admin reaching system-admin-only host power). Expression power available only to a system administrator is `OUT-OF-MODEL: trusted-input`. A finding on the ES/OS backend is in-model only if reachable through Unomi's API, not by directly attacking an exposed backend. *(maintainer)*
## §5 Assumptions about the environment
- **Runtime:** JVM; runs in an Apache Karaf / OSGi container. *(documented — kar/package/manual)*
- **Backend:** Elasticsearch or OpenSearch, assumed deployed on a trusted network and secured by the operator. *(inferred)*
-- **The public endpoint is internet-facing by design** (browsers post events directly); the admin APIs are assumed *not* public. Confirm. *(inferred)*
-- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path. *(inferred)*
+- **The public endpoint is internet-facing by design** (browsers post events directly); the admin REST/GraphQL APIs are assumed *not* public and are authenticated. *(maintainer — §14 Q1)*
+- **Negative side-effects inventory** (predominantly inferred — wave-1/2 target): Unomi listens on HTTP; reads config from the Karaf container; talks to the search backend; loads OSGi plugins; evaluates conditions/expressions; the scripting engine executes expression logic authored through the (trusted) admin path; optional extensions (Groovy, router) can touch the filesystem and remote endpoints. *(inferred)*
## §5a Build-time and configuration variants
-Security-relevant configuration knobs *(all inferred — confirm names/defaults against `configuration.adoc`):*
+Security-relevant configuration knobs:
-- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept)*
-- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented)*
+- **Public-endpoint protection / third-party server allow-list + secured events** — the mechanism that distinguishes events a public client may send from those that require a trusted key. Default posture? *(inferred — Unomi has a "protected events" / third-party-server key concept; see §14 Q5)*
+- **JSON-Schema validation** of incoming events — on by default? Reject-unknown by default? *(inferred; feature documented; see §14 Q4)*
- **Expression/scripting allow-list** (post-CVE) — what restricts which classes/methods conditions may reference, and is it on by default? *(inferred; the CVE fixes introduced restrictions)*
-- **Authentication on the admin REST/GraphQL APIs** — default credentials? bound to localhost vs all interfaces by default? *(inferred — the insecure-default question; wave 1)*
+- **Authentication on the admin REST/GraphQL APIs** — packaged default JAAS user `karaf` with password from `${org.apache.unomi.security.root.password:-karaf}`; health user defaults similarly. Manual documents the default and instructs operators to change it as soon as possible. *(documented — `users.properties`, `configuration.adoc`; maintainer — §14 Q1)*
+- **Profile id cookie flags** — `contextserver.profileIdCookieHttpOnly` defaults to `false` unless overridden. *(documented — `org.apache.unomi.web.cfg`)*
+- **Router allowed endpoint schemes** — default allowlist includes `file,ftp,sftp,ftps`. *(documented — `org.apache.unomi.router.cfg`)*
-**Insecure-default check:** if any of (public-endpoint protection, schema validation, scripting allow-list, admin auth) ships *off* or with a default credential, a report against that default is `VALID` unless the PMC designates it a documented must-configure (`OUT-OF-MODEL: non-default-build`). This is a wave-1 ruling (§14).
+**Insecure-default check (PMC ruling) — two layers:**
+
+1. **Triage of reports against current / pre-3.1 behaviour:** leaving the documented default JAAS password unchanged is a **documented must-configure** operator duty. A report that only shows “default `karaf:karaf` still works on a fresh install” is `OUT-OF-MODEL: non-default-build` / `BY-DESIGN: property-disclaimed` (§9). Auth bypasses or missing gates on an admin surface (independent of the password value) remain `VALID`. *(maintainer — §14 Q1)*
+2. **Product commitment for the 3.1 release:** the PMC treats the shipped known default as **accepted technical debt to retire in 3.1**, not as the long-term desired posture. 3.1 should remove the working known default (for example: require an explicit password via config/env before admin API access; generate and persist a random password on first boot; and/or fail-fast when the API is bound non-loopback with an unset/default password). The health user should follow the same pattern. Unt [...]
## §6 Assumptions about inputs
-Per-surface trust table *(all inferred unless noted):*
+Per-surface trust table:
| Surface | Input | Attacker-controllable? | Caller/operator must enforce |
| --- | --- | --- | --- |
-| Public context endpoint | event JSON, profile/session refs, scope | **yes (unauthenticated, public)** | JSON-schema validation on; public-event allow-list; no expression reach |
-| REST / GraphQL admin | conditions, rules, segments, queries | **yes, within the authenticated credential's authority** | authn + authz; restrict who may author expressions |
-| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; admin: yes-but-trusted** | keep expression authoring on the trusted side |
-| Persistence queries | derived from the above | indirectly | backend hardening; query/scope isolation |
+| Public context endpoint | event JSON, profile/session refs, scope | **yes (public client)** | JSON-schema validation on; public-event allow-list; no expression reach; profile/session refs treated as **bearer identifiers** (see §8) |
+| REST admin | conditions, rules, segments, queries, extension config | **yes, within credential authority** | authn + authz; system admin for host-impacting ops |
+| GraphQL HTTP | queries / mutations | **yes, within credential authority** | same auth gate as documented for GraphQL |
+| GraphQL WebSocket | subscription init + subscribe payloads | **yes** | **same auth as GraphQL HTTP**; no Subject → reject upgrade/subscribe |
+| Condition / rule definitions | MVEL/OGNL expressions | **public: must be no; system admin: yes-but-trusted** | keep expression authoring on the system-trusted side |
+| Public events → identity actions | merge/update target from event properties | **yes, when event type is public** | do not wire unverified identity claims to merge/update actions (§11) |
+| Persistence queries | derived from the above | indirectly | backend hardening; tenant/scope isolation |
| Plugins / connectors config | operator-supplied | no — operator-trusted | vet third-party plugins |
- **Size/shape/rate:** whether the public endpoint bounds event size / batch count / request rate against a flood is open (see §8 resource line). *(inferred)*
## §7 Adversary model
-- **Primary adversary:** an unauthenticated party who can reach the **public context endpoint** from the internet — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, inject events to corrupt segmentation, or exhaust resources. *(documented threat history; framing inferred)*
-- **Secondary:** an authenticated API client trying to exceed its authority (read other scopes' data, escalate). *(inferred)*
-- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads. **Not** assumed: control of the admin credential, the container, or the backend. *(inferred)*
-- **Out of scope:** trusted admins authoring powerful (even dangerous) conditions; attackers with host/backend control. *(inferred)*
+- **Primary adversary:** an unauthenticated or public-API-key party who can reach the **public context endpoint** (and any other publicly reachable surface) — trying to achieve code execution (the CVE history), read/modify other visitors' profiles, open privileged GraphQL subscriptions without credentials, inject events to corrupt segmentation or rebind identity, or exhaust resources. *(documented threat history; maintainer framing)*
+- **Secondary:** an authenticated **tenant** API client trying to exceed its authority — cross-tenant read/write, host RCE, arbitrary filesystem or internal-network reach via import/export, uploading unsandboxed scripts. *(maintainer — §14 Q8a)*
+- **Capabilities:** craft arbitrary event/condition JSON to the public endpoint; replay; send large/malformed payloads; attempt WebSocket upgrade without credentials; use a stolen or issued tenant private key within (and beyond) its tenant. **Not** assumed: control of the system-admin credential, the container, or the backend. *(maintainer)*
+- **Out of scope:** system administrators authoring powerful (even dangerous) conditions; attackers with host/backend control. *(maintainer)*
## §8 Security properties the project provides
-*(All inferred pending PMC confirmation; the CVE-fix posture is documented history.)*
+*(CVE-fix posture is documented history; tenant and GraphQL transport lines are maintainer-confirmed.)*
-- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated request. *Severity:* security-critical. *(documented that this class was fixed; the standing guarantee is the claim to confirm)*
-- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness to confirm)*
-- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. *Violation symptom:* cross-profile / cross-scope data access. *Severity:* security-critical (data exposure — PII). *(inferred)*
+- **No code execution from public/untrusted input.** Public-endpoint input cannot reach OGNL/MVEL evaluation that instantiates or calls arbitrary Java — the post-CVE invariant. *Violation symptom:* RCE / arbitrary-class invocation from an unauthenticated or public-key-only request. *Severity:* security-critical. *(documented)*
+- **Input validation at the public boundary.** Incoming events are validated against registered JSON Schemas; non-conforming input is rejected, not processed. *Violation symptom:* unvalidated/unknown event shape reaching processing. *Severity:* security-critical → moderate. *(documented feature; default/strictness — §14 Q4)*
+- **Profile/scope access control.** A public client cannot read or modify profile data outside what the context/scope model permits; an API client is bounded by its authority. On the public endpoints, `profileId` / `sessionId` are **bearer identifiers** (typically the `context-profile-id` cookie): possession of the identifier is the authorization model. *Violation symptom:* cross-profile / cross-scope data access **without** possessing that bearer (e.g. body `profileId` accepted when it [...]
+- **Authentication on all GraphQL transports.** HTTP queries/mutations and WebSocket upgrade/subscribe share the same authentication requirements; subscriptions are not a public operation. *Violation symptom:* unauthenticated client completes upgrade and reaches `graphQL.execute` for a subscription. *Severity:* security-critical → high. *(maintainer)*
+- **Tenant isolation.** Credentials for tenant A cannot read or modify tenant B’s profiles, segments, rules, or keys. Unomi enforces this boundary in the data plane. *Violation symptom:* cross-tenant data access. *Severity:* security-critical. *(maintainer — §14 Q8a / UNOMI-139)*
+- **No privilege escalation beyond credential class.** Host-impacting operations (unsandboxed script upload/compile/exec, absolute filesystem access, unconstrained remote Camel endpoints) require **system administrator**, not tenant administrator. *Violation symptom:* tenant private key achieves host RCE, arbitrary file read, or equivalent. *Severity:* security-critical. *(maintainer — §14 Q8a)*
- **Resource bounds — UNSPECIFIED.** Whether a public event flood or an expensive segment/condition is a bug or expected-and-operator-managed is open. *(inferred)*
## §9 Security properties the project does *not* provide
-- **No protection if the admin REST/GraphQL APIs are exposed unauthenticated / with default creds** — keeping the admin surface off the public network + authenticated is the operator's job (pending §5a ruling). *(inferred)*
+- **No protection if the admin REST/GraphQL APIs are exposed to the public network or left on the documented default JAAS password (pre-3.1 / until the 3.1 default hardening ships)** — keeping the admin surface off the public network and changing default credentials is the operator's job (§5a layer 1; §10). The project still commits to eliminating the known default in 3.1 (§5a layer 2). *(maintainer — §14 Q1)*
- **No confidentiality/integrity for the ES/OS backend or its network** — Unomi assumes a secured backend; it does not defend an exposed Elasticsearch. *(inferred)*
-- **Not a sandbox for admin-authored expressions/plugins** — a trusted author with condition/scripting authority can run server-side logic by design; that power is not contained. *(inferred)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe.
+- **Not a sandbox for system-administrator-authored expressions/plugins** — a system admin with condition/scripting authority can run server-side logic by design; that power is not contained. *(maintainer — §14 Q7)* **False friend:** the presence of the scripting/expression allow-list protects the *public* surface; it is not a sandbox that makes arbitrary admin-authored expressions safe. **This disclaimer does not cover tenant-administrator script upload or Camel config that reaches host [...]
+- **No guarantee that possession of a visitor `profileId` UUID is hard** — the id is a bearer token; confidentiality of the cookie (and flags such as HttpOnly) is largely an operator/frontend concern, though unsafe defaults may be `VALID-HARDENING`. *(maintainer)*
- **No guarantee of correctness of analytics/segmentation under adversarial event injection** beyond the access-control boundary. *(inferred)*
-- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, and DoS via event floods. *(documented history; framing inferred)*
+- **Well-known classes left to the caller/operator:** expression-injection (the CVE class — defended by constraining the public surface), event/PII-exposure via a misconfigured public endpoint, DoS via event floods, and deploying sample identity-merge rules without verified identity (§11). *(documented history; maintainer framing)*
## §10 Downstream responsibilities (operator/deployer)
-*(All inferred — confirm.)*
-
-- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; change any default credentials. *(inferred)*
+- Keep the admin REST/GraphQL APIs **off the public network** and authenticated; **change the documented default JAAS credentials** before production use (mandatory until 3.1 retires the known default). *(maintainer — §14 Q1)*
- Keep JSON-Schema validation and the public-event allow-list **enabled**; register schemas for the events you accept. *(inferred)*
- Secure the Elasticsearch/OpenSearch backend + its network. *(inferred)*
-- Restrict who holds condition/rule/scripting authoring authority — it is equivalent to server-side code definition. *(inferred)*
+- Restrict who holds **system-administrator** condition/rule/scripting and extension-config authority — it is equivalent to server-side code definition. Treat **tenant private keys** as high privilege within a tenant, not as host-admin equivalents. *(maintainer)*
+- Do not deploy sample login-merge (or similar) rules that bind public events to identity-merge/update actions without a **verified** identity step. *(maintainer)*
+- Prefer `profileId` cookie `HttpOnly=true` (and Secure where appropriate) in production. *(maintainer)*
- Put the public endpoint behind rate-limiting / a CDN/WAF appropriate to public exposure. *(inferred)*
## §11 Known misuse patterns
-*(Draft one-liners — expand before publishing.)*
-
-- Exposing the admin REST/GraphQL APIs to the internet (or leaving default creds). *(inferred)*
+- Exposing the admin REST/GraphQL APIs to the internet, or leaving the documented default JAAS credentials unchanged in production (especially on pre-3.1 builds). *(maintainer)*
- Disabling JSON-Schema validation or the public-event allow-list "to make integration easier", re-opening the public surface. *(inferred)*
- Treating the scripting/expression allow-list as a sandbox for admin-authored conditions. *(inferred)*
- Exposing Elasticsearch/OpenSearch alongside Unomi without backend auth. *(inferred)*
+- Wiring `mergeProfilesOnPropertyAction` / `updatePropertiesAction` (or equivalents) to **public** event types using unverified `eventProperty::` identity claims (including following the login-integration sample pattern in production). *(maintainer)*
+- Granting tenant administrators Groovy upload or router `file`/`ftp`/`sftp` configuration without path/host confinement. *(maintainer)*
+- Assuming GraphQL WebSocket is covered by HTTP-only auth checks. *(maintainer)*
## §11a Known non-findings (recurring false positives)
-*(Seed list — PMC confirmation here is the highest-leverage scan-suppression input.)*
+*(PMC confirmation here is the highest-leverage scan-suppression input.)*
-- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **trusted admin-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/unauthenticated** input reaching expression evaluation; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes)*
-- "Scripting / reflection present in `scripting` module" — needs the public-reachability test (§4) before it is a finding. *(inferred)*
-- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design**; the protection is schema validation + the event allow-list, not authentication. *(inferred)*
+- "Unomi evaluates OGNL/MVEL expressions → RCE" — by-design for **system-administrator-authored** conditions; the public surface is constrained (post-CVE). A report is `VALID` only if it shows **public/untrusted** input, or a **lower privilege than required**, reaching expression evaluation / host script power; otherwise `OUT-OF-MODEL: trusted-input` / `BY-DESIGN`. *(documented — CVE fixes; maintainer — §14 Q7)*
+- "Scripting / reflection present in `scripting` module" — needs the public-reachability (or privilege-escalation) test (§4) before it is a finding. *(inferred)*
+- "No auth on the context endpoint" — the public ingestion endpoint is unauthenticated **by design** (aside from tenant public API key resolution); the protection is schema validation + the event allow-list, not visitor login. *(documented; maintainer)*
+- "Public `/context.json` returns profile data for a supplied `profileId`" — **by design** when that id is the caller’s bearer (cookie / equivalent). Not automatically `VALID` as “IDOR” merely because the attacker knows the UUID. `VALID` / `VALID-HARDENING` when the body id is accepted **without** matching the bearer cookie, when session load switches profile without ownership, or when cookie flags make XSS→id theft trivial by unsafe default. *(maintainer)*
- "Elasticsearch reachable / no TLS" — operator deployment responsibility (§9/§10). *(inferred)*
-- "Admin can run dangerous operation X" — out-of-model: admin is trusted (§7). *(inferred)*
+- "System administrator can run dangerous operation X" — out-of-model: system admin is trusted (§7). **Does not apply** to tenant administrator achieving host RCE, arbitrary file read, or cross-tenant access — those are §8 violations. *(maintainer — §14 Q8a)*
+- "Default `karaf:karaf` works on a fresh install" — against **current / pre-3.1** builds: documented must-configure (§5a layer 1); not `VALID` solely on that basis. The same class of report is accepted as **`VALID-HARDENING` motivation** for the 3.1 default-password retirement (§5a layer 2). After 3.1 ships that retirement, a regression reintroducing a known default is `VALID`. *(maintainer — §14 Q1)*
## §12 Conditions that would change this model
- A change to the public-endpoint protection model, the JSON-Schema-validation default, the scripting allow-list, or admin-auth defaults. *(inferred)*
-- A new public surface or a new expression/scripting capability reachable from untrusted input. *(inferred)*
+- **Planned:** 3.1 retires the known default JAAS password (§5a layer 2). When that ships, revise §5a/§9/§11a so that a working known default is no longer `BY-DESIGN` / must-configure, and a regression is `VALID`. *(maintainer)*
+- A new public surface or a new expression/scripting capability reachable from untrusted input or from tenant credentials. *(maintainer)*
- Promoting a `samples/` or third-party connector into core. *(inferred)*
+- A change to the tenant-isolation or privilege-escalation guarantees (§8). *(maintainer)*
- A report that cannot be routed to one §13 disposition → revise the model.
## §13 Triage dispositions
| Disposition | Meaning | Licensed by |
| --- | --- | --- |
-| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; pre-auth crash). | §8, §6, §7 |
-| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden. | §11 |
-| `OUT-OF-MODEL: trusted-input` | Requires admin/authenticated authority (e.g. an admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 |
+| `VALID` | Violates a §8 property via an in-scope adversary/input (public-input code execution; schema-validation bypass; cross-profile/scope access; GraphQL auth bypass on any transport; tenant isolation break; tenant→host privilege escalation; pre-auth crash). | §8, §6, §7 |
+| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden (e.g. HttpOnly default; **3.1 retirement of the known default JAAS password** per §5a layer 2). | §11, §5a |
+| `OUT-OF-MODEL: trusted-input` | Requires **system-administrator** authority (e.g. a system-admin-authored malicious condition) or operator-controlled config/backend. | §6, §7 |
| `OUT-OF-MODEL: adversary-not-in-scope` | Requires host/container/backend control or another excluded capability. | §7 |
-| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors. | §3 |
-| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting. | §5a |
-| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no admin-expression sandbox, unauthenticated-by-design context endpoint, backend security). | §9 |
+| `OUT-OF-MODEL: unsupported-component` | Lands in `unomi-site`, `samples/`, `itests/`, or third-party connectors (sample-only; core behaviour followed from samples may still be `VALID`). | §3 |
+| `OUT-OF-MODEL: non-default-build` | Only manifests under a discouraged/non-default §5a setting, or solely under an unchanged documented default credential (§5a ruling). | §5a |
+| `BY-DESIGN: property-disclaimed` | Concerns a §9-disclaimed property (no system-admin expression sandbox, unauthenticated-by-design context endpoint, backend security, documented default creds). | §9 |
| `KNOWN-NON-FINDING` | Matches a §11a entry. | §11a |
| `MODEL-GAP` | Cannot be cleanly routed — triggers §12. | §12 |
## §14 Open questions for the maintainers
**Wave 1 — scope & default posture:**
-1. Confirm the trust split: the **context endpoint is public/unauthenticated by design**, while the **REST/GraphQL admin APIs are not public and are authenticated**. What are the defaults (bind address, default credentials)? Is exposing the admin API or leaving a default credential a `VALID` report or a documented must-configure? → §2/§5a/§7.
-2. Confirm `unomi-tracker` is modeled as a client-side satellite and `unomi-site`/`samples`/`itests` are out of scope. → §2/§3.
+1. ~~Confirm the trust split… default credentials?~~ **Answered (2026-08):** Context endpoint is public (public API key) by design; admin REST/GraphQL are authenticated and not intended to be public. **Triage (today / pre-3.1):** packaged default JAAS `karaf`/`karaf` is a **documented must-configure** → reports that only show the default still works are `OUT-OF-MODEL: non-default-build` / `BY-DESIGN`. **Product (3.1):** PMC commits to retire the known working default (explicit config, ra [...]
+2. ~~Confirm `unomi-tracker`… samples/`itests` out of scope.~~ **Answered (2026-08):** Tracker is a client-side satellite; `unomi-site` / `samples` / `itests` are out of scope (§3). → §2/§3.
3. The model covers the apache/unomi server; should `unomi-tracker` get its own (lighter) model later, or a discoverability pointer to this one? → §1.
**Wave 2 — the public boundary & its defenses:**
@@ -181,11 +205,11 @@ Per-surface trust table *(all inferred unless noted):*
6. Are there bounds on public event size / batch / rate, or is flood protection the operator's (WAF/rate-limit) concern? → §8/§11a.
**Wave 3 — expressions, scopes, backend:**
-7. Confirm that OGNL/MVEL expression power is **by-design for trusted admin-authored** conditions and is **not** a sandbox — so a finding is `VALID` only when *public/untrusted* input reaches it. → §9/§11a.
-8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8.
-8a. **Tenant isolation (UNOMI-139):** can an API client with credentials for tenant A read or modify tenant B's profiles, segments, or rules — and is that boundary enforced by Unomi (`TenantService` / `SecurityService.getTenantEncryptionKey`) or delegated to the integrator? → §8/§10. *(maintainer-flagged — sergehuber)*
+7. ~~Confirm that OGNL/MVEL…~~ **Answered (2026-08):** Expression power is by-design for **system-administrator-authored** conditions and is not a sandbox. `VALID` when public/untrusted input reaches it, or when a lower privilege (tenant admin) reaches host script power that should require system admin. → §9/§11a.
+8. What is the profile/**scope** isolation model — can an authenticated API client read/modify data outside its scope, and is that boundary something Unomi enforces or the integrator's concern? → §8. *(partially addressed by bearer-id clarification; scope semantics still open)*
+8a. ~~**Tenant isolation (UNOMI-139):**…~~ **Answered (2026-08):** Unomi **enforces** tenant isolation in the data plane. Tenant A credentials must not read/modify tenant B. Tenant administrator must **not** obtain host RCE, arbitrary filesystem access, or unconstrained Camel reach — those require system administrator. Violations are `VALID`. → §8/§10. *(maintainer — sergehuber)*
9. Is the Elasticsearch/OpenSearch backend assumed trusted/secured-by-operator (so backend-exposure reports are out-of-model)? → §3/§9.
**Wave 4 — meta & non-findings:**
10. Any other recurring scanner/fuzzer false positives to seed §11a (e.g. the `scripting` module, reflection, OSGi dynamic loading)? → §11a.
-11. **Meta:** Unomi has no in-repo `SECURITY.md`/`AGENTS.md` today; this engagement adds `SECURITY.md` + `THREAT_MODEL.md` and wires `AGENTS.md → SECURITY.md → THREAT_MODEL.md`. The website publishes CVE advisories at `unomi.apache.org/security/`. Confirm the in-repo model is canonical and how it should reference the website advisories; confirm revision ownership. → §1.
+11. **Meta:** Confirm the in-repo model is canonical and how it should reference website advisories at `unomi.apache.org/security/`; confirm revision ownership. (In-repo `SECURITY.md` / `AGENTS.md` wiring may already exist on current `master` — verify at ratification.) → §1.
diff --git a/docker/README.md b/docker/README.md
index c5ab6553d..a5dc49bde 100644
--- a/docker/README.md
+++ b/docker/README.md
@@ -30,9 +30,13 @@ required Unomi tarball.
## Launching docker-compose using Maven project
-Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose:
+Unomi requires a search engine (ElasticSearch or OpenSearch) so it is recommended to run Unomi and the search engine using docker-compose.
+
+Set admin and health passwords first (required; no known defaults are shipped):
```
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
mvn docker:start
```
@@ -72,6 +76,8 @@ For Unomi (with ElasticSearch):
```bash
docker pull apache/unomi:3.1.0-SNAPSHOT
docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+ -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200 \
apache/unomi:3.1.0-SNAPSHOT
```
@@ -81,6 +87,8 @@ For Unomi (with OpenSearch):
```bash
docker pull apache/unomi:3.1.0-SNAPSHOT
docker run -d --name unomi --net unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+ -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
-e UNOMI_OPENSEARCH_ADDRESSES=opensearch:9200 \
-e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
@@ -93,6 +101,8 @@ For ElasticSearch:
```bash
docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+ -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_ELASTICSEARCH_ADDRESSES=host.docker.internal:9200 \
apache/unomi:3.1.0-SNAPSHOT
```
@@ -101,6 +111,8 @@ For OpenSearch:
```bash
docker run -d --name unomi -p 8181:8181 -p 9443:9443 -p 8102:8102 \
+ -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' \
-e UNOMI_DISTRIBUTION=unomi-distribution-opensearch \
-e UNOMI_OPENSEARCH_ADDRESSES=host.docker.internal:9200 \
-e UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_ADMIN_PASSWORD} \
@@ -112,6 +124,8 @@ Note: Linux doesn't support the host.docker.internal DNS lookup method yet, it s
## Environment Variables
### Common Variables
+- `UNOMI_ROOT_PASSWORD`: Required admin (`karaf`) password — no known default
+- `UNOMI_HEALTHCHECK_PASSWORD`: Required health-check (`health`) password — no known default
- `UNOMI_AUTO_START`: Boolean to specify if unomi auto start with karaf (defaults to `true`)
- `UNOMI_DISTRIBUTION`: Specifies the Unomi Distribution Feature to use (`unomi-distribution-elasticsearch` or `unomi-distribution-opensearch`, defaults to `unomi-distribution-elasticsearch`)
@@ -133,7 +147,7 @@ Multi-tenancy requires a tenant before client endpoints such as `/cxs/context.js
```bash
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{"requestedId":"default","properties":{"name":"Default Tenant"}}'
```
diff --git a/docker/src/main/docker/docker-compose-build-es.yml b/docker/src/main/docker/docker-compose-build-es.yml
index 03f265a7b..34193ad30 100644
--- a/docker/src/main/docker/docker-compose-build-es.yml
+++ b/docker/src/main/docker/docker-compose-build-es.yml
@@ -39,6 +39,8 @@ services:
- UNOMI_AUTO_START=true
- UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-build-os.yml b/docker/src/main/docker/docker-compose-build-os.yml
index 97d38cfe4..f5c8592d5 100644
--- a/docker/src/main/docker/docker-compose-build-os.yml
+++ b/docker/src/main/docker/docker-compose-build-os.yml
@@ -100,6 +100,8 @@ services:
- UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
- UNOMI_OPENSEARCH_USERNAME=admin
- UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-cluster.yml b/docker/src/main/docker/docker-compose-cluster.yml
index 75aed4628..fa1a3069e 100644
--- a/docker/src/main/docker/docker-compose-cluster.yml
+++ b/docker/src/main/docker/docker-compose-cluster.yml
@@ -36,6 +36,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_CLUSTER_NODEID=unomi-3-node-1
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8181:8181
- 9443:9443
@@ -58,6 +60,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_CLUSTER_NODEID=unomi-3-node-2
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8182:8181
- 9444:9443
diff --git a/docker/src/main/docker/docker-compose-es.yml b/docker/src/main/docker/docker-compose-es.yml
index edfada591..a3d294672 100644
--- a/docker/src/main/docker/docker-compose-es.yml
+++ b/docker/src/main/docker/docker-compose-es.yml
@@ -45,6 +45,9 @@ services:
- UNOMI_AUTO_START=true
- UNOMI_DISTRIBUTION=unomi-distribution-elasticsearch
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
+ # Required admin password (no known default is shipped). Override via .env / shell.
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/docker-compose-os.yml b/docker/src/main/docker/docker-compose-os.yml
index 579d3e2bc..a37babf5b 100644
--- a/docker/src/main/docker/docker-compose-os.yml
+++ b/docker/src/main/docker/docker-compose-os.yml
@@ -84,6 +84,9 @@ services:
- UNOMI_OPENSEARCH_ADDRESSES=opensearch-node1:9200
- UNOMI_OPENSEARCH_USERNAME=admin
- UNOMI_OPENSEARCH_PASSWORD=${OPENSEARCH_INITIAL_ADMIN_PASSWORD}
+ # Required admin password (no known default is shipped). Override via .env / shell.
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD:?Set UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD:?Set UNOMI_HEALTHCHECK_PASSWORD}
# Debug settings
- KARAF_DEBUG=${DEBUG:-false}
- KARAF_DEBUG_PORT=${DEBUG_PORT:-5005}
diff --git a/docker/src/main/docker/entrypoint.sh b/docker/src/main/docker/entrypoint.sh
index 1cfc68456..e24a54567 100755
--- a/docker/src/main/docker/entrypoint.sh
+++ b/docker/src/main/docker/entrypoint.sh
@@ -34,6 +34,36 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${
echo "KARAF_OPTS: $KARAF_OPTS"
+if [ -z "${UNOMI_ROOT_PASSWORD}" ] && [ "${UNOMI_SKIP_ROOT_PASSWORD_CHECK}" != "true" ]; then
+ cat >&2 <<'EOF'
+ERROR: UNOMI_ROOT_PASSWORD is not set.
+
+Apache Unomi does not ship a known default admin password.
+Pass it when starting the container, for example:
+
+ docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ...
+
+Or with docker compose, export both UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD first.
+EOF
+ exit 1
+fi
+
+if [ -z "${UNOMI_HEALTHCHECK_PASSWORD}" ] && [ "${UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK}" != "true" ]; then
+ cat >&2 <<'EOF'
+ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set.
+
+Apache Unomi does not ship a known default health-check password.
+Pass it when starting the container, for example:
+
+ docker run -e UNOMI_ROOT_PASSWORD='choose-a-strong-password' \
+ -e UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password' ...
+
+Or with docker compose, export both UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD first.
+EOF
+ exit 1
+fi
+
# Function to check cluster health for a specific node
check_node_health() {
local node_url="$1"
diff --git a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
index 5d527b1e8..c16201eda 100644
--- a/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
+++ b/extensions/groovy-actions/karaf-kar/src/main/feature/feature.xml
@@ -20,6 +20,8 @@
<details>${project.description}</details>
<feature>wrap</feature>
<feature>unomi-services</feature>
+ <!-- REST endpoints use @RequiresRole from org.apache.unomi.rest.security -->
+ <feature>unomi-rest-api</feature>
<configfile finalname="/etc/org.apache.unomi.groovy.actions.cfg">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}/cfg/groovyactionscfg</configfile>
<bundle start="false">mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}</bundle>
<bundle start="false">mvn:org.apache.unomi/unomi-groovy-actions-rest/${project.version}</bundle>
diff --git a/extensions/groovy-actions/rest/pom.xml b/extensions/groovy-actions/rest/pom.xml
index 3bedcc985..b6aef5cd3 100644
--- a/extensions/groovy-actions/rest/pom.xml
+++ b/extensions/groovy-actions/rest/pom.xml
@@ -57,6 +57,11 @@
<artifactId>unomi-groovy-actions-services</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-rest</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.osgi</groupId>
@@ -106,6 +111,11 @@
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
<plugins>
diff --git a/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java b/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
index 1f41cf6f3..635dfe3d4 100644
--- a/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
+++ b/extensions/groovy-actions/rest/src/main/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPoint.java
@@ -21,7 +21,9 @@ import org.apache.commons.io.IOUtils;
import org.apache.cxf.jaxrs.ext.multipart.Attachment;
import org.apache.cxf.jaxrs.ext.multipart.Multipart;
import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.groovy.actions.services.GroovyActionsService;
+import org.apache.unomi.rest.security.RequiresRole;
import org.osgi.service.component.annotations.Component;
import org.osgi.service.component.annotations.Reference;
import org.slf4j.Logger;
@@ -35,6 +37,7 @@ import java.io.IOException;
@Produces(MediaType.APPLICATION_JSON + ";charset=UTF-8")
@CrossOriginResourceSharing(allowAllOrigins = true, allowCredentials = true)
@Path("/groovyActions")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
@Component(service = GroovyActionsEndPoint.class, property = "osgi.jaxrs.resource=true")
public class GroovyActionsEndPoint {
@@ -55,6 +58,7 @@ public class GroovyActionsEndPoint {
* Uploads a Groovy action script and registers a matching action type.
* <p>
* The multipart field {@code file} must be a {@code .groovy} file; the action id is derived from the filename.
+ * Restricted to system administrators (JAAS); tenant administrators cannot upload scripts.
*
* @param file the Groovy script upload
* @return an empty success response
@@ -79,6 +83,7 @@ public class GroovyActionsEndPoint {
/**
* Deletes the Groovy action and its action type entry.
+ * Restricted to system administrators (JAAS).
*
* @param actionId the action identifier
* @api.status 204 empty Action deleted.
diff --git a/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java b/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java
new file mode 100644
index 000000000..92ffea28d
--- /dev/null
+++ b/extensions/groovy-actions/rest/src/test/java/org/apache/unomi/groovy/actions/rest/GroovyActionsEndPointRoleTest.java
@@ -0,0 +1,37 @@
+/*
+ * 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.groovy.actions.rest;
+
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.rest.security.RequiresRole;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Regression: Groovy upload/delete must stay restricted to system administrators.
+ */
+class GroovyActionsEndPointRoleTest {
+
+ @Test
+ void endpointRequiresSystemAdministratorRole() {
+ RequiresRole requiresRole = GroovyActionsEndPoint.class.getAnnotation(RequiresRole.class);
+ assertNotNull(requiresRole, "GroovyActionsEndPoint must declare @RequiresRole");
+ assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, requiresRole.value());
+ }
+}
diff --git a/extensions/router/router-karaf-feature/src/main/feature/feature.xml b/extensions/router/router-karaf-feature/src/main/feature/feature.xml
index 647b80914..800508f90 100644
--- a/extensions/router/router-karaf-feature/src/main/feature/feature.xml
+++ b/extensions/router/router-karaf-feature/src/main/feature/feature.xml
@@ -20,6 +20,8 @@
<details>Apache Karaf feature for the Apache Unomi Context Server extension</details>
<feature>wrap</feature>
<feature>unomi-services</feature>
+ <!-- REST endpoints use @RequiresRole from org.apache.unomi.rest.security -->
+ <feature>unomi-rest-api</feature>
<bundle start="false">mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.jsch/0.1.55_1</bundle>
<bundle start="false">mvn:commons-net/commons-net/${commons-net.version}</bundle>
<bundle start="false">wrap:mvn:org.apache.kafka/kafka-clients/${kafka.client.version}</bundle>
diff --git a/extensions/router/router-rest/pom.xml b/extensions/router/router-rest/pom.xml
index b7b9233db..5d7f84404 100644
--- a/extensions/router/router-rest/pom.xml
+++ b/extensions/router/router-rest/pom.xml
@@ -55,6 +55,11 @@
<artifactId>unomi-router-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.apache.unomi</groupId>
+ <artifactId>unomi-rest</artifactId>
+ <scope>provided</scope>
+ </dependency>
<dependency>
<groupId>org.osgi</groupId>
@@ -103,6 +108,11 @@
<artifactId>slf4j-api</artifactId>
<scope>provided</scope>
</dependency>
+ <dependency>
+ <groupId>org.junit.jupiter</groupId>
+ <artifactId>junit-jupiter</artifactId>
+ <scope>test</scope>
+ </dependency>
</dependencies>
<build>
diff --git a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
index 4371e2d13..a77325160 100644
--- a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
+++ b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ExportConfigurationServiceEndPoint.java
@@ -17,7 +17,9 @@
package org.apache.unomi.router.rest;
import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ProfileService;
+import org.apache.unomi.rest.security.RequiresRole;
import org.apache.unomi.router.api.ExportConfiguration;
import org.apache.unomi.router.api.services.ImportExportConfigurationService;
import org.apache.unomi.router.api.services.ProfileExportService;
@@ -47,6 +49,7 @@ import java.util.List;
allowCredentials = true
)
@Path("/exportConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
@Component(service=ExportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true")
public class ExportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint<ExportConfiguration> {
diff --git a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
index e8d639df1..cbc7dcec3 100644
--- a/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
+++ b/extensions/router/router-rest/src/main/java/org/apache/unomi/router/rest/ImportConfigurationServiceEndPoint.java
@@ -19,7 +19,9 @@ package org.apache.unomi.router.rest;
import org.apache.cxf.jaxrs.ext.multipart.Attachment;
import org.apache.cxf.jaxrs.ext.multipart.Multipart;
import org.apache.cxf.rs.security.cors.CrossOriginResourceSharing;
+import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ConfigSharingService;
+import org.apache.unomi.rest.security.RequiresRole;
import org.apache.unomi.router.api.ImportConfiguration;
import org.apache.unomi.router.api.RouterConstants;
import org.apache.unomi.router.api.services.ImportExportConfigurationService;
@@ -54,6 +56,7 @@ import java.util.List;
allowCredentials = true
)
@Path("/importConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
@Component(service=ImportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true")
public class ImportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint<ImportConfiguration> {
diff --git a/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.java
new file mode 100644
index 000000000..ab9cfb8b3
--- /dev/null
+++ b/extensions/router/router-rest/src/test/java/org/apache/unomi/router/rest/RouterConfigurationEndPointRoleTest.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.router.rest;
+
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.rest.security.RequiresRole;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.assertArrayEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+
+/**
+ * Regression: router import/export configuration must stay restricted to system administrators.
+ */
+class RouterConfigurationEndPointRoleTest {
+
+ @Test
+ void importEndpointRequiresSystemAdministratorRole() {
+ RequiresRole requiresRole = ImportConfigurationServiceEndPoint.class.getAnnotation(RequiresRole.class);
+ assertNotNull(requiresRole, "ImportConfigurationServiceEndPoint must declare @RequiresRole");
+ assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, requiresRole.value());
+ }
+
+ @Test
+ void exportEndpointRequiresSystemAdministratorRole() {
+ RequiresRole requiresRole = ExportConfigurationServiceEndPoint.class.getAnnotation(RequiresRole.class);
+ assertNotNull(requiresRole, "ExportConfigurationServiceEndPoint must declare @RequiresRole");
+ assertArrayEquals(new String[]{UnomiRoles.ADMINISTRATOR}, requiresRole.value());
+ }
+}
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 41351e5b0..7f640a588 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -60,7 +60,9 @@ import org.junit.runners.Suite.SuiteClasses;
RuleServiceIT.class,
PrivacyServiceIT.class,
GroovyActionsServiceIT.class,
+ RestEndpointRoleSecurityIT.class,
GraphQLEventIT.class,
+ GraphQLServletSecurityIT.class,
GraphQLListIT.class,
GraphQLProfileIT.class,
GraphQLProfilePropertiesIT.class,
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 5ec6385ba..1940d3c79 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BaseIT.java
@@ -132,7 +132,10 @@ public abstract class BaseIT extends KarafTestSupport {
protected static final ContentType JSON_CONTENT_TYPE = ContentType.create("application/json");
protected static final String BASE_URL = "http://localhost";
protected static final String BASIC_AUTH_USER_NAME = "karaf";
+ /** Explicit IT password — package no longer ships a known default ({@code UNOMI_ROOT_PASSWORD}). */
protected static final String BASIC_AUTH_PASSWORD = "karaf";
+ protected static final String HEALTHCHECK_AUTH_USER_NAME = "health";
+ protected static final String HEALTHCHECK_AUTH_PASSWORD = "health";
protected static final int REQUEST_TIMEOUT = 60000;
protected static final int DEFAULT_TRYING_TIMEOUT = 1000;
protected static final int DEFAULT_TRYING_TRIES = 10;
@@ -671,6 +674,9 @@ public abstract class BaseIT extends KarafTestSupport {
editConfigurationFilePut("etc/system.properties", SEARCH_ENGINE_PROPERTY, searchEngine),
editConfigurationFilePut("etc/system.properties", PERSISTENCE_PROVIDER_PROPERTY, searchEngine),
editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.migration.tenant.id", TEST_TENANT_ID),
+ // Explicit test credentials (package no longer ships a known default password).
+ editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.security.root.password", BASIC_AUTH_PASSWORD),
+ editConfigurationFilePut("etc/custom.system.properties", "org.apache.unomi.healthcheck.password", HEALTHCHECK_AUTH_PASSWORD),
// Default scheduler.thread.poolSize (5) is sized for the near-instant in-memory unit-test
// double, not a real ES/OS backend. Under real refresh/write latency, the checker, task
// executions, and lease-renewal heartbeats (see scheduler.adoc) compete for the same small
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 bd650359b..ff4e12f06 100644
--- a/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/BasicIT.java
@@ -38,6 +38,8 @@ import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.ops4j.pax.exam.junit.PaxExam;
+
+import java.util.Base64;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerSuite;
import org.slf4j.Logger;
@@ -248,15 +250,36 @@ public class BasicIT extends BaseIT {
loginEventPropertiesVisitor2.put(LAST_NAME, LAST_NAME_VISITOR_2);
loginEventPropertiesVisitor2.put(EMAIL, EMAIL_VISITOR_2);
- // Create login event with VISITOR_2
ContextRequest contextRequestLoginVisitor2 = getContextRequestWithLoginEvent(sourceSite, loginEventPropertiesVisitor2,
EMAIL_VISITOR_2, SESSION_ID_4);
+
+ // Public API key must not switch identity / merge into another profile on a shared cookie.
+ HttpPost publicSwitchAttempt = new HttpPost(getFullUrl("/cxs/context.json"));
+ publicSwitchAttempt.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue());
+ publicSwitchAttempt.addHeader("X-Unomi-Api-Key", testPublicKeyValue);
+ publicSwitchAttempt.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2),
+ ContentType.create("application/json")));
+ TestUtils.RequestResponse publicSwitchResponse = executeContextJSONRequest(publicSwitchAttempt, SESSION_ID_4);
+ Assert.assertEquals("Public login must not switch away from the cookie profile",
+ profileIdVisitor1, publicSwitchResponse.getContextResponse().getProfileId());
+
+ // Public login still runs copyProperties on the cookie profile; restore visitor1 before the trusted switch.
+ Profile restoredVisitor1 = profileService.load(profileIdVisitor1);
+ restoredVisitor1.setProperty(FIRST_NAME, FIRST_NAME_VISITOR_1);
+ restoredVisitor1.setProperty(LAST_NAME, LAST_NAME_VISITOR_1);
+ restoredVisitor1.setProperty(EMAIL, EMAIL_VISITOR_1);
+ profileService.save(restoredVisitor1);
+ keepTrying("Visitor1 properties not restored", () -> profileService.load(profileIdVisitor1),
+ p -> FIRST_NAME_VISITOR_1.equals(p.getProperty(FIRST_NAME)), DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Trusted private key may switch the browsing profile to VISITOR_2.
HttpPost requestLoginVisitor2 = new HttpPost(getFullUrl("/cxs/context.json"));
requestLoginVisitor2.addHeader("Cookie", requestResponsePageView1.getCookieHeaderValue());
- requestLoginVisitor2.addHeader("X-Unomi-Api-Key", testPublicKeyValue);
+ requestLoginVisitor2.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(
+ (TEST_TENANT_ID + ":" + testPrivateKeyValue).getBytes()));
requestLoginVisitor2.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequestLoginVisitor2),
ContentType.create("application/json")));
- TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4);
+ TestUtils.RequestResponse requestResponseLoginVisitor2 = executeContextJSONRequest(requestLoginVisitor2, SESSION_ID_4, -1, false);
// We should have a new profile id so the session should have been moved from VISITOR_1 to VISITOR_2
String profileIdVisitor2 = requestResponseLoginVisitor2.getContextResponse().getProfileId();
Assert.assertNotEquals("Context profile id should not be the same", profileIdVisitor1,
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 e714ac218..afdb2fc81 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.impl.client.TargetAuthenticationStrategy;
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;
@@ -419,6 +421,242 @@ public class ContextServletIT extends BaseIT {
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 attackerProfileId = "attacker-body-profile-" + System.currentTimeMillis();
+ ContextRequest mismatchRequest = new ContextRequest();
+ mismatchRequest.setSessionId(sessionId);
+ mismatchRequest.setProfileId(attackerProfileId);
+ 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("Attacker-supplied profileId must not be created", profileService.load(attackerProfileId));
+ }
+
+ @Test
+ public void testPublicCaller_sessionProfileSwitchWithoutMatchingCookie_refused() throws Exception {
+ String sessionOwnerId = "session-owner-" + System.currentTimeMillis();
+ String sessionId = "hijack-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 hijack = new ContextRequest();
+ hijack.setSessionId(sessionId);
+ hijack.setProfileId(cookieProfileId);
+ HttpPost hijackRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(hijackRequest);
+ hijackRequest.addHeader("Cookie", established.getCookieHeaderValue());
+ hijackRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(hijack), ContentType.APPLICATION_JSON));
+ RequestResponse hijacked = executeContextJSONRequest(hijackRequest, sessionId);
+
+ assertEquals("Public caller must not adopt a foreign session profile",
+ cookieProfileId, hijacked.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());
+ }
+
+ @Test
+ public void testPublicHttp_updateProperties_cannotUpdateAnotherProfile() throws Exception {
+ String victimId = "update-victim-" + System.currentTimeMillis();
+ Profile victim = new Profile(victimId);
+ profileService.save(victim);
+ keepTrying("Victim profile not found", () -> profileService.load(victimId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ String sessionId = "update-attacker-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(sessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, sessionId);
+
+ Event updateEvent = new Event();
+ updateEvent.setEventType("updateProperties");
+ updateEvent.setScope(TEST_SCOPE);
+ Map<String, Object> props = new HashMap<>();
+ props.put("targetId", victimId);
+ props.put("targetType", "profile");
+ Map<String, Object> toUpdate = new HashMap<>();
+ toUpdate.put("properties.firstName", "PWNED");
+ props.put("update", toUpdate);
+ updateEvent.setProperties(props);
+
+ ContextRequest attack = new ContextRequest();
+ attack.setSessionId(sessionId);
+ attack.setEvents(Collections.singletonList(updateEvent));
+ HttpPost attackRequest = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(attackRequest);
+ attackRequest.addHeader("Cookie", established.getCookieHeaderValue());
+ attackRequest.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attack), ContentType.APPLICATION_JSON));
+ executeContextJSONRequest(attackRequest, sessionId);
+
+ shouldBeTrueUntilEnd("Victim profile must not be updated by public updateProperties",
+ () -> profileService.load(victimId),
+ p -> p.getProperty("firstName") == null,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+ }
+
+ @Test
+ public void testPrivateKeyHttp_updateProperties_canUpdateAnotherProfile() throws Exception {
+ String victimId = "trusted-update-victim-" + System.currentTimeMillis();
+ Profile victim = new Profile(victimId);
+ profileService.save(victim);
+ keepTrying("Victim profile not found", () -> profileService.load(victimId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ String sessionId = "trusted-update-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(sessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, sessionId);
+
+ Event updateEvent = new Event();
+ updateEvent.setEventType("updateProperties");
+ updateEvent.setScope(TEST_SCOPE);
+ Map<String, Object> props = new HashMap<>();
+ props.put("targetId", victimId);
+ props.put("targetType", "profile");
+ Map<String, Object> toUpdate = new HashMap<>();
+ toUpdate.put("properties.firstName", "TRUSTED_HTTP");
+ props.put("update", toUpdate);
+ updateEvent.setProperties(props);
+
+ ContextRequest update = new ContextRequest();
+ update.setSessionId(sessionId);
+ update.setEvents(Collections.singletonList(updateEvent));
+ HttpPost trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+ trusted.addHeader("Cookie", established.getCookieHeaderValue());
+ trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(update), ContentType.APPLICATION_JSON));
+ executeContextJSONRequest(trusted, sessionId, -1, false);
+
+ waitForProfileProperty(victimId, "firstName", "TRUSTED_HTTP");
+ }
+
+ @Test
+ public void testPublicHttpLogin_cannotMergeIntoExistingVictimProfile() throws Exception {
+ ConditionType conditionType = getObjectMapper().readValue(
+ new File("data/tmp/testLoginEventCondition.json").toURI().toURL(), ConditionType.class);
+ definitionsService.setConditionType(conditionType);
+ keepTrying("loginEventCondition not registered",
+ () -> definitionsService.getConditionType("loginEventCondition"),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+ Rule rule = getObjectMapper().readValue(new File("data/tmp/testLogin.json").toURI().toURL(), Rule.class);
+ createAndWaitForRule(rule);
+
+ String victimEmail = "victim-takeover-" + System.currentTimeMillis() + "@example.com";
+ String victimId = "victim-merge-" + System.currentTimeMillis();
+ Profile victim = new Profile(victimId);
+ victim.setProperty("email", victimEmail);
+ victim.setSystemProperty("mergeIdentifier", victimEmail);
+ profileService.save(victim);
+ keepTrying("Victim not found", () -> profileService.load(victimId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ String sessionId = "attacker-merge-session-" + System.currentTimeMillis();
+ ContextRequest pageView = new ContextRequest();
+ pageView.setSessionId(sessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(pageView), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, sessionId);
+ String attackerId = established.getContextResponse().getProfileId();
+ assertNotEquals(victimId, attackerId);
+
+ CustomItem loginTarget = new CustomItem(victimEmail, "visitor");
+ Map<String, Object> loginProps = new HashMap<>();
+ loginProps.put("email", victimEmail);
+ loginTarget.setProperties(loginProps);
+ Event login = new Event();
+ login.setEventType("login");
+ login.setScope(TEST_SCOPE);
+ login.setTarget(loginTarget);
+ login.setTimeStamp(new Date());
+
+ ContextRequest loginRequest = new ContextRequest();
+ loginRequest.setSessionId(sessionId);
+ loginRequest.setEvents(Collections.singletonList(login));
+ HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(attack);
+ attack.addHeader("Cookie", established.getCookieHeaderValue());
+ attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON));
+ RequestResponse afterLogin = executeContextJSONRequest(attack, sessionId);
+
+ assertEquals("Public login must not take over the victim profile",
+ attackerId, afterLogin.getContextResponse().getProfileId());
+ assertNotNull(profileService.load(victimId));
+ rulesService.removeRule("testLogin");
+ }
+
@Test
public void testCreateEventWithProfileId_Success() throws Exception {
//Arrange
@@ -432,16 +670,36 @@ public class ContextServletIT extends BaseIT {
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 victimId = "body-only-victim-" + System.currentTimeMillis();
+ Profile victim = new Profile(victimId);
+ victim.setProperty("email", "victim-body-only@example.com");
+ profileService.save(victim);
+ keepTrying("Victim not found", () -> profileService.load(victimId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ ContextRequest attack = new ContextRequest();
+ attack.setProfileId(victimId);
+ attack.setRequiredProfileProperties(Collections.singletonList("*"));
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(request);
+ request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(attack), ContentType.APPLICATION_JSON));
+ // Body profileId is ignored for public callers; with no cookie/session → 400, not victim data
+ RequestResponse response = executeContextJSONRequest(request, null, 400, false);
+ assertEquals(400, response.getStatusCode());
+ }
+
@Test
public void testCreateEventWithPropertiesValidation_Success() throws Exception {
//Arrange
@@ -489,11 +747,11 @@ public class ContextServletIT extends BaseIT {
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 +774,11 @@ public class ContextServletIT extends BaseIT {
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 +839,11 @@ public class ContextServletIT extends BaseIT {
@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<String> variants = contextResponse.getPersonalizations().get("perso-by-interest");
assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -600,8 +860,9 @@ public class ContextServletIT extends BaseIT {
// check results of the perso now
request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON));
- response = executeContextJSONRequest(request);
+ response = executeContextJSONRequest(request, null, -1, false);
contextResponse = response.getContextResponse();
variants = contextResponse.getPersonalizations().get("perso-by-interest");
assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -637,8 +898,9 @@ public class ContextServletIT extends BaseIT {
// re test now that profiles has interests
request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON));
- response = executeContextJSONRequest(request);
+ response = executeContextJSONRequest(request, null, -1, false);
contextResponse = response.getContextResponse();
variants = contextResponse.getPersonalizations().get("perso-by-interest");
assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -675,8 +937,9 @@ public class ContextServletIT extends BaseIT {
// first let's make sure everything works without the requireScoring parameter
parameters = new HashMap<>();
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("withoutRequireScores.json", parameters), ContentType.APPLICATION_JSON));
- TestUtils.RequestResponse response = executeContextJSONRequest(request);
+ TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false);
assertEquals("Invalid response code", 200, response.getStatusCode());
assertNotNull("Context response should not be null", response.getContextResponse());
@@ -686,8 +949,9 @@ public class ContextServletIT extends BaseIT {
// now let's test adding it.
parameters = new HashMap<>();
request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("withRequireScores.json", parameters), ContentType.APPLICATION_JSON));
- response = executeContextJSONRequest(request);
+ response = executeContextJSONRequest(request, null, -1, false);
assertEquals("Invalid response code", 200, response.getStatusCode());
assertNotNull("Context response should not be null", response.getContextResponse());
@@ -874,7 +1138,8 @@ public class ContextServletIT extends BaseIT {
// Test with JAAS authentication (should succeed)
BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
- credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ credsProvider.setCredentials(AuthScope.ANY,
+ new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
RequestConfig requestConfig = RequestConfig.custom()
.setAuthenticationEnabled(true)
@@ -922,13 +1187,15 @@ public class ContextServletIT extends BaseIT {
// Test normal personalization should not have control group info in response
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ // JSON fixtures bind profileId in the body — requires trusted caller (public ignores body profileId).
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
if (controlGroupConfig != null) {
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-control-group.json", controlGroupConfig), ContentType.APPLICATION_JSON));
} else {
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-no-control-group.json", null), ContentType.APPLICATION_JSON));
}
- TestUtils.RequestResponse response = executeContextJSONRequest(request);
+ TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false);
ContextResponse contextResponse = response.getContextResponse();
// Check variants
@@ -978,23 +1245,31 @@ public class ContextServletIT extends BaseIT {
contextRequest.setProfileId(profile.getItemId());
contextRequest.setSessionId(sessionId);
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ // Body profileId requires trusted caller (public ignores body profileId).
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
+ assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
// set the property as concealed
customPropertyType.getMetadata().getSystemTags().add("concealed");
profileService.deletePropertyType(customPropertyType.getItemId());
profileService.setPropertyType(customPropertyType);
// Not in all properties
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- assertNull(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"));
+ assertNull(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"));
// Got it explicitly
contextRequest.setRequiredProfileProperties(Arrays.asList("customProperty"));
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
+ assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
// Got it with all
contextRequest.setRequiredProfileProperties(Arrays.asList("*", "customProperty"));
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
+ assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
// remove the concealed tag on the property type
customPropertyType.getMetadata().getSystemTags().remove("concealed");
@@ -1003,8 +1278,10 @@ public class ContextServletIT extends BaseIT {
// Got it from all properties
contextRequest.setRequiredProfileProperties(Arrays.asList("*"));
+ request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- assertEquals(executeContextJSONRequest(request, sessionId).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
+ assertEquals(executeContextJSONRequest(request, sessionId, -1, false).getContextResponse().getProfileProperties().get("customProperty"), ("concealedValue"));
}
@Test
diff --git a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
index 6cc692a0d..e7a135b00 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -61,7 +61,9 @@ import org.junit.runners.Suite.SuiteClasses;
RuleServiceIT.class,
PrivacyServiceIT.class,
GroovyActionsServiceIT.class,
+ RestEndpointRoleSecurityIT.class,
GraphQLEventIT.class,
+ GraphQLServletSecurityIT.class,
GraphQLListIT.class,
GraphQLProfileIT.class,
GraphQLProfilePropertiesIT.class,
diff --git a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
index 305c2f38e..f7179a7f4 100644
--- a/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/HealthCheckIT.java
@@ -54,8 +54,6 @@ public class HealthCheckIT extends BaseIT {
private final static Logger LOGGER = LoggerFactory.getLogger(HealthCheckIT.class);
- protected static final String HEALTHCHECK_AUTH_USER_NAME = "health";
- protected static final String HEALTHCHECK_AUTH_PASSWORD = "health";
protected static final String HEALTHCHECK_ENDPOINT = "/health/check";
@Test
@@ -132,6 +130,15 @@ public class HealthCheckIT extends BaseIT {
}
}
+ @Test
+ public void testHealthCheck_wrongPasswordRejected() throws Exception {
+ final HttpGet httpGet = new HttpGet(getFullUrl(HEALTHCHECK_ENDPOINT));
+ try (CloseableHttpResponse response = executeHttpRequest(
+ httpGet, AuthType.CUSTOM_BASIC, HEALTHCHECK_AUTH_USER_NAME, "wrong-password")) {
+ Assert.assertEquals(401, response.getStatusLine().getStatusCode());
+ }
+ }
+
protected <T> T get(final String url, TypeReference<T> typeReference) {
CloseableHttpResponse response = null;
try {
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 20011d21c..179984d61 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ProfileMergeIT.java
@@ -176,6 +176,71 @@ public class ProfileMergeIT extends BaseIT {
* - a new one, if it's the first time we encounter his own mergeIdentifier
* - a previous one, if we already have a profile in DB with the same mergeIdentifier. (TESTED in this scenario)
*/
+ /**
+ * Public / untrusted callers must not merge into an existing victim profile (identity takeover).
+ * Suite {@code @Before} installs a tenant-admin subject; this test temporarily downgrades it.
+ */
+ @Test
+ public void testUntrustedCaller_cannotMergeIntoExistingVictimProfile() throws InterruptedException {
+ createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+ Profile victim = new Profile("victimProfileID");
+ victim.setProperty("email", "victim@example.com");
+ victim.setSystemProperty("mergeIdentifier", "victim@example.com");
+ profileService.save(victim);
+
+ keepTrying("Victim profile not found", () -> profileService.load("victimProfileID"),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ Profile attacker = new Profile("attackerProfileID");
+ attacker.setProperty("email", "victim@example.com");
+ Session session = new Session("untrustedMergeSession", attacker, new Date(), null);
+ Event event = new Event(TEST_EVENT_TYPE, session, attacker, null, null, attacker, 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);
+ }
+
+ Assert.assertEquals("attackerProfileID", event.getProfile().getItemId());
+ Assert.assertEquals("attackerProfileID", event.getSession().getProfile().getItemId());
+ Assert.assertNotNull(profileService.load("victimProfileID"));
+ }
+
+ @Test
+ public void testTrustedPrivateKeySubject_canMergeIntoExistingProfile() throws InterruptedException {
+ createAndWaitForRule(createMergeOnPropertyRule(false, "email"));
+
+ Profile victim = new Profile("trustedVictimProfileID");
+ victim.setProperty("email", "trusted-victim@example.com");
+ victim.setSystemProperty("mergeIdentifier", "trusted-victim@example.com");
+ victim.setProperty("firstVisit", new Date(0));
+ profileService.save(victim);
+
+ keepTrying("Victim profile not found", () -> profileService.load("trustedVictimProfileID"),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ Profile caller = new Profile("trustedCallerProfileID");
+ caller.setProperty("email", "trusted-victim@example.com");
+ caller.setProperty("firstVisit", new Date());
+ 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);
+ }
+
+ Assert.assertEquals("trustedVictimProfileID", event.getProfile().getItemId());
+ Assert.assertEquals("trustedVictimProfileID", event.getSession().getProfile().getItemId());
+ }
+
@Test
public void testProfileMergeOnPropertyAction_sessionReassigned_existingProfile() throws InterruptedException {
// create rule
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 ad2206a6b..64d65ea4f 100644
--- a/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/PropertiesUpdateActionIT.java
@@ -20,6 +20,7 @@ package org.apache.unomi.itests;
import org.apache.unomi.api.Event;
import org.apache.unomi.api.Profile;
import org.apache.unomi.api.rules.Rule;
+import org.apache.unomi.api.services.EventService;
import org.apache.unomi.plugins.baseplugin.actions.UpdatePropertiesAction;
import org.junit.Assert;
import org.junit.Before;
@@ -118,6 +119,81 @@ public class PropertiesUpdateActionIT extends BaseIT {
waitForProfileProperty(PROFILE_TEST_ID, "firstName", "UPDATED FIRST NAME");
}
+ @Test
+ public void testUntrustedCaller_cannotUpdateAnotherProfile() throws InterruptedException {
+ Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+ Profile other = profileService.load(PROFILE_TEST_ID);
+ Assert.assertNull(other.getProperty("firstName"));
+
+ Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date());
+ updateProperties.setPersistent(false);
+ Map<String, Object> propertyToUpdate = new HashMap<>();
+ propertyToUpdate.put("properties.firstName", "SHOULD_NOT_APPLY");
+ updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate);
+ 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);
+ }
+
+ shouldBeTrueUntilEnd("Other profile must remain unchanged",
+ () -> profileService.load(PROFILE_TEST_ID),
+ p -> p.getProperty("firstName") == null,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+ }
+
+ @Test
+ public void testUntrustedCaller_cannotWriteSystemProperties() 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", "stolen");
+ 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);
+ }
+
+ Assert.assertNull(profileService.load(PROFILE_TEST_ID).getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void testTrustedPrivateKeySubject_canUpdateAnotherProfile() throws InterruptedException {
+ Profile caller = profileService.load(PROFILE_TARGET_TEST_ID);
+ Assert.assertNull(profileService.load(PROFILE_TEST_ID).getProperty("firstName"));
+
+ Event updateProperties = new Event("updateProperties", null, caller, null, null, null, new Date());
+ updateProperties.setPersistent(false);
+ Map<String, Object> propertyToUpdate = new HashMap<>();
+ propertyToUpdate.put("properties.firstName", "TRUSTED UPDATE");
+ updateProperties.setProperty(UpdatePropertiesAction.PROPS_TO_UPDATE, propertyToUpdate);
+ 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);
+ }
+
+ waitForProfileProperty(PROFILE_TEST_ID, "firstName", "TRUSTED UPDATE");
+ }
+
@Test
public void testUpdateProperties_CurrentProfile_PROPS_TO_ADD() throws InterruptedException {
Profile profile = profileService.load(PROFILE_TEST_ID);
diff --git a/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java
new file mode 100644
index 000000000..67329b98e
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/RestEndpointRoleSecurityIT.java
@@ -0,0 +1,177 @@
+/*
+ * 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.HttpDelete;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ByteArrayEntity;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.junit.Assert;
+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.nio.charset.StandardCharsets;
+
+/**
+ * HTTP-level checks that system-admin-only REST endpoints reject tenant private keys
+ * (including multipart upload / oneshot paths).
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class RestEndpointRoleSecurityIT extends BaseIT {
+
+ @Test
+ public void importConfiguration_requiresSystemAdministrator() throws Exception {
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+ new HttpGet(getFullUrl("/cxs/importConfiguration")), AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Tenant private key must not list import configurations",
+ 403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+ new HttpGet(getFullUrl("/cxs/importConfiguration")), AuthType.JAAS_ADMIN)) {
+ Assert.assertEquals("JAAS admin should list import configurations",
+ 200, jaasAdmin.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void exportConfiguration_requiresSystemAdministrator() throws Exception {
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(
+ new HttpGet(getFullUrl("/cxs/exportConfiguration")), AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(
+ new HttpGet(getFullUrl("/cxs/exportConfiguration")), AuthType.JAAS_ADMIN)) {
+ Assert.assertEquals(200, jaasAdmin.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void importConfiguration_oneshotUpload_requiresSystemAdministrator() throws Exception {
+ HttpPost oneshot = multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+ "----UnomiImportBoundary",
+ part("importConfigId", "text/plain", "rest-role-security-oneshot"),
+ filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ HttpPost oneshotJaas = multipartPost(getFullUrl("/cxs/importConfiguration/oneshot"),
+ "----UnomiImportBoundaryJaas",
+ part("importConfigId", "text/plain", "rest-role-security-oneshot"),
+ filePart("file", "probe.csv", "text/csv", "col1\nvalue1\n"));
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, AuthType.JAAS_ADMIN)) {
+ // Role gate is what we care about; missing config may yield 500 after auth succeeds.
+ Assert.assertNotEquals(403, jaasAdmin.getStatusLine().getStatusCode());
+ Assert.assertNotEquals(401, jaasAdmin.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void exportConfiguration_oneshot_requiresSystemAdministrator() throws Exception {
+ String body = "{\"itemId\":\"rest-role-security-export\",\"itemType\":\"exportConfig\"}";
+ HttpPost oneshot = new HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+ oneshot.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(oneshot, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals(403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ HttpPost oneshotJaas = new HttpPost(getFullUrl("/cxs/exportConfiguration/oneshot"));
+ oneshotJaas.setEntity(new StringEntity(body, ContentType.APPLICATION_JSON));
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(oneshotJaas, AuthType.JAAS_ADMIN)) {
+ Assert.assertNotEquals(403, jaasAdmin.getStatusLine().getStatusCode());
+ Assert.assertNotEquals(401, jaasAdmin.getStatusLine().getStatusCode());
+ }
+ }
+
+ @Test
+ public void groovyActions_requiresSystemAdministrator() throws Exception {
+ String path = getFullUrl("/cxs/groovyActions/rest-role-security-it-missing-action");
+
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(new HttpDelete(path), AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Tenant private key must not delete groovy actions",
+ 403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(new HttpDelete(path), AuthType.JAAS_ADMIN)) {
+ int status = jaasAdmin.getStatusLine().getStatusCode();
+ Assert.assertTrue("JAAS admin delete should be allowed (got " + status + ")",
+ status == 200 || status == 204 || status == 404);
+ }
+ }
+
+ @Test
+ public void groovyActions_upload_requiresSystemAdministrator() throws Exception {
+ String script = "// RestEndpointRoleSecurityIT probe\nvoid execute() {}\n";
+ HttpPost upload = multipartPost(getFullUrl("/cxs/groovyActions/"),
+ "----UnomiGroovyBoundary",
+ filePart("file", "RestRoleSecurityITProbe.groovy", "text/plain", script));
+
+ try (CloseableHttpResponse tenantAdmin = executeHttpRequest(upload, AuthType.PRIVATE_KEY)) {
+ Assert.assertEquals("Tenant private key must not upload groovy actions",
+ 403, tenantAdmin.getStatusLine().getStatusCode());
+ }
+
+ HttpPost uploadJaas = multipartPost(getFullUrl("/cxs/groovyActions/"),
+ "----UnomiGroovyBoundaryJaas",
+ filePart("file", "RestRoleSecurityITProbe.groovy", "text/plain", script));
+ try (CloseableHttpResponse jaasAdmin = executeHttpRequest(uploadJaas, AuthType.JAAS_ADMIN)) {
+ Assert.assertEquals("JAAS admin should be allowed to upload groovy actions",
+ 200, jaasAdmin.getStatusLine().getStatusCode());
+ }
+
+ try (CloseableHttpResponse cleanup = executeHttpRequest(
+ new HttpDelete(getFullUrl("/cxs/groovyActions/RestRoleSecurityITProbe")), AuthType.JAAS_ADMIN)) {
+ int status = cleanup.getStatusLine().getStatusCode();
+ Assert.assertTrue(status == 200 || status == 204 || status == 404);
+ }
+ }
+
+ private static HttpPost multipartPost(String url, String boundary, String... parts) {
+ HttpPost post = new HttpPost(url);
+ StringBuilder body = new StringBuilder();
+ for (String part : parts) {
+ body.append("--").append(boundary).append("\r\n").append(part);
+ }
+ body.append("--").append(boundary).append("--\r\n");
+ post.setHeader("Content-Type", "multipart/form-data; boundary=" + boundary);
+ post.setEntity(new ByteArrayEntity(body.toString().getBytes(StandardCharsets.UTF_8)));
+ return post;
+ }
+
+ private static String part(String name, String contentType, String value) {
+ return "Content-Disposition: form-data; name=\"" + name + "\"\r\n"
+ + "Content-Type: " + contentType + "\r\n\r\n"
+ + value + "\r\n";
+ }
+
+ private static String filePart(String name, String filename, String contentType, String value) {
+ return "Content-Disposition: form-data; name=\"" + name + "\"; filename=\"" + filename + "\"\r\n"
+ + "Content-Type: " + contentType + "\r\n\r\n"
+ + value + "\r\n";
+ }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
index a9ef9a595..9cce84707 100644
--- a/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/TenantIT.java
@@ -190,7 +190,7 @@ public class TenantIT extends BaseIT {
// Create test tenant for API key tests
BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider();
- adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build()) {
Map<String, Object> requestBody = new HashMap<>();
@@ -273,7 +273,7 @@ public class TenantIT extends BaseIT {
// Test with JAAS auth (should succeed) — use a fresh request to avoid carrying X-Unomi-Api-Key from previous step
BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider();
- adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/context.json?sessionId=" + sessionId)))) {
Assert.assertEquals("JAAS auth should grant access to public endpoints", 200, response.getStatusLine().getStatusCode());
@@ -325,7 +325,7 @@ public class TenantIT extends BaseIT {
// Test with JAAS auth (should succeed) — use a fresh request to avoid carrying Authorization from previous step
BasicCredentialsProvider adminCredsProvider = new BasicCredentialsProvider();
- adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ adminCredsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
try (CloseableHttpClient adminClient = HttpClients.custom().setDefaultCredentialsProvider(adminCredsProvider).build();
CloseableHttpResponse response = adminClient.execute(new HttpGet(getFullUrl("/cxs/profiles/count")))) {
Assert.assertEquals("JAAS auth should grant access to private endpoints", 200, response.getStatusLine().getStatusCode());
@@ -378,7 +378,7 @@ public class TenantIT extends BaseIT {
// Test with JAAS authentication (should succeed)
getRequest = new HttpGet(getFullUrl("/cxs/profiles/count"));
- getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString(("karaf:karaf").getBytes()));
+ getRequest.setHeader("Authorization", "Basic " + Base64.getEncoder().encodeToString((BASIC_AUTH_USER_NAME + ":" + BASIC_AUTH_PASSWORD).getBytes()));
try (CloseableHttpResponse response = executeHttpRequest(getRequest, AuthType.JAAS_ADMIN)) {
Assert.assertEquals("JAAS authentication should grant access to private endpoints", 200, response.getStatusLine().getStatusCode());
}
diff --git a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
index cce6c5484..f27089368 100644
--- a/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/V2CompatibilityModeIT.java
@@ -225,7 +225,7 @@ public class V2CompatibilityModeIT extends BaseIT {
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
- credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
RequestConfig requestConfig = RequestConfig.custom()
.setAuthenticationEnabled(true)
@@ -283,7 +283,7 @@ public class V2CompatibilityModeIT extends BaseIT {
HttpGet getRequest = new HttpGet(getFullUrl("/cxs/profiles/" + TEST_PROFILE_ID));
BasicCredentialsProvider credsProvider = new BasicCredentialsProvider();
- credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials("karaf", "karaf"));
+ credsProvider.setCredentials(AuthScope.ANY, new UsernamePasswordCredentials(BASIC_AUTH_USER_NAME, BASIC_AUTH_PASSWORD));
RequestConfig requestConfig = RequestConfig.custom()
.setAuthenticationEnabled(true)
diff --git a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
index 1ff220109..34db0381e 100644
--- a/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/graphql/BaseGraphQLIT.java
@@ -72,7 +72,7 @@ public abstract class BaseGraphQLIT extends BaseIT {
}
/**
- * Performs a GraphQL POST request with JAAS admin authentication (karaf:karaf).
+ * Performs a GraphQL POST request with JAAS admin authentication ({@link #BASIC_AUTH_USER_NAME}/{@link #BASIC_AUTH_PASSWORD}).
* This is equivalent to AuthType.JAAS_ADMIN.
*
* @param resource The resource path to the GraphQL query/mutation
diff --git a/itests/src/test/resources/etc/users.properties b/itests/src/test/resources/etc/users.properties
index 377fe6a16..538906c38 100644
--- a/itests/src/test/resources/etc/users.properties
+++ b/itests/src/test/resources/etc/users.properties
@@ -29,6 +29,6 @@
# and modifiable via the JAAS command group. These users reside in a JAAS domain
# with the name "karaf".
#
-karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup
-health = ${org.apache.unomi.healthcheck.password:-health},health
+karaf = ${org.apache.unomi.security.root.password},_g_:admingroup
+health = ${org.apache.unomi.healthcheck.password},health
_g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_USER,ROLE_UNOMI_TENANT_ADMIN
diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc
index b76b2abb2..9c7511e79 100644
--- a/manual/src/main/asciidoc/configuration.adoc
+++ b/manual/src/main/asciidoc/configuration.adoc
@@ -248,7 +248,10 @@ At the end, you should have about 4 million entries in the geonames index.
=== REST API Security
The Apache Unomi Context Server REST API is protected using JAAS authentication and using Basic or Digest HTTP auth.
-By default, the login/password for the REST API full administrative access is "karaf/karaf".
+You must set an admin password via `UNOMI_ROOT_PASSWORD` (or `org.apache.unomi.security.root.password`)
+and a health-check password via `UNOMI_HEALTHCHECK_PASSWORD` (or `org.apache.unomi.healthcheck.password`);
+Unomi does not ship known defaults.
+The default JAAS user name is `karaf`.
The generated package is also configured with a default SSL certificate. You can change it by following these steps :
@@ -267,8 +270,9 @@ org.ops4j.pax.web.ssl.keypassword=${env:UNOMI_SSL_KEYPASSWORD:-changeme}
You should now have SSL setup on Karaf with your certificate, and you can test it by trying to access it on port 9443.
-Changing the default Karaf password can be done by modifying the `org.apache.unomi.security.root.password` in the
-`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
+Changing the Karaf admin password is done by setting `UNOMI_ROOT_PASSWORD` or by modifying
+`org.apache.unomi.security.root.password` in the
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` file. Unomi does not ship a known default password.
=== Tenant Management and API Access
@@ -278,7 +282,7 @@ Apache Unomi supports multi-tenancy, allowing multiple organizations to use the
IMPORTANT: All tenant management operations (create, list, update, delete, API key management) are restricted to administrators only and require JAAS authentication. These endpoints cannot be accessed using tenant API keys.
-To manage tenants, you need administrator access to Unomi (default credentials: karaf/karaf). You can manage tenants using either the REST API or the Karaf shell commands:
+To manage tenants, you need administrator access to Unomi (`karaf` / your `UNOMI_ROOT_PASSWORD`). You can manage tenants using either the REST API or the Karaf shell commands:
Using REST API (requires admin credentials):
[source,bash]
@@ -1178,23 +1182,29 @@ documentation for here :
* https://karaf.apache.org/manual/latest/#_security_2[https://karaf.apache.org/manual/latest/#_security_2]
-The default username/password is
+You must set an admin password and a health-check password before using the REST API / health endpoints.
+There are **no** known default passwords shipped with Unomi.
-[source]
+Set the environment variables `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD`, or set
+`org.apache.unomi.security.root.password` and `org.apache.unomi.healthcheck.password` in
+`$MY_KARAF_HOME/etc/unomi.custom.system.properties` (or `etc/custom.system.properties`).
+
+Example:
+
+[source,bash]
----
-karaf/karaf
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
----
-You should really change this default username/password as soon as possible. Changing the default Karaf password can be
-done by modifying the `org.apache.unomi.security.root.password` in the `$MY_KARAF_HOME/etc/unomi.custom.system.properties` file
-
-Or if you want to also change the user name you could modify the following file :
+The JAAS admin user name defaults to `karaf`. Authenticate with `karaf:$UNOMI_ROOT_PASSWORD`.
+The health-check user name defaults to `health`. Authenticate with `health:$UNOMI_HEALTHCHECK_PASSWORD`.
- $MY_KARAF_HOME/etc/users.properties
+To change the admin user name, edit `$MY_KARAF_HOME/etc/users.properties` and also set:
-But you will also need to change the following property in the $MY_KARAF_HOME/etc/unomi.custom.system.properties :
+ karaf.local.user = <your-user>
- karaf.local.user = karaf
+in `unomi.custom.system.properties`.
For your context servers, and for any standalone Elasticsearch nodes you will need to open the following ports for proper
node-to-node communication : 9200 (Elasticsearch REST API), 9300 (Elasticsearch TCP transport)
diff --git a/manual/src/main/asciidoc/getting-started.adoc b/manual/src/main/asciidoc/getting-started.adoc
index 223cb3848..1351cbc95 100644
--- a/manual/src/main/asciidoc/getting-started.adoc
+++ b/manual/src/main/asciidoc/getting-started.adoc
@@ -50,6 +50,21 @@ Note for OpenSearch users:
==== Running Unomi
+===== Set the admin and health passwords (required)
+
+Unomi does not ship known default passwords. Set both *before* starting the server
+(otherwise startup fails with a clear error):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
+Authenticate later with `karaf:$UNOMI_ROOT_PASSWORD`. See the configuration chapter if you prefer to set
+`org.apache.unomi.security.root.password` / `org.apache.unomi.healthcheck.password` in
+`etc/custom.system.properties` instead.
+
===== Start Unomi
Start Unomi according to the <<_five_minutes_quickstart,quick start with docker>> or by compiling using the
@@ -67,14 +82,14 @@ Initializing profile service endpoint...
Initializing cluster service endpoint...
----
-This indicates that all the Unomi services are started and ready to react to requests.
+This indicates that all the Unomi services are started and ready to react to requests.
-Before you can use the API, you need to create a tenant:
+Create a tenant (using the password you set before startup):
[source,bash]
----
curl -X POST http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:${UNOMI_ROOT_PASSWORD}" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "default",
@@ -89,8 +104,8 @@ The tenant create response includes **masked** API key metadata only. Regenerate
[source,bash]
----
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PUBLIC" --user "karaf:${UNOMI_ROOT_PASSWORD}"
+curl -X POST "http://localhost:8181/cxs/tenants/default/apikeys?type=PRIVATE" --user "karaf:${UNOMI_ROOT_PASSWORD}"
----
Store `plainTextKey` immediately — you'll need the public key for subsequent API calls. See <<_multitenancy,Multi-tenancy>> for authentication details.
diff --git a/package/src/main/resources/bin/setenv b/package/src/main/resources/bin/setenv
index e48561de4..ff12e2de3 100755
--- a/package/src/main/resources/bin/setenv
+++ b/package/src/main/resources/bin/setenv
@@ -50,6 +50,50 @@
MY_DIRNAME=`dirname $0`
MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd`
+# Fail fast when starting without admin/health passwords (no known defaults are shipped).
+# Skip for stop/status/etc., or set UNOMI_SKIP_*_PASSWORD_CHECK=true if you configured
+# the matching property another way (e.g. edited users.properties / custom.system.properties).
+case "${KARAF_SCRIPT}" in
+ karaf|start|daemon|run|debug)
+ if [ -z "${UNOMI_ROOT_PASSWORD}" ] && [ "${UNOMI_SKIP_ROOT_PASSWORD_CHECK}" != "true" ]; then
+ cat >&2 <<'EOF'
+ERROR: UNOMI_ROOT_PASSWORD is not set.
+
+Apache Unomi does not ship a known default admin password.
+Set one before starting, for example:
+
+ export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+ export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+ ./bin/karaf
+
+Or set org.apache.unomi.security.root.password in etc/custom.system.properties
+and export UNOMI_SKIP_ROOT_PASSWORD_CHECK=true.
+
+See getting started / configuration documentation for details.
+EOF
+ exit 1
+ fi
+ if [ -z "${UNOMI_HEALTHCHECK_PASSWORD}" ] && [ "${UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK}" != "true" ]; then
+ cat >&2 <<'EOF'
+ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set.
+
+Apache Unomi does not ship a known default health-check password.
+Set one before starting, for example:
+
+ export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+ export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+ ./bin/karaf
+
+Or set org.apache.unomi.healthcheck.password in etc/custom.system.properties
+and export UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true.
+
+See getting started / configuration documentation for details.
+EOF
+ exit 1
+ fi
+ ;;
+esac
+
# In order to activate the Yourkit Profiler agent, uncomment one of the following lines depending on the operating
# system and adjust the path to the location of the installation directory.
# On MacOS:
diff --git a/package/src/main/resources/bin/setenv.bat b/package/src/main/resources/bin/setenv.bat
index f5db6f80b..83652357a 100644
--- a/package/src/main/resources/bin/setenv.bat
+++ b/package/src/main/resources/bin/setenv.bat
@@ -63,3 +63,46 @@ rem SET KARAF_DEBUG
set MY_DIRNAME=%~dp0%
set MY_KARAF_HOME=%DIRNAME%..
+
+rem Fail fast when starting without admin/health passwords (no known defaults are shipped).
+rem Set UNOMI_SKIP_*_PASSWORD_CHECK=true if you configured the matching password another way.
+if /I "%KARAF_SCRIPT%"=="karaf.bat" goto checkPasswords
+if /I "%KARAF_SCRIPT%"=="start.bat" goto checkPasswords
+if /I "%KARAF_SCRIPT%"=="daemon.bat" goto checkPasswords
+if /I "%KARAF_SCRIPT%"=="run.bat" goto checkPasswords
+if /I "%KARAF_SCRIPT%"=="debug.bat" goto checkPasswords
+goto afterPasswordChecks
+
+:checkPasswords
+if not "%UNOMI_ROOT_PASSWORD%"=="" goto afterRootPasswordCheck
+if /I "%UNOMI_SKIP_ROOT_PASSWORD_CHECK%"=="true" goto afterRootPasswordCheck
+echo ERROR: UNOMI_ROOT_PASSWORD is not set.
+echo.
+echo Apache Unomi does not ship a known default admin password.
+echo Set one before starting, for example:
+echo.
+echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password
+echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
+echo bin\karaf.bat
+echo.
+echo Or set org.apache.unomi.security.root.password in etc\custom.system.properties
+echo and set UNOMI_SKIP_ROOT_PASSWORD_CHECK=true.
+exit /b 1
+
+:afterRootPasswordCheck
+if not "%UNOMI_HEALTHCHECK_PASSWORD%"=="" goto afterPasswordChecks
+if /I "%UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK%"=="true" goto afterPasswordChecks
+echo ERROR: UNOMI_HEALTHCHECK_PASSWORD is not set.
+echo.
+echo Apache Unomi does not ship a known default health-check password.
+echo Set one before starting, for example:
+echo.
+echo set UNOMI_ROOT_PASSWORD=choose-a-strong-password
+echo set UNOMI_HEALTHCHECK_PASSWORD=choose-a-strong-health-password
+echo bin\karaf.bat
+echo.
+echo Or set org.apache.unomi.healthcheck.password in etc\custom.system.properties
+echo and set UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true.
+exit /b 1
+
+:afterPasswordChecks
diff --git a/package/src/main/resources/etc/custom.system.properties b/package/src/main/resources/etc/custom.system.properties
index fa98f7979..658d9caba 100644
--- a/package/src/main/resources/etc/custom.system.properties
+++ b/package/src/main/resources/etc/custom.system.properties
@@ -22,7 +22,13 @@ ${optionals}=unomi.custom.system.properties
#######################################################################################################################
## Security settings ##
#######################################################################################################################
-org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD:-karaf}
+# Required: set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start
+# (bin/setenv and the Docker entrypoint refuse to start if either is unset).
+# There are no shipped known defaults.
+# Escape hatches: UNOMI_SKIP_ROOT_PASSWORD_CHECK=true / UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK=true
+# if you set the matching property another way.
+org.apache.unomi.security.root.password=${env:UNOMI_ROOT_PASSWORD}
+org.apache.unomi.healthcheck.password=${env:UNOMI_HEALTHCHECK_PASSWORD}
# These parameters control the list of classes that are allowed or forbidden when executing expressions.
org.apache.unomi.scripting.allow=${env:UNOMI_ALLOW_SCRIPTING_CLASSES:-org.apache.unomi.api.Event,org.apache.unomi.api.Profile,org.apache.unomi.api.Session,org.apache.unomi.api.Item,org.apache.unomi.api.CustomItem,java.lang.Object,java.util.Map,java.util.HashMap,java.lang.Integer,org.mvel2.*,java.lang.String}
diff --git a/package/src/main/resources/etc/users.properties b/package/src/main/resources/etc/users.properties
index bffdc5bf3..c7064f536 100644
--- a/package/src/main/resources/etc/users.properties
+++ b/package/src/main/resources/etc/users.properties
@@ -29,6 +29,8 @@
# and modifiable via the JAAS command group. These users reside in a JAAS domain
# with the name "karaf".
#
-karaf = ${org.apache.unomi.security.root.password:-karaf},_g_:admingroup
-health = ${org.apache.unomi.healthcheck.password:-health},health
+# Passwords come from org.apache.unomi.security.root.password / healthcheck.password (see custom.system.properties).
+# No known defaults are shipped; set UNOMI_ROOT_PASSWORD and UNOMI_HEALTHCHECK_PASSWORD before start.
+karaf = ${org.apache.unomi.security.root.password},_g_:admingroup
+health = ${org.apache.unomi.healthcheck.password},health
_g_\:admingroup = group,admin,manager,viewer,systembundles,ssh,ROLE_UNOMI_ADMIN,ROLE_UNOMI_TENANT_ADMIN,ROLE_UNOMI_TENANT_USER
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 a29b39f30..2be429a2e 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,6 +26,7 @@ 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;
@@ -91,6 +92,13 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor {
// Check if the user switched to another profile
if (StringUtils.isNotEmpty(currentProfileMergeValue) && !currentProfileMergeValue.equals(mergePropValue)) {
+ if (!isTrustedIdentityCaller()) {
+ LOGGER.warn("Refusing profile merge switch for untrusted caller (mergeProp={})", mergePropName);
+ if (tracer != null) {
+ tracer.endOperation(false, "Untrusted caller cannot switch merge identity");
+ }
+ return EventService.NO_CHANGE;
+ }
if (tracer != null) {
tracer.trace("Profile switch detected", Map.of(
"fromValue", currentProfileMergeValue,
@@ -119,6 +127,18 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor {
return profileUpdated ? EventService.PROFILE_UPDATED : EventService.NO_CHANGE;
}
+ // Merging into another existing profile rebinds the session — require a trusted caller
+ // (system admin or tenant private key), 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);
@@ -327,6 +347,15 @@ public class MergeProfilesOnPropertyAction implements ActionExecutor {
}
}
+ /**
+ * Identity merge that rebinds sessions requires a trusted caller (JAAS admin or tenant private key),
+ * not a public API key / unauthenticated context event.
+ */
+ private boolean isTrustedIdentityCaller() {
+ return securityService != null
+ && (securityService.isAdmin() || securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR));
+ }
+
public void setProfileService(ProfileService profileService) {
this.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 af9a2a091..63473db75 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
@@ -24,6 +24,8 @@ import org.apache.unomi.api.Profile;
import org.apache.unomi.api.PropertyType;
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.security.UnomiRoles;
import org.apache.unomi.api.services.EventService;
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.persistence.spi.PropertyHelper;
@@ -51,6 +53,7 @@ public class UpdatePropertiesAction implements ActionExecutor {
private ProfileService profileService;
private EventService eventService;
private TracerService tracerService;
+ private SecurityService securityService;
public int execute(Action action, Event event) {
RequestTracer tracer = null;
@@ -74,6 +77,13 @@ public class UpdatePropertiesAction implements ActionExecutor {
}
if (StringUtils.isNotBlank(targetId) && event.getProfile() != null && !targetId.equals(event.getProfile().getItemId())) {
+ if (!isTrustedIdentityCaller()) {
+ LOGGER.warn("Refusing cross-profile property update for untrusted caller (targetId={})", targetId);
+ if (tracer != null) {
+ tracer.endOperation(false, "Untrusted caller cannot update another profile");
+ }
+ return EventService.NO_CHANGE;
+ }
target = TARGET_TYPE_PROFILE.equals(targetType) ? profileService.load(targetId) : profileService.loadPersona(targetId);
if (target == null) {
if (tracer != null) {
@@ -105,6 +115,10 @@ public class UpdatePropertiesAction implements ActionExecutor {
List<String> propsToDelete = (List<String>) event.getProperties().get(PROPS_TO_DELETE);
if (propsToDelete != null) {
for (String prop : propsToDelete) {
+ if (!isTrustedIdentityCaller() && prop.startsWith("systemProperties.")) {
+ LOGGER.warn("Refusing systemProperties delete for untrusted caller: {}", prop);
+ continue;
+ }
isProfileOrPersonaUpdated |= PropertyHelper.setProperty(target, prop, null, "remove");
}
}
@@ -143,6 +157,10 @@ public class UpdatePropertiesAction implements ActionExecutor {
private boolean processProperties(Profile target, Map<String, Object> propsMap, String strategy) {
boolean isProfileOrPersonaUpdated = false;
for (String prop : propsMap.keySet()) {
+ if (!isTrustedIdentityCaller() && prop.startsWith("systemProperties.")) {
+ LOGGER.warn("Refusing systemProperties update for untrusted caller: {}", prop);
+ continue;
+ }
PropertyType propType = null;
if (prop.startsWith("properties.") || prop.startsWith("systemProperties.")) {
propType = profileService.getPropertyType(prop.substring(prop.indexOf('.') + 1));
@@ -162,6 +180,11 @@ public class UpdatePropertiesAction implements ActionExecutor {
return isProfileOrPersonaUpdated;
}
+ private boolean isTrustedIdentityCaller() {
+ return securityService != null
+ && (securityService.isAdmin() || securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR));
+ }
+
public void setProfileService(ProfileService profileService) {
this.profileService = profileService;
}
@@ -174,4 +197,8 @@ public class UpdatePropertiesAction implements ActionExecutor {
this.tracerService = tracerService;
}
+ public void setSecurityService(SecurityService securityService) {
+ this.securityService = securityService;
+ }
+
}
diff --git a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
index 2b10af39a..c317dc34d 100644
--- a/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
+++ b/plugins/baseplugin/src/main/resources/OSGI-INF/blueprint/blueprint.xml
@@ -181,6 +181,7 @@
<property name="profileService" ref="profileService"/>
<property name="eventService" ref="eventService"/>
<property name="tracerService" ref="tracerService"/>
+ <property name="securityService" ref="securityService"/>
</bean>
</service>
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
new file mode 100644
index 000000000..5f6084d03
--- /dev/null
+++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
@@ -0,0 +1,137 @@
+/*
+ * 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.security.UnomiRoles;
+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.Collections;
+import java.util.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotEquals;
+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;
+
+/**
+ * Regression: public/untrusted events must not rebind a session onto another profile via merge.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class MergeProfilesOnPropertyActionTest {
+
+ @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 MergeProfilesOnPropertyAction actionExecutor;
+
+ @Before
+ public void setUp() {
+ actionExecutor = new MergeProfilesOnPropertyAction();
+ actionExecutor.setProfileService(profileService);
+ actionExecutor.setPersistenceService(persistenceService);
+ actionExecutor.setEventService(eventService);
+ actionExecutor.setDefinitionsService(definitionsService);
+ actionExecutor.setPrivacyService(privacyService);
+ actionExecutor.bindSecurityService(securityService);
+ actionExecutor.setMaxProfilesInOneMerge("50");
+
+ when(definitionsService.getConditionType("profilePropertyCondition")).thenReturn(new ConditionType());
+ when(securityService.isAdmin()).thenReturn(false);
+ when(securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(false);
+ }
+
+ @Test
+ public void untrustedCaller_cannotMergeIntoExistingVictimProfile() {
+ Profile attacker = new Profile("attacker");
+ Profile victim = new Profile("victim");
+ victim.getSystemProperties().put("mergeIdentifier", "victim@example.com");
+
+ when(persistenceService.query(any(), anyString(), eq(Profile.class), anyInt(), anyInt()))
+ .thenReturn(new PartialList<>(new ArrayList<>(Collections.singletonList(victim)), 0, 1, 1, PartialList.Relation.EQUAL));
+
+ Event event = new Event("login", null, attacker, "systemscope", null, null, null, new Date(), true);
+ Action action = mergeAction("victim@example.com");
+
+ int changes = actionExecutor.execute(action, event);
+
+ // May write mergeIdentifier onto the attacker profile, but must not rebind to victim
+ assertNotEquals(EventService.PROFILE_UPDATED + EventService.SESSION_UPDATED, changes);
+ assertEquals("attacker", event.getProfile().getItemId());
+ verify(profileService, never()).mergeProfiles(any(), any());
+ }
+
+ @Test
+ public void trustedTenantAdmin_canMergeIntoExistingProfile() {
+ when(securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(true);
+
+ Profile caller = new Profile("caller");
+ Profile victim = new Profile("victim");
+ victim.setProperty("firstVisit", new Date(0));
+ caller.setProperty("firstVisit", new Date());
+
+ when(persistenceService.query(any(), anyString(), eq(Profile.class), anyInt(), anyInt()))
+ .thenReturn(new PartialList<>(new ArrayList<>(Collections.singletonList(victim)), 0, 1, 1, PartialList.Relation.EQUAL));
+ when(profileService.mergeProfiles(eq(victim), any())).thenReturn(victim);
+ when(privacyService.isRequireAnonymousBrowsing(any(Profile.class))).thenReturn(false);
+ when(privacyService.isRequireAnonymousBrowsing("victim")).thenReturn(false);
+
+ Event event = new Event("login", null, caller, "systemscope", null, null, null, new Date(), true);
+ Action action = mergeAction("victim@example.com");
+
+ int changes = actionExecutor.execute(action, event);
+
+ assertEquals(EventService.PROFILE_UPDATED + EventService.SESSION_UPDATED, changes);
+ assertEquals("victim", event.getProfile().getItemId());
+ verify(profileService).mergeProfiles(eq(victim), any());
+ }
+
+ private static Action mergeAction(String mergeValue) {
+ Action action = new Action();
+ Map<String, Object> params = new HashMap<>();
+ params.put("mergeProfilePropertyName", "mergeIdentifier");
+ params.put("mergeProfilePropertyValue", mergeValue);
+ action.setParameterValues(params);
+ return action;
+ }
+}
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
new file mode 100644
index 000000000..f4927e3fb
--- /dev/null
+++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
@@ -0,0 +1,123 @@
+/*
+ * 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.Profile;
+import org.apache.unomi.api.actions.Action;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.security.UnomiRoles;
+import org.apache.unomi.api.services.EventService;
+import org.apache.unomi.api.services.ProfileService;
+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.Date;
+import java.util.HashMap;
+import java.util.Map;
+
+import static org.junit.Assert.assertEquals;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * Regression: untrusted events must not update another profile or systemProperties.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class UpdatePropertiesActionTest {
+
+ @Mock private ProfileService profileService;
+ @Mock private EventService eventService;
+ @Mock private SecurityService securityService;
+
+ private UpdatePropertiesAction actionExecutor;
+
+ @Before
+ public void setUp() {
+ actionExecutor = new UpdatePropertiesAction();
+ actionExecutor.setProfileService(profileService);
+ actionExecutor.setEventService(eventService);
+ actionExecutor.setSecurityService(securityService);
+
+ when(securityService.isAdmin()).thenReturn(false);
+ when(securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).thenReturn(false);
+ }
+
+ @Test
+ public void untrustedCaller_cannotUpdateAnotherProfile() {
+ Profile caller = new Profile("caller");
+ Map<String, Object> updateMap = new HashMap<>();
+ updateMap.put("properties.email", "pwned");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "victim");
+ eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, UpdatePropertiesAction.TARGET_TYPE_PROFILE);
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+ Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true);
+ Action action = new Action();
+
+ int changes = actionExecutor.execute(action, event);
+
+ assertEquals(EventService.NO_CHANGE, changes);
+ verify(profileService, never()).load(any(String.class));
+ verify(profileService, never()).save(any(Profile.class));
+ }
+
+ @Test
+ public void untrustedCaller_cannotWriteSystemProperties() {
+ Profile caller = new Profile("caller");
+ Map<String, Object> updateMap = new HashMap<>();
+ updateMap.put("systemProperties.mergeIdentifier", "stolen");
+ 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 trustedAdmin_canUpdateAnotherProfile() {
+ when(securityService.isAdmin()).thenReturn(true);
+
+ Profile caller = new Profile("caller");
+ Profile victim = new Profile("victim");
+ when(profileService.load("victim")).thenReturn(victim);
+
+ Map<String, Object> updateMap = new HashMap<>();
+ updateMap.put("properties.email", "admin-set");
+ Map<String, Object> eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.TARGET_ID_KEY, "victim");
+ eventProps.put(UpdatePropertiesAction.TARGET_TYPE_KEY, UpdatePropertiesAction.TARGET_TYPE_PROFILE);
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_UPDATE, updateMap);
+
+ Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true);
+
+ actionExecutor.execute(new Action(), event);
+
+ verify(profileService).load("victim");
+ verify(profileService).save(victim);
+ }
+}
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 5a17da207..599c41d4c 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,7 @@ import org.apache.commons.lang3.StringUtils;
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.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ConfigSharingService;
@@ -92,6 +93,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 +136,35 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
}
}
- if (profileId == null) {
- // Get profile id from the cookie
- profileId = getProfileIdCookieValue(request);
+ final String requestedBodyProfileId = profileId;
+ final String cookieProfileIdAtRequest = getProfileIdCookieValue(request);
+ // When a public caller presents a foreign sessionId, we must not overwrite that session.
+ String effectiveSessionId = sessionId;
+
+ if (!isTrustedProfileCaller()) {
+ // Public callers: the cookie is the only profile bearer. Ignore body profileId entirely
+ // (including when no cookie is present — otherwise knowing a UUID is enough to load it).
+ if (requestedBodyProfileId != null && cookieProfileIdAtRequest != null
+ && !requestedBodyProfileId.equals(cookieProfileIdAtRequest)) {
+ LOGGER.debug("Ignoring body profileId {} that does not match cookie profileId {}",
+ requestedBodyProfileId, cookieProfileIdAtRequest);
+ } else if (requestedBodyProfileId != null && cookieProfileIdAtRequest == null) {
+ LOGGER.debug("Ignoring body profileId {} for public caller without cookie bearer",
+ requestedBodyProfileId);
+ }
+ profileId = cookieProfileIdAtRequest;
+ } else if (profileId == null) {
+ profileId = cookieProfileIdAtRequest;
}
+ // else trusted caller keeps explicit body profileId (may differ from cookie)
- if (profileId == null && sessionId == null && personaId == null) {
+ // Trusted callers may intentionally bind to a body profileId that differs from the cookie.
+ final boolean trustedExplicitProfileOverride = isTrustedProfileCaller()
+ && requestedBodyProfileId != null
+ && cookieProfileIdAtRequest != null
+ && !requestedBodyProfileId.equals(cookieProfileIdAtRequest);
+
+ 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 +188,9 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
// 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 +198,59 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
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.
+ String cookieProfileId = cookieProfileIdAtRequest != null
+ ? cookieProfileIdAtRequest
+ : getProfileIdCookieValue(request);
+ boolean cookieOwnsSession = cookieProfileId != null && cookieProfileId.equals(sessionProfile.getItemId());
+ if (!trustedExplicitProfileOverride && (cookieOwnsSession || isTrustedProfileCaller())) {
+ 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", sessionProfile.getItemId(), effectiveSessionId);
+ eventsRequestContext.setProfile(createNewProfile(sessionProfile.getItemId(), timestamp));
+ }
+ } else if (trustedExplicitProfileOverride) {
+ LOGGER.debug("Keeping trusted body profileId {} despite session/cookie mismatch",
+ eventsRequestContext.getProfile().getItemId());
} 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.debug("Refusing to switch profile from {} to session profile {} without matching cookie bearer",
+ eventsRequestContext.getProfile().getItemId(), sessionProfile.getItemId());
+ // Detach so we neither adopt the foreign profile nor rebind the foreign session
+ eventsRequestContext.setSession(null);
+ 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())) {
- eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
- }
+ // 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);
- } else {
- LOGGER.warn("Null profile in event request context");
+ 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())) {
+ eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
+ }
+ eventsRequestContext.getSession().setProfile(sessionProfile);
+ } else {
+ LOGGER.warn("Null profile in event request context");
+ }
}
}
}
@@ -217,10 +261,10 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
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 +444,14 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
return profile;
}
+ /**
+ * System or tenant administrators may override profile/session binding; public callers may not.
+ */
+ private boolean isTrustedProfileCaller() {
+ return securityService != null
+ && (securityService.isAdmin() || securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR));
+ }
+
/**
* 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/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
new file mode 100644
index 000000000..0054c78fc
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
@@ -0,0 +1,124 @@
+/*
+ * 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.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+/**
+ * Ensures the distribution no longer ships a known working default admin/health password.
+ */
+class ShippedAdminPasswordConfigTest {
+
+ @Test
+ void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception {
+ Path users = packageEtc("users.properties");
+ String content = Files.readString(users);
+
+ assertFalse(content.contains(":-karaf"), "users.properties must not default the karaf password to 'karaf'");
+ assertFalse(content.contains(":-health"), "users.properties must not default the health password to 'health'");
+ assertTrue(content.contains("${org.apache.unomi.security.root.password}"));
+ assertTrue(content.contains("${org.apache.unomi.healthcheck.password}"));
+ }
+
+ @Test
+ void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() throws Exception {
+ Path props = packageEtc("custom.system.properties");
+ List<String> securityLines = Files.readAllLines(props).stream()
+ .filter(line -> line.contains("org.apache.unomi.security.root.password")
+ || line.contains("org.apache.unomi.healthcheck.password"))
+ .collect(Collectors.toList());
+
+ assertFalse(securityLines.isEmpty());
+ for (String line : securityLines) {
+ assertFalse(line.contains(":-karaf"), "root password must not fall back to karaf: " + line);
+ assertFalse(line.contains(":-health"), "health password must not fall back to health: " + line);
+ }
+ }
+
+ @Test
+ void profileCookieHttpOnly_defaultsToTrue() throws Exception {
+ Path webCfg = Paths.get("..", "web-servlets", "src", "main", "resources", "org.apache.unomi.web.cfg")
+ .toAbsolutePath().normalize();
+ String content = Files.readString(webCfg);
+ assertTrue(content.contains(":-true") || content.contains("HttpOnly:-true")
+ || content.matches("(?s).*profileIdCookieHttpOnly=\\$\\{[^}]*:-true\\}.*"),
+ "profileId cookie HttpOnly should default to true");
+ }
+
+ @Test
+ void setenv_failsFastWhenRootOrHealthPasswordMissing() throws Exception {
+ Path setenv = Paths.get("..", "package", "src", "main", "resources", "bin", "setenv")
+ .toAbsolutePath().normalize();
+ String content = Files.readString(setenv);
+ assertTrue(content.contains("UNOMI_ROOT_PASSWORD"),
+ "setenv must reference UNOMI_ROOT_PASSWORD");
+ assertTrue(content.contains("UNOMI_HEALTHCHECK_PASSWORD"),
+ "setenv must reference UNOMI_HEALTHCHECK_PASSWORD");
+ assertTrue(content.contains("UNOMI_SKIP_ROOT_PASSWORD_CHECK"),
+ "setenv must document/escape-hatch UNOMI_SKIP_ROOT_PASSWORD_CHECK");
+ assertTrue(content.contains("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK"),
+ "setenv must document/escape-hatch UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK");
+ assertTrue(content.contains("does not ship a known default"),
+ "setenv error message should explain that no known default is shipped");
+ assertTrue(content.contains("exit 1"),
+ "setenv must exit when a password is missing");
+ }
+
+ @Test
+ void entrypoint_failsFastWhenRootOrHealthPasswordMissing() throws Exception {
+ Path entrypoint = Paths.get("..", "docker", "src", "main", "docker", "entrypoint.sh")
+ .toAbsolutePath().normalize();
+ String content = Files.readString(entrypoint);
+ assertTrue(content.contains("UNOMI_ROOT_PASSWORD"),
+ "entrypoint must reference UNOMI_ROOT_PASSWORD");
+ assertTrue(content.contains("UNOMI_HEALTHCHECK_PASSWORD"),
+ "entrypoint must reference UNOMI_HEALTHCHECK_PASSWORD");
+ assertTrue(content.contains("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK"),
+ "entrypoint must document/escape-hatch UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK");
+ assertTrue(content.contains("exit 1"),
+ "entrypoint must exit when a password is missing");
+ }
+
+ @Test
+ void setenvBat_failsFastWhenRootOrHealthPasswordMissing() throws Exception {
+ Path setenvBat = Paths.get("..", "package", "src", "main", "resources", "bin", "setenv.bat")
+ .toAbsolutePath().normalize();
+ String content = Files.readString(setenvBat);
+ assertTrue(content.contains("UNOMI_ROOT_PASSWORD"),
+ "setenv.bat must reference UNOMI_ROOT_PASSWORD");
+ assertTrue(content.contains("UNOMI_HEALTHCHECK_PASSWORD"),
+ "setenv.bat must reference UNOMI_HEALTHCHECK_PASSWORD");
+ assertTrue(content.contains("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK"),
+ "setenv.bat must document/escape-hatch UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK");
+ assertTrue(content.contains("exit /b 1"),
+ "setenv.bat must exit when a password is missing");
+ }
+
+ private static Path packageEtc(String fileName) {
+ return Paths.get("..", "package", "src", "main", "resources", "etc", fileName)
+ .toAbsolutePath().normalize();
+ }
+}
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 000000000..77ddce9fc
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImplProfileBindingTest.java
@@ -0,0 +1,213 @@
+/*
+ * 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.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.Date;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+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.isAdmin()).thenReturn(false);
+ lenient().when(securityService.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).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, "attacker-supplied-profile", null,
+ false, false, request, response, new Date());
+
+ assertEquals("cookie-profile", ctx.getProfile().getItemId());
+ verify(profileService).load("cookie-profile");
+ verify(profileService, never()).load("attacker-supplied-profile");
+ }
+
+ @Test
+ void initEventsRequest_ignoresBodyProfileIdWithoutCookieForPublicCaller() {
+ when(request.getCookies()).thenReturn(null);
+
+ try {
+ restServiceUtils.initEventsRequest(
+ "systemscope", null, "victim-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("victim-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.isAdmin()).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.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).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.hasRole(UnomiRoles.TENANT_ADMINISTRATOR)).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());
+ }
+
+ 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 f5d1a145d..8932eac7d 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 eb488515d..ad620bc11 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.