VYPR
Moderate severityNVD Advisory· Published Mar 6, 2026· Updated Mar 9, 2026

Kimai: API invoice endpoint missing customer-level access control (IDOR)

CVE-2026-28685

Description

Kimai is a web-based multi-user time-tracking application. Prior to version 2.51.0, "GET /api/invoices/{id}" only checks the role-based view_invoice permission but does not verify the requesting user has access to the invoice's customer. Any user with ROLE_TEAMLEAD (which grants view_invoice) can read all invoices in the system, including those belonging to customers assigned to other teams. This issue has been patched in version 2.51.0.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
kimai/kimaiPackagist
< 2.51.02.51.0

Affected products

1

Patches

1
a0601c8cb28f

check customer permissions on invoice api access (#5849)

https://github.com/kimai/kimaiKevin PapstMar 1, 2026via ghsa
2 files changed · +63 0
  • src/API/InvoiceController.php+5 0 modified
    @@ -18,6 +18,7 @@
     use FOS\RestBundle\View\View;
     use FOS\RestBundle\View\ViewHandlerInterface;
     use OpenApi\Attributes as OA;
    +use Symfony\Component\ExpressionLanguage\Expression;
     use Symfony\Component\HttpFoundation\Response;
     use Symfony\Component\Routing\Attribute\Route;
     use Symfony\Component\Security\Http\Attribute\IsGranted;
    @@ -76,6 +77,9 @@ public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerReposito
             /** @var array<int> $customers */
             $customers = $paramFetcher->get('customers');
             foreach ($customerRepository->findByIds(array_unique($customers)) as $customer) {
    +            if (!$this->isGranted('access', $customer)) {
    +                throw $this->createAccessDeniedException('Cannot access Customer: ' . $customer->getId());
    +            }
                 $query->addCustomer($customer);
             }
     
    @@ -90,6 +94,7 @@ public function cgetAction(ParamFetcherInterface $paramFetcher, CustomerReposito
          * Fetch invoice
          */
         #[IsGranted('view_invoice')]
    +    #[IsGranted(new Expression("is_granted('access', subject.getCustomer())"), 'invoice')]
         #[OA\Response(response: 200, description: 'Returns one invoice', content: new OA\JsonContent(ref: '#/components/schemas/Invoice'))]
         #[Route(methods: ['GET'], path: '/{id}', name: 'get_invoice', requirements: ['id' => '\d+'])]
         public function getAction(Invoice $invoice): Response
    
  • tests/API/InvoiceControllerTest.php+58 0 modified
    @@ -9,8 +9,11 @@
     
     namespace App\Tests\API;
     
    +use App\Entity\Customer;
     use App\Entity\Invoice;
    +use App\Entity\Team;
     use App\Entity\User;
    +use App\Repository\TeamRepository;
     use App\Tests\DataFixtures\InvoiceFixtures;
     use PHPUnit\Framework\Attributes\Group;
     
    @@ -123,4 +126,59 @@ public function testNotFound(): void
         {
             $this->assertEntityNotFound(User::ROLE_USER, '/api/invoices/' . PHP_INT_MAX);
         }
    +
    +    public function testDownloadRespectsCustomerPermission(): void
    +    {
    +        $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
    +
    +        $invoices = $this->importInvoiceFixtures(1, [Invoice::STATUS_NEW]);
    +        $invoice = $invoices[0];
    +        $customer = $invoice->getCustomer();
    +        self::assertInstanceOf(Customer::class, $customer);
    +
    +        $this->assertAccessIsGranted($client, '/api/invoices/' . $invoice->getId());
    +
    +        $content = $client->getResponse()->getContent();
    +        self::assertIsString($content);
    +        $result = json_decode($content, true);
    +
    +        self::assertIsArray($result);
    +        self::assertApiResponseTypeStructure('Invoice', $result);
    +
    +        $team = new Team('foo');
    +        $team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN));
    +        $team->addCustomer($customer);
    +
    +        $em = $this->getEntityManager();
    +        /** @var TeamRepository $repository */
    +        $repository = $em->getRepository(Team::class);
    +        $repository->saveTeam($team);
    +
    +        $this->assertApiAccessDenied($client, '/api/invoices/' . $invoice->getId());
    +    }
    +
    +    public function testCollectionRespectsCustomerPermission(): void
    +    {
    +        $client = $this->getClientForAuthenticatedUser(User::ROLE_TEAMLEAD);
    +
    +        $invoices = $this->importInvoiceFixtures(1, [Invoice::STATUS_NEW]);
    +        $invoice = $invoices[0];
    +        $customer = $invoice->getCustomer();
    +        self::assertInstanceOf(Customer::class, $customer);
    +
    +        $query = ['customers' => [$customer->getId()]];
    +        $this->assertAccessIsGranted($client, '/api/invoices', 'GET', $query);
    +
    +        $team = new Team('foo');
    +        $team->addTeamlead($this->getUserByRole(User::ROLE_ADMIN));
    +        $team->addCustomer($customer);
    +
    +        $em = $this->getEntityManager();
    +        /** @var TeamRepository $repository */
    +        $repository = $em->getRepository(Team::class);
    +        $repository->saveTeam($team);
    +
    +        $this->request($client, '/api/invoices', 'GET', $query);
    +        $this->assertApiResponseAccessDenied($client->getResponse());
    +    }
     }
    

Vulnerability mechanics

Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

5

News mentions

0

No linked articles in our index yet.