VYPR
Moderate severityNVD Advisory· Published Dec 1, 2021· Updated Aug 3, 2024

Improper Access Control in kevinpapst/kimai2

CVE-2021-3992

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.

PackageAffected versionsPatched versions
kevinpapst/kimai2Packagist
< 1.16.31.16.3

Affected products

2

Patches

1
ff9acab0fc81

improve permissison handling in invoice screen (#2965)

https://github.com/kevinpapst/kimai2Kevin PapstNov 21, 2021via ghsa
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 added
  • public/build/app.8609d161.js.LICENSE.txt+0 0 renamed
  • public/build/app.920ba43e.js+0 2 removed
  • public/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

News mentions

0

No linked articles in our index yet.