Improper Access Control in kevinpapst/kimai2
Description
Kimai2 invoice preview endpoint lacked proper access control, allowing unauthorized users to preview invoices for any customer.
AI Insight
LLM-synthesized narrative grounded in this CVE's description and references.
Kimai2 invoice preview endpoint lacked proper access control, allowing unauthorized users to preview invoices for any customer.
Vulnerability
The previewAction method in InvoiceController of Kimai2 (versions prior to commit ff9acab) failed to enforce proper access control. The route /preview/{customer} did not verify that the authenticated user had permission to access the specified customer's data. The fix added @Security("is_granted('access', customer)") and @Security("is_granted('create_invoice')") annotations, ensuring only users with the create_invoice role and explicit customer access could invoke the endpoint [1].
Exploitation
An attacker with a valid Kimai2 user account could call the GET /preview/{customer} endpoint with any customer ID, bypassing the intended permission checks. No additional privileges or user interaction beyond authentication were required. The original code only checked is_granted('view_invoice') at the class level, which was insufficient to restrict access per customer [1].
Impact
Successful exploitation allowed an authenticated attacker to preview invoices for any customer in the system, potentially exposing sensitive billing information such as invoice amounts, line items, and customer details. This constitutes an unauthorized information disclosure vulnerability [4].
Mitigation
The vulnerability was fixed in commit ff9acab (merged into the main branch) by adding proper permission checks. Users should upgrade to a Kimai2 release that includes this commit. No workarounds are documented; applying the patch or updating to the latest version is recommended [1][4].
AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
kevinpapst/kimai2Packagist | < 1.16.3 | 1.16.3 |
Affected products
2- kevinpapst/kevinpapst/kimai2v5Range: unspecified
Patches
1ff9acab0fc81improve permissison handling in invoice screen (#2965)
15 files changed · +183 −124
assets/js/plugins/KimaiForm.js+12 −36 modified@@ -33,46 +33,22 @@ export default class KimaiForm extends KimaiPlugin { this.getContainer().getPlugin('date-range-picker').destroyDateRangePicker(formSelector); } - getFormData(form) { + /** + * @param {HTMLFormElement} form + * @param {Object} overwrites + * @returns {string} + */ + convertFormDataToQueryString(form, overwrites = {}) + { let serialized = []; + let data = new FormData(form); - // Loop through each field in the form - for (let i = 0; i < form.elements.length; i++) { - - let field = form.elements[i]; - - // Don't serialize a couple of field types (button and submit are important to exclude, eg. invoice preview would fail otherwise) - if (!field.name || field.disabled || field.type === 'file' || field.type === 'reset' || field.type === 'submit' || field.type === 'button') { - continue; - } - - // If a multi-select, get all selections - if (field.type === 'select-multiple') { - for (var n = 0; n < field.options.length; n++) { - if (!field.options[n].selected) { - continue; - } - serialized.push({ - name: field.name, - value: field.options[n].value - }); - } - } else if ((field.type !== 'checkbox' && field.type !== 'radio') || field.checked) { - serialized.push({ - name: field.name, - value: field.value - }); - } + for (const key in overwrites) { + data.set(key, overwrites[key]); } - return serialized; - } - - convertFormDataToQueryString(formData) { - let serialized = []; - - for (let row of formData) { - serialized.push(encodeURIComponent(row.name) + "=" + encodeURIComponent(row.value)); + for (let row of data) { + serialized.push(encodeURIComponent(row[0]) + "=" + encodeURIComponent(row[1])); } return serialized.join('&');
public/build/app.8609d161.js+2 −0 addedpublic/build/app.8609d161.js.LICENSE.txt+0 −0 renamedpublic/build/app.920ba43e.js+0 −2 removedpublic/build/entrypoints.json+1 −1 modified@@ -3,7 +3,7 @@ "app": { "js": [ "build/runtime.b8e7bb04.js", - "build/app.920ba43e.js" + "build/app.8609d161.js" ], "css": [ "build/app.3bc2b4d9.css"
public/build/manifest.json+1 −1 modified@@ -1,6 +1,6 @@ { "build/app.css": "build/app.3bc2b4d9.css", - "build/app.js": "build/app.920ba43e.js", + "build/app.js": "build/app.8609d161.js", "build/invoice.css": "build/invoice.ff32661a.css", "build/invoice.js": "build/invoice.19f36eca.js", "build/invoice-pdf.css": "build/invoice-pdf.9a7468ef.css",
src/Controller/InvoiceController.php+73 −63 modified@@ -48,21 +48,9 @@ */ final class InvoiceController extends AbstractController { - /** - * @var ServiceInvoice - */ private $service; - /** - * @var InvoiceTemplateRepository - */ private $templateRepository; - /** - * @var InvoiceRepository - */ private $invoiceRepository; - /** - * @var EventDispatcherInterface - */ private $dispatcher; public function __construct(ServiceInvoice $service, InvoiceTemplateRepository $templateRepository, InvoiceRepository $invoiceRepository, EventDispatcherInterface $dispatcher) @@ -130,10 +118,11 @@ public function indexAction(Request $request, SystemConfiguration $configuration } /** - * @Route(path="/preview/{customer}/{template}", name="invoice_preview", methods={"GET"}) - * @Security("is_granted('view_invoice')") + * @Route(path="/preview/{customer}", name="invoice_preview", methods={"GET"}) + * @Security("is_granted('access', customer)") + * @Security("is_granted('create_invoice')") */ - public function previewAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response + public function previewAction(Customer $customer, Request $request, SystemConfiguration $configuration): Response { if (!$this->templateRepository->hasTemplate()) { return $this->redirectToRoute('invoice'); @@ -143,10 +132,8 @@ public function previewAction(Customer $customer, InvoiceTemplate $template, Req $form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form')); $form->submit($request->query->all(), false); - if ($form->isValid() && $this->isGranted('create_invoice')) { + if ($form->isValid()) { try { - $query->setTemplate($template); - $query->setCustomers([$customer]); $model = $this->service->createModel($query); return $this->service->renderInvoiceWithModel($model, $this->dispatcher); @@ -156,12 +143,15 @@ public function previewAction(Customer $customer, InvoiceTemplate $template, Req } } + $this->flashFormError($form); + return $this->redirectToRoute('invoice'); } /** * @Route(path="/save-invoice/{customer}/{template}", name="invoice_create", methods={"GET"}) - * @Security("is_granted('view_invoice')") + * @Security("is_granted('access', customer)") + * @Security("is_granted('create_invoice')") */ public function createInvoiceAction(Customer $customer, InvoiceTemplate $template, Request $request, SystemConfiguration $configuration): Response { @@ -173,62 +163,22 @@ public function createInvoiceAction(Customer $customer, InvoiceTemplate $templat $form = $this->getToolbarForm($query, $configuration->find('invoice.simple_form')); $form->submit($request->query->all(), false); - if ($form->isValid() && $this->isGranted('create_invoice')) { + if ($form->isValid()) { $query->setTemplate($template); $query->setCustomers([$customer]); return $this->renderInvoice($query, $request); } - return $this->redirectToRoute('invoice'); - } - - private function getDefaultQuery(): InvoiceQuery - { - $factory = $this->getDateTimeFactory(); - $begin = $factory->getStartOfMonth(); - $end = $factory->getEndOfMonth(); - - $query = new InvoiceQuery(); - $query->setBegin($begin); - $query->setEnd($end); - // limit access to data from teams - $query->setCurrentUser($this->getUser()); - - if (!$this->isGranted('view_other_timesheet')) { - // limit access to own data - $query->setUser($this->getUser()); - } - - return $query; - } - - private function renderInvoice(InvoiceQuery $query, Request $request) - { - // use the current request locale as fallback, if no translation was configured - if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) { - $query->getTemplate()->setLanguage($request->getLocale()); - } - - try { - $invoices = $this->service->createInvoices($query, $this->dispatcher); - - $this->flashSuccess('action.update.success'); - - if (\count($invoices) === 1) { - return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]); - } - - return $this->redirectToRoute('admin_invoice_list'); - } catch (Exception $ex) { - $this->flashUpdateException($ex); - } + $this->flashFormError($form); return $this->redirectToRoute('invoice'); } /** * @Route(path="/change-status/{id}/{status}", name="admin_invoice_status", methods={"GET", "POST"}) + * @Security("is_granted('access', invoice.getCustomer())") + * @Security("is_granted('create_invoice')") */ public function changeStatusAction(Invoice $invoice, string $status, Request $request): Response { @@ -256,6 +206,8 @@ public function changeStatusAction(Invoice $invoice, string $status, Request $re /** * @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"}) + * @Security("is_granted('access', invoice.getCustomer())") + * @Security("is_granted('delete_invoice')") */ public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response { @@ -279,6 +231,8 @@ public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenMa /** * @Route(path="/download/{id}", name="admin_invoice_download", methods={"GET"}) + * @Security("is_granted('access', invoice.getCustomer())") + * @Security("is_granted('create_invoice')") */ public function downloadAction(Invoice $invoice): Response { @@ -295,6 +249,7 @@ public function downloadAction(Invoice $invoice): Response /** * @Route(path="/show/{page}", defaults={"page": 1}, requirements={"page": "[1-9]\d*"}, name="admin_invoice_list", methods={"GET"}) + * @Security("is_granted('view_invoice')") */ public function showInvoicesAction(Request $request, int $page): Response { @@ -325,6 +280,7 @@ public function showInvoicesAction(Request $request, int $page): Response /** * @Route(path="/export", name="invoice_export", methods={"GET"}) + * @Security("is_granted('view_invoice')") */ public function exportAction(Request $request, AnnotatedObjectExporter $exporter) { @@ -474,6 +430,60 @@ public function deleteTemplate(InvoiceTemplate $template, string $token, CsrfTok return $this->redirectToRoute('admin_invoice_template'); } + private function getDefaultQuery(): InvoiceQuery + { + $factory = $this->getDateTimeFactory(); + $begin = $factory->getStartOfMonth(); + $end = $factory->getEndOfMonth(); + + $query = new InvoiceQuery(); + $query->setBegin($begin); + $query->setEnd($end); + // limit access to data from teams + $query->setCurrentUser($this->getUser()); + + if (!$this->isGranted('view_other_timesheet')) { + // limit access to own data + $query->setUser($this->getUser()); + } + + return $query; + } + + private function renderInvoice(InvoiceQuery $query, Request $request) + { + // use the current request locale as fallback, if no translation was configured + if (null !== $query->getTemplate() && null === $query->getTemplate()->getLanguage()) { + $query->getTemplate()->setLanguage($request->getLocale()); + } + + try { + $invoices = $this->service->createInvoices($query, $this->dispatcher); + + $this->flashSuccess('action.update.success'); + + if (\count($invoices) === 1) { + return $this->redirectToRoute('admin_invoice_list', ['id' => $invoices[0]->getId()]); + } + + return $this->redirectToRoute('admin_invoice_list'); + } catch (Exception $ex) { + $this->flashUpdateException($ex); + } + + return $this->redirectToRoute('invoice'); + } + + private function flashFormError(FormInterface $form): void + { + $err = ''; + foreach ($form->getErrors(true, true) as $error) { + $err .= PHP_EOL . '[' . $error->getOrigin()->getName() . '] ' . $error->getMessage(); + } + + $this->flashError('action.update.error', ['%reason%' => $err]); + } + private function renderTemplateForm(InvoiceTemplate $template, Request $request): Response { $editForm = $this->createEditForm($template);
src/Repository/CustomerRepository.php+2 −1 modified@@ -241,7 +241,8 @@ public function getQueryBuilderForFormType(CustomerFormTypeQuery $query): QueryB $outerQuery = $qb->expr()->orX(); - if ($query->hasCustomers()) { + // this is a risk, as a user can manipulate the query and inject IDs that would be hidden otherwise + if ($query->isAllowCustomerPreselect() && $query->hasCustomers()) { $outerQuery->add($qb->expr()->in('c.id', ':customer')); $qb->setParameter('customer', $query->getCustomers()); }
src/Repository/Query/CustomerFormTypeQuery.php+11 −0 modified@@ -20,6 +20,7 @@ final class CustomerFormTypeQuery extends BaseFormTypeQuery * @var Customer|null */ private $customerToIgnore; + private $allowCustomerPreselect = false; /** * @param Customer|int|null $customer @@ -34,6 +35,16 @@ public function __construct($customer = null) } } + public function isAllowCustomerPreselect(): bool + { + return $this->allowCustomerPreselect; + } + + public function setAllowCustomerPreselect(bool $allowCustomerPreselect): void + { + $this->allowCustomerPreselect = $allowCustomerPreselect; + } + /** * @return Customer|null */
src/Voter/CustomerVoter.php+17 −0 modified@@ -34,6 +34,7 @@ final class CustomerVoter extends Voter 'comments', 'comments_create', 'details', + 'access', ]; private $permissionManager; @@ -75,6 +76,22 @@ protected function voteOnAttribute($attribute, $subject, TokenInterface $token) return false; } + // this is a virtual permission, only meant to be used by developer + // it checks if access to the given customer is potentially possible + if ($attribute === 'access') { + if ($subject->getTeams()->count() === 0) { + return true; + } + + foreach ($subject->getTeams() as $team) { + if ($user->isInTeam($team)) { + return true; + } + } + + return false; + } + if ($this->permissionManager->hasRolePermission($user, $attribute . '_customer')) { return true; }
src/Voter/UserVoter.php+0 −1 modified@@ -29,7 +29,6 @@ final class UserVoter extends Voter 'preferences', 'api-token', 'hourly-rate', - // teams_own_profile could be merged with view_team_member 'view_team_member', ];
templates/invoice/index.html.twig+10 −10 modified@@ -109,8 +109,8 @@ </td> <td class="w-min text-center"> {{ widgets.action_button('show', {'url': '#invoice_preview_details_' ~ model.customer.id, 'title': 'timesheet.all'|trans, 'class': 'btn btn-sm hidden-xs hidden-sm'}, 'link') }} - {{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_preview', {'customer': model.customer.id, 'template': model.template.id})}}) }} - {{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }} + {{ widgets.action_button('print', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'button.preview'|trans, 'target': '_blank', 'class': 'btn btn-sm', 'attr': {'data-customer': model.customer.id, 'data-template': model.template.id, 'data-href': path('invoice_preview', {'customer': model.customer.id})}}) }} + {{ widgets.action_button('save', {'url': '#', 'onclick': 'return singleInvoice(this)', 'title': 'action.save'|trans, 'class': 'btn btn-sm', 'attr': {'data-customer': model.customer.id, 'data-template': model.template.id, 'data-href': path('invoice_create', {'customer': model.customer.id, 'template': model.template.id})}}, 'success') }} </td> <td class="w-min text-right hidden-xs"> {{ model.calculator.timeWorked|duration(isDecimal) }} @@ -214,20 +214,20 @@ function singleInvoice(link) { - let formPlugin = kimai.getPlugin('form'); - let data = formPlugin.getFormData(document.getElementById('{{ formId }}')); - let uri = formPlugin.convertFormDataToQueryString(data); - let baseUrl = link.getAttribute('data-href'); - link.href = baseUrl + '?' + uri; + const formPlugin = kimai.getPlugin('form'); + const overwrites = {'customers[]': link.dataset['customer'], 'template': link.dataset['template']}; + const uri = formPlugin.convertFormDataToQueryString(document.getElementById('{{ formId }}'), overwrites); + + link.href = link.dataset['href'] + '?' + uri; return true; } function saveAllInvoices(link) { - let formPlugin = kimai.getPlugin('form'); - let data = formPlugin.getFormData(document.getElementById('{{ formId }}')); - let uri = formPlugin.convertFormDataToQueryString(data); + const formPlugin = kimai.getPlugin('form'); + const uri = formPlugin.convertFormDataToQueryString(document.getElementById('{{ formId }}')); + link.href = '{{ path('invoice') }}?createInvoice=true&' + uri; return true;
tests/Controller/InvoiceControllerTest.php+5 −9 modified@@ -207,7 +207,7 @@ public function testCreateAction() } } - public function testPrintAction() + public function testPreviewAction() { $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD); @@ -233,17 +233,19 @@ public function testPrintAction() $params = [ 'daterange' => $dateRange, 'projects' => [1], + 'template' => $id, + 'customers[]' => 1 ]; - $action = '/invoice/preview/1/' . $id . '?' . http_build_query($params); + $action = '/invoice/preview/1?' . http_build_query($params); $this->request($client, $action); $this->assertTrue($client->getResponse()->isSuccessful()); $node = $client->getCrawler()->filter('body'); $this->assertEquals(1, $node->count()); $this->assertEquals('invoice_print', $node->getIterator()[0]->getAttribute('class')); } - public function testCreateActionAsAdminWithDownloadAndStatusChangeAndDelete() + public function testCreateActionAsAdminWithDownloadAndStatusChange() { $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN); @@ -352,12 +354,6 @@ public function testCreateActionAsAdminWithDownloadAndStatusChangeAndDelete() $this->assertIsRedirect($client, '/invoice/show'); $client->followRedirect(); $this->assertTrue($client->getResponse()->isSuccessful()); - - // this does not delete the invoice, because the token is wrong - $this->request($client, '/invoice/delete/' . $id . '/fghfkjhgkjhg'); - $this->assertIsRedirect($client, '/invoice/show'); - $client->followRedirect(); - $this->assertTrue($client->getResponse()->isSuccessful()); } public function testEditTemplateAction()
tests/Repository/Query/CustomerFormTypeQueryTest.php+3 −0 modified@@ -25,6 +25,9 @@ public function testQuery() $this->assertBaseQuery($sut); $customer = new Customer(); + self::assertFalse($sut->isAllowCustomerPreselect()); + $sut->setAllowCustomerPreselect(true); + self::assertTrue($sut->isAllowCustomerPreselect()); self::assertNull($sut->getCustomerToIgnore()); self::assertInstanceOf(CustomerFormTypeQuery::class, $sut->setCustomerToIgnore($customer)); self::assertSame($customer, $sut->getCustomerToIgnore());
tests/Voter/CustomerVoterTest.php+46 −0 modified@@ -118,4 +118,50 @@ public function testTeamMember() $this->assertVote($user, $customer, 'edit', VoterInterface::ACCESS_GRANTED); } + + public function testAccess() + { + // ALLOW: customer has no teams + $this->assertVote(new User(), new Customer(), 'access', VoterInterface::ACCESS_GRANTED); + + // ALLOW: customer has no teams + $user = new User(); + $user->addTeam(new Team()); + $this->assertVote($user, new Customer(), 'access', VoterInterface::ACCESS_GRANTED); + + // ALLOW: user and customer are in the same team (as teamlead) + $team = new Team(); + $user = new User(); + $team->addTeamlead($user); + + $customer = new Customer(); + $customer->addTeam($team); + + $this->assertVote($user, $customer, 'access', VoterInterface::ACCESS_GRANTED); + + // ALLOW: user and customer are in the same team (as member) + $team = new Team(); + $user = new User(); + $user->addTeam(new Team()); + $user->addTeam($team); + + $customer = new Customer(); + $customer->addTeam($team); + + $this->assertVote($user, $customer, 'access', VoterInterface::ACCESS_GRANTED); + + // DENY: customer has a team, user not + $customer = new Customer(); + $customer->addTeam(new Team()); + + $this->assertVote(new User(), $customer, 'access', VoterInterface::ACCESS_DENIED); + + // DENY: user and customer has a team are not in the same team + $user = new User(); + $user->addTeam(new Team()); + $customer = new Customer(); + $customer->addTeam(new Team()); + + $this->assertVote($user, $customer, 'access', VoterInterface::ACCESS_DENIED); + } }
Vulnerability mechanics
Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
4- github.com/advisories/GHSA-9w8f-7wgr-2h7gghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2021-3992ghsaADVISORY
- github.com/kevinpapst/kimai2/commit/ff9acab0fc81f0e9490462739ef15fe4ab028ea5ghsax_refsource_MISCWEB
- huntr.dev/bounties/a0c438fb-c8e1-40cf-acc6-c8a532b80b93ghsax_refsource_CONFIRMWEB
News mentions
0No linked articles in our index yet.