Under FMCS, a non-superuser with users.create (or users.edit on a target user) could submit a company_id or company_ids[] referencing a company outside their permitted scope via the REST API. UsersController::store() and ::update() filled and saved the user record before filtering the requested companies against the actor's permitted set, so the account row landed on the DB before authorization was checked. On installs with null_company_is_floater=1 , the resulting user was persisted as a floater (empty pivot after the post-hoc filter) with cross-company visibility.
CVSS:3.1/AV:N/AC:L/PR:L/UI:N/S:C/C:N/I:H/A:N
Integrity is High because the attacker persists an unauthorized user in the tenant boundary, which is a durable state change and (under floater mode) grants broad cross-company visibility to the newly-minted account. Scope changes to C because the impact crosses the tenant boundary the actor was originally authorized within.
CWE-863 (Incorrect Authorization)
CWE-267 (Privilege Defined With Unsafe Actions) as a secondary framing
fill($request->all()) and $user->save() before the company filter.
The user record is committed to the DB with the fill result.
After save, syncCompaniesWithLogging(Company::getIdsForCurrentUser(...)) filters the requested ids. Foreign ids are dropped silently.
On installs with null_company_is_floater=1 , the resulting empty pivot mirrors the scalar users.company_id to NULL. The user becomes a floater with cross-company visibility.
update() had the same shape: fill and save first, then filter. An actor could relocate an existing user by patching company_ids to include a foreign company alongside their own.
Resolve requested company ids from the request up front, BEFORE any DB write.
Filter through Company::getIdsForCurrentUser() .
If the requested list has any id that fails the filter (and the actor is not a superuser, whose filter is a no-op), reject the whole request with HTTP 403 and admin/users/message.error.company_not_permitted .
On the store path, wrap the save + groups sync + company sync in a DB::transaction() so an exception in any post-save step rolls back the whole create.
On the update path, add the same up-front filter and reject.
836f37f854bca497711b7b3af05cb9120ee73806
tests/Feature/Users/Api/UserCompanyMembershipTest.php :
test_store_denies_mixed_cross_tenant_company_ids_from_non_superuser covers the mixed case that the pre-existing SaveUserRequest validator does not catch.
test_update_denies_mixed_cross_tenant_company_ids_from_non_superuser covers the same case on the update path.
test_store_scalar_cross_tenant_company_id_rejected_by_validation documents that the scalar single-company case is caught earlier by the SaveUserRequest floater-grant gate, so future refactors do not remove either layer thinking the other is sufficient.
test_store_allows_non_superuser_to_assign_own_company proves the fix does not break legitimate same-tenant creation.
Christopher Finks - Constraint Layer
The full story
This article is one source in a clustered incident — the cluster page carries the summary, timeline and every other outlet covering it.
