Skip to content

Commit 7d25180

Browse files
1 parent 0bcbe1e commit 7d25180

3 files changed

Lines changed: 184 additions & 0 deletions

File tree

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-4m8q-55qv-9pwp",
4+
"modified": "2026-07-13T23:55:16Z",
5+
"published": "2026-07-13T23:55:16Z",
6+
"aliases": [
7+
"CVE-2026-52819"
8+
],
9+
"summary": "Kimai: Teamlead authorization bypass in GET /api/timesheets allows reading other users' timesheet records without being teamlead of the target",
10+
"details": "## Summary\n\n`GET /api/timesheets?user=<id>` (and `users[]=<id>`) returns the targeted user's timesheet records to any caller that has the `view_other_timesheet` permission, without verifying that the caller is teamlead of any team containing the target user. The per-record endpoint `GET /api/timesheets/{id}` correctly enforces this check via `TimesheetVoter`/`RolePermissionManager::checkTeamAccessTimesheet` → `checkTeamLeadAccess`, but the list endpoint only filters projects/customers by team membership and never validates `t.user`. A `ROLE_TEAMLEAD` user can therefore enumerate any user's records — including the `rate` field — as long as those records are on a project with no team scoping (Kimai's default) or on any project that shares any team (membership, not lead) with the requester.\n\n## Details\n\n**Root cause:** authorization mismatch between the per-record voter and the list endpoint.\n\n### Per-record path (correct)\n\n`src/Voter/TimesheetVoter.php:138`:\n```php\nif (!$this->permissionManager->checkTeamAccessTimesheet($subject, $user)) {\n return false;\n}\nreturn $this->permissionManager->hasRolePermission($user, $permission . '_other_timesheet');\n```\n\n`checkTeamLeadAccess` (RolePermissionManager.php:143-160) requires `isTeamleadOf` (not just member) one of the **target user's** teams. The unit test `testTeamleadDeniedWhenOnlyPlainMemberOfOwnerTeam` (tests/Voter/TimesheetVoterTest.php:253-269) codifies this:\n\n> *\"a TEAMLEAD role with `view_other_timesheet` must not access another user's timesheet by being a plain team member — they must be the team's teamlead.\"*\n\n### List path (vulnerable)\n\n`src/API/TimesheetController.php:97-119`:\n```php\npublic function cgetAction(ParamFetcherInterface $paramFetcher, ..., UserRepository $userRepository): Response\n{\n $query = new TimesheetQuery(false);\n $this->prepareQuery($query, $paramFetcher);\n $seeAll = false;\n\n if ($this->isGranted('view_other_timesheet')) {\n /** @var array<int> $users */\n $users = $paramFetcher->get('users');\n $userId = $paramFetcher->get('user');\n\n if ('all' === $userId) {\n $seeAll = true;\n } elseif (\\is_string($userId) && $userId !== '') {\n $users[] = (int) $userId;\n }\n\n if (!$seeAll) {\n foreach ($userRepository->findByIds($users) as $user) {\n $query->addUser($user); // <-- no teamlead-of-target check\n }\n }\n }\n ...\n```\n\n`config/packages/kimai.yaml:96,115` grants `TIMESHEET_OTHER` (which contains `view_other_timesheet`) to `ROLE_TEAMLEAD`, so the gate at line 103 passes for any teamlead. The `user=` / `users[]=` IDs are pushed straight into the query.\n\n### Net effect\n\nFor any victim `bob` who:\n- has at least one team that the requester `alice` is **not** teamlead of (so the voter denies per-record access), AND\n- has timesheets either on a project with no team (Kimai's default), or on a project that shares any team with `alice` (membership, not lead)\n\n`alice` is denied via `GET /api/timesheets/{id}` but receives `bob`'s records via `GET /api/timesheets?user=<bob_id>`.\n\nDisclosed fields in the collection response include `description`, `begin`, `end`, `duration`, `billable`, `exported`, `tags`, `rate`, `internalRate`, plus project/activity/user IDs (Default/Collection serializer groups, Timesheet.php:164-173). `rate` is financial data that the per-record voter is supposed to gate via the separate `view_rate_other_timesheet` permission.\n\n### Why other proposed mitigations don't apply\n- The `view_other_timesheet` `IsGranted` on the route is the only authorization layer in the list path; ROLE_TEAMLEAD has it globally.\n- `prepareQuery` only sets `currentUser`, not authorization (BaseApiController.php:68-71).\n- The serializer does not filter `rate` per caller — it is a static `Default`-group property.\n- Recent commit 20c7b03 \"Re-usable ACL checks on teams\" hardened the voter side but left the list endpoint unchanged.\n\n*A PoC was provided, but removed for security reasons.*\n\n## Impact\n\n- **Authorization bypass**: a `ROLE_TEAMLEAD` (a non-admin role typically granted to multiple users in a Kimai instance) can read any other user's timesheet records\n- **Financial data disclosure**: the `rate` and `internalRate` fields are returned in the collection serializer group, leaking what gets billed/costed against any user's records.\n- **PII / activity disclosure**: per-entry `description`, `begin`, `end`, `duration`, `billable`, `exported`, project/activity/customer IDs, and tags are leaked, allowing reconstruction of any user's activity timeline.\n\n# Solution\n\nThe list of requested user `TimesheetController::cgetAction()` is now guarded with the `access_user` permission.\nThe `access_user` permission verifies that the requesting user is allowed to see each of the requested user.\nIf any of the requested users may not be seen, the entire call will fail.\n\nFind out more at [https://www.kimai.org/en/security/ghsa-4m8q-55qv-9pwp](https://www.kimai.org/en/security/ghsa-4m8q-55qv-9pwp)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:L/VI:N/VA:N/SC:N/SI:N/SA:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "kimai/kimai"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "2.57.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 2.56.0"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/kimai/kimai/security/advisories/GHSA-4m8q-55qv-9pwp"
45+
},
46+
{
47+
"type": "PACKAGE",
48+
"url": "https://github.com/kimai/kimai"
49+
}
50+
],
51+
"database_specific": {
52+
"cwe_ids": [
53+
"CWE-863"
54+
],
55+
"severity": "MODERATE",
56+
"github_reviewed": true,
57+
"github_reviewed_at": "2026-07-13T23:55:16Z",
58+
"nvd_published_at": null
59+
}
60+
}
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-pgcc-vfmc-7cw5",
4+
"modified": "2026-07-13T23:55:01Z",
5+
"published": "2026-07-13T23:55:01Z",
6+
"aliases": [
7+
"CVE-2026-49992"
8+
],
9+
"summary": " Kimai: Login CSRF in Default Team Creation Endpoints Allows Unauthorized Team and Permission Structure Changes",
10+
"details": "### Summary\n\nKimai 2.56.0 contains authenticated cross-site request forgery issues in its default team creation shortcuts for projects, customers, and activities. These endpoints are exposed through `GET` routes and directly create or reuse a `Team`, add the current user as teamlead, and bind the target object to that team.\n\nAs a result, an attacker can trick a logged-in user with the required permissions into visiting a malicious page and cause unauthorized changes to team, teamlead, and object-binding relationships. This is a real authorization-structure modification issue rather than a harmless UI shortcut.\n\n### Details\n\nThe issue affects at least the following routes:\n\n- `GET /en/admin/project/{id}/create_team`\n- `GET /en/admin/customer/{id}/create_team`\n- `GET /en/admin/activity/{id}/create_team`\n\nEach of these routes is a `GET` endpoint, yet each performs persistent writes that alter authorization structure:\n\n- create or reuse a `Team`\n- add the current user as `teamlead`\n- bind the target `Project`, `Customer`, or `Activity` to that team\n\n*A PoC was provided, but removed for security reasons.*\n\n### Impact\n\nThis vulnerability allows an attacker to remotely alter permission topology while the victim is logged in. A successful exploit can create or reuse a team, assign the victim as its teamlead, and bind a project, customer, or activity to that team without intentional user action.\n\nThe pre-requisite is, that the logged-in user already has access to manage permissions of the object in question.\n\nBecause these routes modify authorization structure rather than a simple personal preference, the business impact can extend into visibility rules, assignment scope, team-based access control, reporting, and later privilege-expansion chains. This makes the issue materially more serious than a low-value cosmetic CSRF.\n\n## Solution\n\n- The routes have been moved to API `POST` endpoints\n\nSee https://www.kimai.org/en/security/ghsa-pgcc-vfmc-7cw5",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"score": "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "kimai/kimai"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "2.58.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 2.57.0"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/kimai/kimai/security/advisories/GHSA-pgcc-vfmc-7cw5"
45+
},
46+
{
47+
"type": "PACKAGE",
48+
"url": "https://github.com/kimai/kimai"
49+
},
50+
{
51+
"type": "WEB",
52+
"url": "https://www.kimai.org/en/security/ghsa-pgcc-vfmc-7cw5"
53+
}
54+
],
55+
"database_specific": {
56+
"cwe_ids": [
57+
"CWE-352"
58+
],
59+
"severity": "MODERATE",
60+
"github_reviewed": true,
61+
"github_reviewed_at": "2026-07-13T23:55:01Z",
62+
"nvd_published_at": null
63+
}
64+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
{
2+
"schema_version": "1.4.0",
3+
"id": "GHSA-vrr2-g9gh-c3jc",
4+
"modified": "2026-07-13T23:55:35Z",
5+
"published": "2026-07-13T23:55:35Z",
6+
"aliases": [
7+
"CVE-2026-52820"
8+
],
9+
"summary": "Kimai: Timesheet PATCH/POST allows assigning to project outside user's team via query_builder OR-bypass",
10+
"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' => '\\d+'])]\npublic function patchAction(Request $request, Timesheet $timesheet): Response\n{\n ...\n $form = $this->createForm(TimesheetApiEditForm::class, $timesheet, [...]);\n $form->setData($timesheet);\n $form->submit($request->request->all(), false);\n if (false === $form->isValid()) { ... }\n $this->service->saveTimesheet($timesheet);\n ...\n}\n```\n\n`src/Voter/TimesheetVoter.php:134-142`:\n\n```php\nif ($subject->getUser()?->getId() === $user->getId()) {\n return $this->permissionManager->hasRolePermission($user, $permission . '_own_timesheet');\n}\n\nif (!$this->permissionManager->checkTeamAccessTimesheet($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->getId()) {\n $isNew = false;\n }\n ...\n}\n$this->addProject($builder, $isNew, $project, $customer);\n```\n\n`src/Form/FormTrait.php:59-100`:\n\n```php\n$builder->addEventListener(\n FormEvents::PRE_SUBMIT,\n function (FormEvent $event) use ($builder, $project, $customer, $isNew, $options): void {\n $data = $event->getData();\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->getForm()->add('project', ProjectType::class, array_merge($options, [\n 'group_by' => null,\n 'query_builder' => 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->find($project);\n if ($project !== null) {\n if (!$project->getCustomer()->isVisible()) { ... $project = null; }\n elseif (!$project->isVisible()) { $project = null; }\n }\n }\n ...\n $query = new ProjectFormTypeQuery($project, $customer);\n $query->setUser($builder->getOption('user'));\n $query->setWithCustomer(true);\n return $repo->getQueryBuilderForFormType($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->expr()->andX();\n $mainQuery->add($qb->expr()->eq('p.visible', ':visible'));\n $mainQuery->add($qb->expr()->eq('c.visible', ':customer_visible'));\n if (!$query->isIgnoreDate()) { ... }\n if ($query->hasCustomers()) { ... }\n\n $permissions = $this->getPermissionCriteria($qb, $query->getUser(), $query->getTeams());\n if ($permissions->count() > 0) {\n $mainQuery->add($permissions);\n }\n\n $outerQuery = $qb->expr()->orX();\n if ($query->hasProjects()) {\n $outerQuery->add($qb->expr()->in('p.id', ':project')); // <-- unconditional\n $qb->setParameter('project', $query->getProjects());\n }\n ...\n $outerQuery->add($mainQuery);\n $qb->andWhere($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 <date> AND <team-ACL>)\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)",
11+
"severity": [
12+
{
13+
"type": "CVSS_V4",
14+
"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"
15+
}
16+
],
17+
"affected": [
18+
{
19+
"package": {
20+
"ecosystem": "Packagist",
21+
"name": "kimai/kimai"
22+
},
23+
"ranges": [
24+
{
25+
"type": "ECOSYSTEM",
26+
"events": [
27+
{
28+
"introduced": "0"
29+
},
30+
{
31+
"fixed": "2.57.0"
32+
}
33+
]
34+
}
35+
],
36+
"database_specific": {
37+
"last_known_affected_version_range": "<= 2.56.0"
38+
}
39+
}
40+
],
41+
"references": [
42+
{
43+
"type": "WEB",
44+
"url": "https://github.com/kimai/kimai/security/advisories/GHSA-vrr2-g9gh-c3jc"
45+
},
46+
{
47+
"type": "PACKAGE",
48+
"url": "https://github.com/kimai/kimai"
49+
}
50+
],
51+
"database_specific": {
52+
"cwe_ids": [
53+
"CWE-639"
54+
],
55+
"severity": "MODERATE",
56+
"github_reviewed": true,
57+
"github_reviewed_at": "2026-07-13T23:55:35Z",
58+
"nvd_published_at": null
59+
}
60+
}

0 commit comments

Comments
 (0)