VYPR
Moderate severityNVD Advisory· Published Nov 19, 2021· Updated Aug 3, 2024

Cross-Site Request Forgery (CSRF) in kevinpapst/kimai2

CVE-2021-3963

Description

Kimai2 is vulnerable to CSRF in comment delete and pin actions, allowing unauthorized state changes.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

Kimai2 is vulnerable to CSRF in comment delete and pin actions, allowing unauthorized state changes.

Vulnerability

Kimai2 versions prior to the fix in commit 95796ab are vulnerable to Cross-Site Request Forgery (CSRF) in the customer_comment_delete and customer_comment_pin endpoints. These endpoints use GET requests without CSRF token validation, allowing an attacker to forge requests on behalf of an authenticated user.

Exploitation

An attacker can craft a malicious web page or link that, when visited by an authenticated Kimai2 user, triggers a GET request to delete or pin a comment without the user's consent. No additional privileges are required beyond the victim being logged in and having appropriate permissions to modify comments.

Impact

Successful exploitation allows an attacker to delete or pin comments on customer records. This can lead to loss of important data or manipulation of comment visibility, potentially affecting business operations or data integrity.

Mitigation

The vulnerability is fixed in commit 95796ab by adding CSRF token validation for the affected endpoints. Users should update to a version that includes this commit or apply the patch manually. No workarounds are documented. The issue was reported via huntr.dev [3] and has a corresponding fix in the Kimai2 repository [1].

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.161.16

Affected products

2

Patches

1
95796ab2560a

improve csrf handling (#2936)

https://github.com/kevinpapst/kimai2Kevin PapstNov 16, 2021via ghsa
15 files changed · +119 31
  • src/Controller/CustomerController.php+25 5 modified
    @@ -41,6 +41,8 @@
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\HttpFoundation\Response;
     use Symfony\Component\Routing\Annotation\Route;
    +use Symfony\Component\Security\Csrf\CsrfToken;
    +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
     
     /**
      * Controller used to manage customer in the admin part of the site.
    @@ -157,13 +159,21 @@ public function teamPermissionsAction(Customer $customer, Request $request)
         }
     
         /**
    -     * @Route(path="/{id}/comment_delete", name="customer_comment_delete", methods={"GET"})
    +     * @Route(path="/{id}/comment_delete/{token}", name="customer_comment_delete", methods={"GET"})
          * @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
          */
    -    public function deleteCommentAction(CustomerComment $comment)
    +    public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
         {
             $customerId = $comment->getCustomer()->getId();
     
    +        if (!$csrfTokenManager->isTokenValid(new CsrfToken('customer.delete_comment', $token))) {
    +            $this->flashError('action.csrf.error');
    +
    +            return $this->redirectToRoute('customer_details', ['id' => $customerId]);
    +        }
    +
    +        $csrfTokenManager->refreshToken($token);
    +
             try {
                 $this->repository->deleteComment($comment);
             } catch (\Exception $ex) {
    @@ -196,19 +206,29 @@ public function addCommentAction(Customer $customer, Request $request)
         }
     
         /**
    -     * @Route(path="/{id}/comment_pin", name="customer_comment_pin", methods={"GET"})
    +     * @Route(path="/{id}/comment_pin/{token}", name="customer_comment_pin", methods={"GET"})
          * @Security("is_granted('edit', comment.getCustomer()) and is_granted('comments', comment.getCustomer())")
          */
    -    public function pinCommentAction(CustomerComment $comment)
    +    public function pinCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
         {
    +        $customerId = $comment->getCustomer()->getId();
    +
    +        if (!$csrfTokenManager->isTokenValid(new CsrfToken('customer.pin_comment', $token))) {
    +            $this->flashError('action.csrf.error');
    +
    +            return $this->redirectToRoute('customer_details', ['id' => $customerId]);
    +        }
    +
    +        $csrfTokenManager->refreshToken($token);
    +
             $comment->setPinned(!$comment->isPinned());
             try {
                 $this->repository->saveComment($comment);
             } catch (\Exception $ex) {
                 $this->flashUpdateException($ex);
             }
     
    -        return $this->redirectToRoute('customer_details', ['id' => $comment->getCustomer()->getId()]);
    +        return $this->redirectToRoute('customer_details', ['id' => $customerId]);
         }
     
         /**
    
  • src/Controller/DoctorController.php+1 1 modified
    @@ -64,7 +64,7 @@ public function __construct(string $projectDirectory, string $kernelEnvironment,
         public function deleteLogfileAction(string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
         {
             if (!$csrfTokenManager->isTokenValid(new CsrfToken('doctor.flush_log', $token))) {
    -            $this->flashError('action.delete.error');
    +            $this->flashError('action.csrf.error');
     
                 return $this->redirectToRoute('doctor');
             }
    
  • src/Controller/InvoiceController.php+11 3 modified
    @@ -260,7 +260,7 @@ public function changeStatusAction(Invoice $invoice, string $status, Request $re
         public function deleteInvoiceAction(Invoice $invoice, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
         {
             if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete', $token))) {
    -            $this->flashError('action.delete.error');
    +            $this->flashError('action.csrf.error');
     
                 return $this->redirectToRoute('admin_invoice_list');
             }
    @@ -451,11 +451,19 @@ public function createTemplateAction(Request $request, ?InvoiceTemplate $copyFro
         }
     
         /**
    -     * @Route(path="/template/{id}/delete", name="admin_invoice_template_delete", methods={"GET", "POST"})
    +     * @Route(path="/template/{id}/delete/{token}", name="admin_invoice_template_delete", methods={"GET", "POST"})
          * @Security("is_granted('manage_invoice_template')")
          */
    -    public function deleteTemplate(InvoiceTemplate $template): Response
    +    public function deleteTemplate(InvoiceTemplate $template, string $token, CsrfTokenManagerInterface $csrfTokenManager): Response
         {
    +        if (!$csrfTokenManager->isTokenValid(new CsrfToken('invoice.delete_template', $token))) {
    +            $this->flashError('action.csrf.error');
    +
    +            return $this->redirectToRoute('admin_invoice_template');
    +        }
    +
    +        $csrfTokenManager->refreshToken($token);
    +
             try {
                 $this->templateRepository->removeTemplate($template);
                 $this->flashSuccess('action.delete.success');
    
  • src/Controller/PermissionController.php+1 1 modified
    @@ -209,7 +209,7 @@ public function createRole(Request $request): Response
         public function deleteRole(Role $role, string $csrfToken, UserRepository $userRepository, CsrfTokenManagerInterface $csrfTokenManager): Response
         {
             if (!$this->isCsrfTokenValid(self::TOKEN_NAME, $csrfToken)) {
    -            $this->flashUpdateException(new \Exception('Invalid CSRF token'));
    +            $this->flashError('action.csrf.error');
     
                 return $this->redirectToRoute('admin_user_permissions');
             }
    
  • src/Controller/ProjectController.php+25 5 modified
    @@ -43,6 +43,8 @@
     use Symfony\Component\Form\FormInterface;
     use Symfony\Component\HttpFoundation\Request;
     use Symfony\Component\Routing\Annotation\Route;
    +use Symfony\Component\Security\Csrf\CsrfToken;
    +use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
     
     /**
      * Controller used to manage projects.
    @@ -179,13 +181,21 @@ public function createAction(Request $request, ?Customer $customer = null)
         }
     
         /**
    -     * @Route(path="/{id}/comment_delete", name="project_comment_delete", methods={"GET"})
    +     * @Route(path="/{id}/comment_delete/{token}", name="project_comment_delete", methods={"GET"})
          * @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
          */
    -    public function deleteCommentAction(ProjectComment $comment)
    +    public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
         {
             $projectId = $comment->getProject()->getId();
     
    +        if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.delete_comment', $token))) {
    +            $this->flashError('action.csrf.error');
    +
    +            return $this->redirectToRoute('project_details', ['id' => $projectId]);
    +        }
    +
    +        $csrfTokenManager->refreshToken($token);
    +
             try {
                 $this->repository->deleteComment($comment);
             } catch (\Exception $ex) {
    @@ -218,19 +228,29 @@ public function addCommentAction(Project $project, Request $request)
         }
     
         /**
    -     * @Route(path="/{id}/comment_pin", name="project_comment_pin", methods={"GET"})
    +     * @Route(path="/{id}/comment_pin/{token}", name="project_comment_pin", methods={"GET"})
          * @Security("is_granted('edit', comment.getProject()) and is_granted('comments', comment.getProject())")
          */
    -    public function pinCommentAction(ProjectComment $comment)
    +    public function pinCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
         {
    +        $projectId = $comment->getProject()->getId();
    +
    +        if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.pin_comment', $token))) {
    +            $this->flashError('action.csrf.error');
    +
    +            return $this->redirectToRoute('project_details', ['id' => $projectId]);
    +        }
    +
    +        $csrfTokenManager->refreshToken($token);
    +
             $comment->setPinned(!$comment->isPinned());
             try {
                 $this->repository->saveComment($comment);
             } catch (\Exception $ex) {
                 $this->flashUpdateException($ex);
             }
     
    -        return $this->redirectToRoute('project_details', ['id' => $comment->getProject()->getId()]);
    +        return $this->redirectToRoute('project_details', ['id' => $projectId]);
         }
     
         /**
    
  • src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php+1 1 modified
    @@ -36,7 +36,7 @@ public function onActions(PageActionsEvent $event): void
                 }
                 $event->addAction('edit', ['url' => $this->path('admin_invoice_template_edit', ['id' => $template->getId()]), 'class' => 'modal-ajax-form']);
                 $event->addAction('copy', ['url' => $this->path('admin_invoice_template_copy', ['id' => $template->getId()])]);
    -            $event->addDelete($this->path('admin_invoice_template_delete', ['id' => $template->getId()]), false);
    +            $event->addDelete($this->path('admin_invoice_template_delete', ['id' => $template->getId(), 'token' => $payload['token']]), false);
             }
         }
     }
    
  • templates/customer/details.html.twig+1 1 modified
    @@ -139,7 +139,7 @@
         {% if comments is not null %}
             {% set options = {'form': commentForm, 'comments': comments} %}
             {% if can_edit %}
    -            {% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete'}) %}
    +            {% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete', 'csrf_delete': 'customer.delete_comment', 'csrf_pin': 'customer.pin_comment'}) %}
             {% endif %}
             {{ include('embeds/comments.html.twig', options) }}
         {% endif %}
    
  • templates/embeds/comments.html.twig+3 3 modified
    @@ -1,4 +1,4 @@
    -{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null), 'delete_by_user': delete_by_user|default(false)} %}
    +{% embed '@AdminLTE/Widgets/box-widget.html.twig' with {'form': form, 'comments': comments, 'route_pin': route_pin|default(null), 'route_delete': route_delete|default(null), 'delete_by_user': delete_by_user|default(false), 'csrf_delete': csrf_token(csrf_delete), 'csrf_pin': csrf_token(csrf_pin)} %}
         {% import "macros/widgets.html.twig" as widgets %}
         {% block box_title %}{{ 'label.comment'|trans }}{% endblock %}
         {% block box_attributes %}id="comments_box"{% endblock %}
    @@ -24,12 +24,12 @@
                             </span>
                             <span class="pull-right">
                             {% if route_pin is not null %}
    -                            <a href="{{ path(route_pin, {'id': comment.id}) }}" class="btn btn-default btn-xs {% if comment.pinned %}active{% endif %}"><i class="{{ 'pin'|icon }}"></i></a>
    +                            <a href="{{ path(route_pin, {'id': comment.id, 'token': csrf_pin}) }}" class="btn btn-default btn-xs {% if comment.pinned %}active{% endif %}"><i class="{{ 'pin'|icon }}"></i></a>
                             {% elseif comment.pinned %}
                                 <i class="{{ 'pin'|icon }}"></i>
                             {% endif %}
                             {% if route_delete is not null and ((not delete_by_user) or (delete_by_user and comment.createdBy.id == app.user.id)) %}
    -                            <a href="{{ path(route_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
    +                            <a href="{{ path(route_delete, {'id': comment.id, 'token': csrf_delete}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
                             {% endif %}
                             </span>
                         </div>
    
  • templates/invoice/actions.html.twig+1 1 modified
    @@ -30,6 +30,6 @@
     
     {% macro invoice_template(template, view) %}
         {% import "macros/widgets.html.twig" as widgets %}
    -    {% set event = actions(app.user, 'invoice_template', view, {'template': template}) %}
    +    {% set event = actions(app.user, 'invoice_template', view, {'template': template, 'token': csrf_token('invoice.delete_template')}) %}
         {{ widgets.table_actions(event.actions) }}
     {% endmacro %}
    
  • templates/project/details.html.twig+1 1 modified
    @@ -144,7 +144,7 @@
         {% if comments is not null %}
             {% set options = {'form': commentForm, 'comments': comments} %}
             {% if can_edit %}
    -            {% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete'}) %}
    +            {% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete', 'csrf_delete': 'project.delete_comment', 'csrf_pin': 'project.pin_comment'}) %}
             {% endif %}
             {{ include('embeds/comments.html.twig', options) }}
         {% endif %}
    
  • tests/Controller/CustomerControllerTest.php+30 4 modified
    @@ -176,21 +176,45 @@ public function testDeleteCommentAction()
             ]);
             $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
             $client->followRedirect();
    +
    +        $token = self::$container->get('security.csrf.token_manager')->getToken('customer.delete_comment');
    +
             $node = $client->getCrawler()->filter('div.box#comments_box .direct-chat-msg');
             self::assertStringContainsString('Blah foo bar', $node->html());
             $node = $client->getCrawler()->filter('div.box#comments_box .box-body a.confirmation-link');
    -        self::assertStringEndsWith('/comment_delete', $node->attr('href'));
    +        self::assertStringEndsWith('/comment_delete/' . $token, $node->attr('href'));
     
             $comments = $this->getEntityManager()->getRepository(CustomerComment::class)->findAll();
             $id = $comments[0]->getId();
     
    -        $this->request($client, '/admin/customer/' . $id . '/comment_delete');
    +        $this->request($client, '/admin/customer/' . $id . '/comment_delete/' . $token);
             $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
             $client->followRedirect();
             $node = $client->getCrawler()->filter('div.box#comments_box .box-body');
             self::assertStringContainsString('There were no comments posted yet', $node->html());
         }
     
    +    public function testDeleteCommentActionWithoutToken()
    +    {
    +        $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
    +        $this->assertAccessIsGranted($client, '/admin/customer/1/details');
    +        $form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
    +        $client->submit($form, [
    +            'customer_comment_form' => [
    +                'message' => 'Blah foo bar',
    +            ]
    +        ]);
    +        $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
    +        $client->followRedirect();
    +
    +        $comments = $this->getEntityManager()->getRepository(CustomerComment::class)->findAll();
    +        $id = $comments[0]->getId();
    +
    +        $this->request($client, '/admin/customer/' . $id . '/comment_delete');
    +
    +        $this->assertRouteNotFound($client);
    +    }
    +
         public function testPinCommentAction()
         {
             $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
    @@ -211,12 +235,14 @@ public function testPinCommentAction()
             $comments = $this->getEntityManager()->getRepository(CustomerComment::class)->findAll();
             $id = $comments[0]->getId();
     
    -        $this->request($client, '/admin/customer/' . $id . '/comment_pin');
    +        $token = self::$container->get('security.csrf.token_manager')->getToken('customer.pin_comment');
    +
    +        $this->request($client, '/admin/customer/' . $id . '/comment_pin/' . $token);
             $this->assertIsRedirect($client, $this->createUrl('/admin/customer/1/details'));
             $client->followRedirect();
             $node = $client->getCrawler()->filter('div.box#comments_box .box-body a.btn.active');
             self::assertEquals(1, $node->count());
    -        self::assertEquals($this->createUrl('/admin/customer/' . $id . '/comment_pin'), $node->attr('href'));
    +        self::assertEquals($this->createUrl('/admin/customer/' . $id . '/comment_pin/' . $token), $node->attr('href'));
         }
     
         public function testCreateDefaultTeamAction()
    
  • tests/Controller/InvoiceControllerTest.php+3 1 modified
    @@ -395,7 +395,9 @@ public function testDeleteTemplateAction()
             $template = $this->importFixture($fixture);
             $id = $template[0]->getId();
     
    -        $this->request($client, '/invoice/template/' . $id . '/delete');
    +        $token = self::$container->get('security.csrf.token_manager')->getToken('invoice.delete_template');
    +
    +        $this->request($client, '/invoice/template/' . $id . '/delete/' . $token);
             $this->assertIsRedirect($client, '/invoice/template');
             $client->followRedirect();
     
    
  • tests/Controller/ProjectControllerTest.php+8 4 modified
    @@ -261,8 +261,10 @@ public function testDeleteCommentAction()
             $comments = $this->getEntityManager()->getRepository(ProjectComment::class)->findAll();
             $id = $comments[0]->getId();
     
    -        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_delete'), $node->attr('href'));
    -        $this->request($client, '/admin/project/' . $id . '/comment_delete');
    +        $token = self::$container->get('security.csrf.token_manager')->getToken('project.delete_comment');
    +
    +        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_delete/' . $token), $node->attr('href'));
    +        $this->request($client, '/admin/project/' . $id . '/comment_delete/' . $token);
             $this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
             $client->followRedirect();
             $node = $client->getCrawler()->filter('div.box#comments_box .box-body');
    @@ -289,12 +291,14 @@ public function testPinCommentAction()
             $comments = $this->getEntityManager()->getRepository(ProjectComment::class)->findAll();
             $id = $comments[0]->getId();
     
    -        $this->request($client, '/admin/project/' . $id . '/comment_pin');
    +        $token = self::$container->get('security.csrf.token_manager')->getToken('project.pin_comment');
    +
    +        $this->request($client, '/admin/project/' . $id . '/comment_pin/' . $token);
             $this->assertIsRedirect($client, $this->createUrl('/admin/project/1/details'));
             $client->followRedirect();
             $node = $client->getCrawler()->filter('div.box#comments_box .box-body a.btn.active');
             self::assertEquals(1, $node->count());
    -        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_pin'), $node->attr('href'));
    +        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_pin/' . $token), $node->attr('href'));
         }
     
         public function testCreateDefaultTeamAction()
    
  • translations/flashmessages.de.xlf+4 0 modified
    @@ -54,6 +54,10 @@
             <source>action.upload.error</source>
             <target>Die Datei konnte nicht hochgeladen bzw. gespeichert werden: %reason%</target>
           </trans-unit>
    +      <trans-unit resname="action.csrf.error" id="bOE_q5R">
    +        <source>action.csrf.error</source>
    +        <target>Die Aktion konnte nicht durchgeführt werden: ungültiges Sicherheitstoken.</target>
    +      </trans-unit>
         </body>
       </file>
     </xliff>
    
  • translations/flashmessages.en.xlf+4 0 modified
    @@ -54,6 +54,10 @@
             <source>action.upload.error</source>
             <target>The file could not be uploaded or saved: %reason%</target>
           </trans-unit>
    +      <trans-unit resname="action.csrf.error" id="bOE_q5R">
    +        <source>action.csrf.error</source>
    +        <target>The action could not be performed: invalid security token.</target>
    +      </trans-unit>
         </body>
       </file>
     </xliff>
    

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.