Summary
The PUT /api/v1/organizations/{organization}/time-entries/{timeEntry} API accepts a route-bound timeEntry from another organization when the caller has time-entries:update:all in the URL organization, allowing a known foreign time-entry UUID to be modified and rebound to objects in the caller's organization.
Details
TimeEntryController::checkPermission() only verifies time_entries.organization_id when the bound TimeEntry instance is passed into the helper. In the single-record update() path, the controller branches on time-entries:update:own vs time-entries:update:all but never passes the route-bound $timeEntry into that ownership check. By contrast, destroy() passes $timeEntry, and updateMultiple() first scopes the query to whereBelongsTo($organization, 'organization').
TimeEntryUpdateRequest validates the submitted member_id, project_id, task_id, and tags against the organization from the URL, but it does not validate that the route-bound timeEntry itself belongs to that organization. The update path then resolves project_id and task_id, calls fill($request->validated()), and saves the row. As a result, if the attacker knows a valid foreign time_entries.id, the following state change is accepted:
request URL organization: orgA
route-bound timeEntry row: orgB.time_entries[id = victim_uuid]
submitted project/task/tag/member: orgA-owned objects
result: orgB row is updated with orgA object references
This is a cross-tenant integrity issue rather than a purely local authorization smell. The application globally disables mass-assignment protection, the database only enforces single-column foreign keys, and the existing self-host consistency command does not check whether a time entry's organization_id matches its project, task, client, or member organization. After the save, the controller dispatches recalculation jobs for the old and new project/task, and those model relations aggregate time_entries by project_id or task_id without an additional organization filter. I also confirmed that report aggregation resolves project/task/client descriptors by ID without organization scoping, so polluted rows can affect project/task totals and grouped report labels. I did not identify a built-in low-privilege endpoint in this codebase that discloses foreign time_entries.id values, so the confirmed exploit boundary is limited to cases where that UUID is already known from some other source. Until a fix exists, the update path needs the same organization ownership check already present in destroy() or equivalent scoped binding enforcement.
PoC
- Create or use an authenticated account that has
time-entries:update:all in organization orgA.
- Obtain any valid
time_entries.id from another organization orgB. In this review I confirmed exploitation only for the known-ID case; I did not identify a built-in low-privilege disclosure path for foreign IDs.
- Pick any
project_id and optional task_id that belong to orgA, then send:
PUT /api/v1/organizations/{orgA}/time-entries/{victim_uuid}
Content-Type: application/json
{
"project_id": "{orgA_project_uuid}",
"task_id": "{orgA_task_uuid}"
}
- Observe a successful update response instead of a forbidden response, and confirm the row state changes even though the row still belongs to
orgB:
before: time_entries.id = victim_uuid, organization_id = orgB, project_id = old_orgB_project
after: time_entries.id = victim_uuid, organization_id = orgB, project_id = orgA_project
- Wait for or run the recalculation jobs. The new
orgA project/task totals now include the foreign row, and grouped report output can resolve descriptor names from the orgA objects attached to that orgB row.
Impact
A caller with elevated time-entry update rights in one organization can modify a known time-entry row from another organization and persist cross-tenant reference corruption. The confirmed impact is unauthorized integrity modification of another tenant's records plus downstream pollution of project/task totals and report group labels. I did not confirm a built-in path in this codebase to enumerate arbitrary foreign time-entry UUIDs, so broader exploitation depends on how such identifiers are exposed in the deployment.
Summary
The
PUT /api/v1/organizations/{organization}/time-entries/{timeEntry}API accepts a route-boundtimeEntryfrom another organization when the caller hastime-entries:update:allin the URL organization, allowing a known foreign time-entry UUID to be modified and rebound to objects in the caller's organization.Details
TimeEntryController::checkPermission()only verifiestime_entries.organization_idwhen the boundTimeEntryinstance is passed into the helper. In the single-recordupdate()path, the controller branches ontime-entries:update:ownvstime-entries:update:allbut never passes the route-bound$timeEntryinto that ownership check. By contrast,destroy()passes$timeEntry, andupdateMultiple()first scopes the query towhereBelongsTo($organization, 'organization').TimeEntryUpdateRequestvalidates the submittedmember_id,project_id,task_id, andtagsagainst the organization from the URL, but it does not validate that the route-boundtimeEntryitself belongs to that organization. The update path then resolvesproject_idandtask_id, callsfill($request->validated()), and saves the row. As a result, if the attacker knows a valid foreigntime_entries.id, the following state change is accepted:This is a cross-tenant integrity issue rather than a purely local authorization smell. The application globally disables mass-assignment protection, the database only enforces single-column foreign keys, and the existing self-host consistency command does not check whether a time entry's
organization_idmatches itsproject,task,client, ormemberorganization. After the save, the controller dispatches recalculation jobs for the old and new project/task, and those model relations aggregatetime_entriesbyproject_idortask_idwithout an additional organization filter. I also confirmed that report aggregation resolves project/task/client descriptors by ID without organization scoping, so polluted rows can affect project/task totals and grouped report labels. I did not identify a built-in low-privilege endpoint in this codebase that discloses foreigntime_entries.idvalues, so the confirmed exploit boundary is limited to cases where that UUID is already known from some other source. Until a fix exists, the update path needs the same organization ownership check already present indestroy()or equivalent scoped binding enforcement.PoC
time-entries:update:allin organizationorgA.time_entries.idfrom another organizationorgB. In this review I confirmed exploitation only for the known-ID case; I did not identify a built-in low-privilege disclosure path for foreign IDs.project_idand optionaltask_idthat belong toorgA, then send:orgB:orgAproject/task totals now include the foreign row, and grouped report output can resolve descriptor names from theorgAobjects attached to thatorgBrow.Impact
A caller with elevated time-entry update rights in one organization can modify a known time-entry row from another organization and persist cross-tenant reference corruption. The confirmed impact is unauthorized integrity modification of another tenant's records plus downstream pollution of project/task totals and report group labels. I did not confirm a built-in path in this codebase to enumerate arbitrary foreign time-entry UUIDs, so broader exploitation depends on how such identifiers are exposed in the deployment.