{"id":"GHSA-vrr2-g9gh-c3jc","summary":"Kimai: Timesheet PATCH/POST allows assigning to project outside user's team via query_builder OR-bypass","details":"## Summary\n\nThe Timesheet API `PATCH /api/timesheets/{id}` and `POST /api/timesheets` endpoints accept a user-supplied `project` ID and resolve it through a Symfony `EntityType` whose `query_builder` allows the submitted ID to satisfy the access predicate via an unconditional OR branch. As a result, any authenticated user can re-assign their own timesheet to any project in the database — including projects that belong to teams or customers they have no membership in and cannot otherwise see. The user can then read serialized project/customer details via `GET /api/timesheets/{id}?full=true`, leaking metadata (name, currency, customer hierarchy) that would otherwise be filtered out by the team ACL.\n\n## Details\n\n### Entry point — only ownership is checked in `src/API/TimesheetController.php:317-355`\n\n```php\n#[IsGranted('edit', 'timesheet')]\n#[Route(methods: ['PATCH'], path: '/{id}', name: 'patch_timesheet', requirements: ['id' =\u003e '\\d+'])]\npublic function patchAction(Request $request, Timesheet $timesheet): Response\n{\n    ...\n    $form = $this-\u003ecreateForm(TimesheetApiEditForm::class, $timesheet, [...]);\n    $form-\u003esetData($timesheet);\n    $form-\u003esubmit($request-\u003erequest-\u003eall(), false);\n    if (false === $form-\u003eisValid()) { ... }\n    $this-\u003eservice-\u003esaveTimesheet($timesheet);\n    ...\n}\n```\n\n`src/Voter/TimesheetVoter.php:134-142`:\n\n```php\nif ($subject-\u003egetUser()?-\u003egetId() === $user-\u003egetId()) {\n    return $this-\u003epermissionManager-\u003ehasRolePermission($user, $permission . '_own_timesheet');\n}\n\nif (!$this-\u003epermissionManager-\u003echeckTeamAccessTimesheet($subject, $user)) {\n    return false;\n}\n```\n\nFor an own-timesheet, only `edit_own_timesheet` is required. The voter does **not** look at the *new* project being submitted; it only validates the existing record's ownership.\n\n### Form replays user-controlled project ID into the access query\n\n`src/Form/TimesheetEditForm.php:60-71`:\n\n```php\n$isNew = true;\nif (isset($options['data']) && $options['data'] instanceof Timesheet) {\n    ...\n    if (null !== $entry-\u003egetId()) {\n        $isNew = false;\n    }\n    ...\n}\n$this-\u003eaddProject($builder, $isNew, $project, $customer);\n```\n\n`src/Form/FormTrait.php:59-100`:\n\n```php\n$builder-\u003eaddEventListener(\n    FormEvents::PRE_SUBMIT,\n    function (FormEvent $event) use ($builder, $project, $customer, $isNew, $options): void {\n        $data = $event-\u003egetData();\n        $customer = \\array_key_exists('customer', $data) && $data['customer'] !== '' ? $data['customer'] : null;\n        $project = \\array_key_exists('project', $data) && $data['project'] !== '' ? $data['project'] : $project;\n\n        $event-\u003egetForm()-\u003eadd('project', ProjectType::class, array_merge($options, [\n            'group_by' =\u003e null,\n            'query_builder' =\u003e function (ProjectRepository $repo) use ($builder, $project, $customer, $isNew) {\n                $project = \\is_string($project) ? (int) $project : $project;\n                ...\n                if ($isNew && \\is_int($project)) {\n                    $project = $repo-\u003efind($project);\n                    if ($project !== null) {\n                        if (!$project-\u003egetCustomer()-\u003eisVisible()) { ... $project = null; }\n                        elseif (!$project-\u003eisVisible())            { $project = null; }\n                    }\n                }\n                ...\n                $query = new ProjectFormTypeQuery($project, $customer);\n                $query-\u003esetUser($builder-\u003egetOption('user'));\n                $query-\u003esetWithCustomer(true);\n                return $repo-\u003egetQueryBuilderForFormType($query);\n            },\n        ]));\n    }\n);\n```\n\nTwo problems compound:\n\n1. The visibility re-check on line 73 is gated on `$isNew`. For PATCH, `$isNew = false`, so the closure passes the attacker-supplied ID straight through.\n2. Even when `$isNew = true` (POST), the re-check only validates `isVisible()` — it does not validate team membership.\n\n### The query-builder unconditionally accepts the submitted ID\n\n`src/Repository/ProjectRepository.php:150-208`:\n\n```php\npublic function getQueryBuilderForFormType(ProjectFormTypeQuery $query): QueryBuilder\n{\n    ...\n    $mainQuery = $qb-\u003eexpr()-\u003eandX();\n    $mainQuery-\u003eadd($qb-\u003eexpr()-\u003eeq('p.visible', ':visible'));\n    $mainQuery-\u003eadd($qb-\u003eexpr()-\u003eeq('c.visible', ':customer_visible'));\n    if (!$query-\u003eisIgnoreDate()) { ... }\n    if ($query-\u003ehasCustomers()) { ... }\n\n    $permissions = $this-\u003egetPermissionCriteria($qb, $query-\u003egetUser(), $query-\u003egetTeams());\n    if ($permissions-\u003ecount() \u003e 0) {\n        $mainQuery-\u003eadd($permissions);\n    }\n\n    $outerQuery = $qb-\u003eexpr()-\u003eorX();\n    if ($query-\u003ehasProjects()) {\n        $outerQuery-\u003eadd($qb-\u003eexpr()-\u003ein('p.id', ':project'));     // \u003c-- unconditional\n        $qb-\u003esetParameter('project', $query-\u003egetProjects());\n    }\n    ...\n    $outerQuery-\u003eadd($mainQuery);\n    $qb-\u003eandWhere($outerQuery);\n    return $qb;\n}\n```\n\nThe final WHERE clause is roughly:\n\n```\nWHERE (p.id IN (:project)) OR (p.visible AND c.visible AND \u003cdate\u003e AND \u003cteam-ACL\u003e)\n```\n\nBecause `:project` is the submitted ID itself, the first branch matches unconditionally, completely bypassing the team-ACL applied by `getPermissionCriteria`. Symfony's `EntityType` happily resolves the foreign `Project` entity, the form passes validation, and the timesheet is persisted with the new `project_id`.\n\n### No downstream validation closes the gap\n\n- `TimesheetService::saveTimesheet` → `updateTimesheet` (`src/Timesheet/TimesheetService.php:154-177`) is explicitly documented as *not* validating.\n- `TimesheetBasicValidator` only validates begin/end and project/activity coherence.\n- `TimesheetDeactivatedValidator::validateActivityAndProject` (`src/Validator/Constraints/TimesheetDeactivatedValidator.php:36-42`) returns early for non-running existing timesheets.\n- No validator anywhere in the timesheet pipeline checks that the project's team membership intersects the acting user's teams.\n\n*A PoC was provided, but removed for security reasons.*\n\n## Impact\n\n- **Integrity:** any authenticated user can attribute their own tracked time to any project ID in the database — including projects belonging to teams/customers they cannot see. This pollutes per-project budgets, billing exports and reports for other teams. There is no in-app warning that records belonging to outsiders have been added.\n- **Confidentiality:** by reading the timesheet back via `?full=true`, the attacker obtains serialized project and customer details (name, currency, start/end dates, customer hierarchy) which would normally be filtered by the team ACL.\n- **Privilege model:** the `edit_own_timesheet` permission is part of the default ROLE_USER, so the bypass is reachable by every regular user without any administrator action.\n\nThe blast radius is bounded by what an attacker can persist (their own timesheet rows) and what the `?full=true` serializer exposes — there is no direct ability to modify other teams' existing data.\n\n## Solution\n\n- The FormTrait was updated to only pass the project forward for new timesheets\n- A new `TimesheetTeamAccessValidator`was added, which checks if `project` or `activity` were changed. If that is the case, the team access permission is checked first\n\nFind out more at [https://www.kimai.org/en/security/ghsa-vrr2-g9gh-c3jc](https://www.kimai.org/en/security/ghsa-vrr2-g9gh-c3jc)","aliases":["CVE-2026-52820"],"modified":"2026-07-14T00:11:38.958850Z","published":"2026-07-13T23:55:35Z","database_specific":{"github_reviewed":true,"github_reviewed_at":"2026-07-13T23:55:35Z","nvd_published_at":null,"cwe_ids":["CWE-639"],"severity":"MODERATE"},"references":[{"type":"WEB","url":"https://github.com/kimai/kimai/security/advisories/GHSA-vrr2-g9gh-c3jc"},{"type":"PACKAGE","url":"https://github.com/kimai/kimai"}],"affected":[{"package":{"name":"kimai/kimai","ecosystem":"Packagist","purl":"pkg:composer/kimai/kimai"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"2.57.0"}]}],"versions":["0.1","0.2","0.3","0.4","0.5","0.6","0.6.1","0.7","0.8","0.8.1","0.9","1.0","1.0.1","1.1","1.10","1.10.1","1.10.2","1.11","1.11.1","1.12","1.13","1.14","1.14.1","1.14.2","1.14.3","1.15","1.15.1","1.15.2","1.15.3","1.15.4","1.15.5","1.15.6","1.16","1.16.1","1.16.10","1.16.2","1.16.3","1.16.4","1.16.5","1.16.6","1.16.7","1.16.8","1.16.9","1.17","1.17.1","1.18","1.18.1","1.18.2","1.19","1.19.1","1.19.2","1.19.3","1.19.4","1.19.5","1.19.6","1.19.7","1.2","1.20","1.20.1","1.20.2","1.20.3","1.20.4","1.21.0","1.22.0","1.22.1","1.23.0","1.23.1","1.24.0","1.25.0","1.26.0","1.27.0","1.28.0","1.28.1","1.29.0","1.29.1","1.3","1.30.0","1.30.1","1.30.10","1.30.11","1.30.2","1.30.3","1.30.4","1.30.5","1.30.6","1.30.7","1.30.8","1.30.9","1.4","1.4.1","1.4.2","1.5","1.6","1.6.1","1.6.2","1.7","1.8","1.9","2.0.0","2.0.0-alpha","2.0.0-beta","2.0.0-beta-2","2.0.0-beta-3","2.0.0-rc-1","2.0.1","2.0.10","2.0.11","2.0.12","2.0.13","2.0.14","2.0.15","2.0.16","2.0.17","2.0.18","2.0.19","2.0.2","2.0.20","2.0.21","2.0.22","2.0.23","2.0.24","2.0.25","2.0.26","2.0.27","2.0.28","2.0.29","2.0.3","2.0.30","2.0.31","2.0.32","2.0.33","2.0.34","2.0.35","2.0.4","2.0.5","2.0.6","2.0.7","2.0.8","2.0.9","2.1.0","2.10.0","2.11.0","2.12.0","2.13.0","2.14.0","2.15.0","2.16.0","2.16.1","2.17.0","2.18.0","2.19.0","2.19.1","2.2.0","2.2.1","2.20.0","2.20.1","2.21.0","2.22.0","2.23.0","2.24.0","2.25.0","2.26.0","2.27.0","2.28.0","2.29.0","2.3.0","2.30.0","2.31.0","2.32.0","2.33.0","2.34.0","2.35.0","2.35.1","2.36.0","2.36.1","2.37.0","2.38.0","2.39.0","2.4.0","2.4.1","2.40.0","2.41.0","2.42.0","2.43.0","2.44.0","2.45.0","2.46.0","2.47.0","2.48.0","2.49.0","2.5.0","2.50.0","2.51.0","2.52.0","2.53.0","2.54.0","2.55.0","2.56.0","2.6.0","2.7.0","2.8.0","2.9.0"],"database_specific":{"last_known_affected_version_range":"\u003c= 2.56.0","source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2026/07/GHSA-vrr2-g9gh-c3jc/GHSA-vrr2-g9gh-c3jc.json"}}],"schema_version":"1.9.0","severity":[{"type":"CVSS_V4","score":"CVSS:4.0/AV:N/AC:L/AT:N/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N"}]}