CVE-2021-3963 (PHP, published 2021-11-19T12:15Z)
Description: value: kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)
Severity: MEDIUM (CVSS v3 base score: 4.3)
Weakness: CWE-352 \u2014 Cross-Site Request Forgery (CSRF)
The web application does not, or can not, sufficiently verify whether a well-formed, valid, consistent request was intentionally provided by the user who submitted the request.
Repository: https://github.com/kevinpapst/kimai2
Files changed:
src/Controller/CustomerController.php
src/Controller/DoctorController.php
src/Controller/InvoiceController.php
src/Controller/PermissionController.php
src/Controller/ProjectController.php
src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php
templates/customer/details.html.twig
templates/embeds/comments.html.twig
templates/invoice/actions.html.twig
templates/project/details.html.twig
tests/Controller/CustomerControllerTest.php
tests/Controller/InvoiceControllerTest.php
tests/Controller/ProjectControllerTest.php
translations/flashmessages.de.xlf
translations/flashmessages.en.xlf
Commit message: improve csrf handling (#2936)



Vulnerable code:
     * @Route(path="/{id}/comment_delete", name="customer_comment_delete", methods={"GET"})
    public function deleteCommentAction(CustomerComment $comment)
     * @Route(path="/{id}/comment_pin", name="customer_comment_pin", methods={"GET"})
    public function pinCommentAction(CustomerComment $comment)
        return $this->redirectToRoute('customer_details', ['id' => $comment->getCustomer()->getId()]);
            $this->flashError('action.delete.error');
            $this->flashError('action.delete.error');
     * @Route(path="/template/{id}/delete", name="admin_invoice_template_delete", methods={"GET", "POST"})
    public function deleteTemplate(InvoiceTemplate $template): Response
            $this->flashUpdateException(new \Exception('Invalid CSRF token'));
     * @Route(path="/{id}/comment_delete", name="project_comment_delete", methods={"GET"})
    public function deleteCommentAction(ProjectComment $comment)
     * @Route(path="/{id}/comment_pin", name="project_comment_pin", methods={"GET"})
    public function pinCommentAction(ProjectComment $comment)
        return $this->redirectToRoute('project_details', ['id' => $comment->getProject()->getId()]);
            $event->addDelete($this->path('admin_invoice_template_delete', ['id' => $template->getId()]), false);
            {% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete'}) %}
{% 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)} %}
                            <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_delete, {'id': comment.id}) }}" class="confirmation-link btn btn-default btn-xs" data-question="confirm.delete"><i class="{{ 'delete'|icon }}"></i></a>
    {% set event = actions(app.user, 'invoice_template', view, {'template': template}) %}
            {% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete'}) %}
        self::assertStringEndsWith('/comment_delete', $node->attr('href'));
        $this->request($client, '/admin/customer/' . $id . '/comment_delete');
        $this->request($client, '/admin/customer/' . $id . '/comment_pin');
        self::assertEquals($this->createUrl('/admin/customer/' . $id . '/comment_pin'), $node->attr('href'));
        $this->request($client, '/invoice/template/' . $id . '/delete');
        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_delete'), $node->attr('href'));
        $this->request($client, '/admin/project/' . $id . '/comment_delete');
        $this->request($client, '/admin/project/' . $id . '/comment_pin');
        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_pin'), $node->attr('href'));

Fixed code:
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
     * @Route(path="/{id}/comment_delete/{token}", name="customer_comment_delete", methods={"GET"})
    public function deleteCommentAction(CustomerComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
        if (!$csrfTokenManager->isTokenValid(new CsrfToken('customer.delete_comment', $token))) {
            $this->flashError('action.csrf.error');

            return $this->redirectToRoute('customer_details', ['id' => $customerId]);
        }

        $csrfTokenManager->refreshToken($token);

     * @Route(path="/{id}/comment_pin/{token}", name="customer_comment_pin", methods={"GET"})
    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);

        return $this->redirectToRoute('customer_details', ['id' => $customerId]);
            $this->flashError('action.csrf.error');
            $this->flashError('action.csrf.error');
     * @Route(path="/template/{id}/delete/{token}", name="admin_invoice_template_delete", methods={"GET", "POST"})
    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);

            $this->flashError('action.csrf.error');
use Symfony\Component\Security\Csrf\CsrfToken;
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
     * @Route(path="/{id}/comment_delete/{token}", name="project_comment_delete", methods={"GET"})
    public function deleteCommentAction(ProjectComment $comment, string $token, CsrfTokenManagerInterface $csrfTokenManager)
        if (!$csrfTokenManager->isTokenValid(new CsrfToken('project.delete_comment', $token))) {
            $this->flashError('action.csrf.error');

            return $this->redirectToRoute('project_details', ['id' => $projectId]);
        }

        $csrfTokenManager->refreshToken($token);

     * @Route(path="/{id}/comment_pin/{token}", name="project_comment_pin", methods={"GET"})
    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);

        return $this->redirectToRoute('project_details', ['id' => $projectId]);
            $event->addDelete($this->path('admin_invoice_template_delete', ['id' => $template->getId(), 'token' => $payload['token']]), false);
            {% set options = options|merge({'route_pin': 'customer_comment_pin', 'route_delete': 'customer_comment_delete', 'csrf_delete': 'customer.delete_comment', 'csrf_pin': 'customer.pin_comment'}) %}
{% 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)} %}
                            <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>
                            <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>
    {% set event = actions(app.user, 'invoice_template', view, {'template': template, 'token': csrf_token('invoice.delete_template')}) %}
            {% set options = options|merge({'route_pin': 'project_comment_pin', 'route_delete': 'project_comment_delete', 'csrf_delete': 'project.delete_comment', 'csrf_pin': 'project.pin_comment'}) %}

        $token = self::$container->get('security.csrf.token_manager')->getToken('customer.delete_comment');

        self::assertStringEndsWith('/comment_delete/' . $token, $node->attr('href'));
        $this->request($client, '/admin/customer/' . $id . '/comment_delete/' . $token);
    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);
    }

        $token = self::$container->get('security.csrf.token_manager')->getToken('customer.pin_comment');

        $this->request($client, '/admin/customer/' . $id . '/comment_pin/' . $token);
        self::assertEquals($this->createUrl('/admin/customer/' . $id . '/comment_pin/' . $token), $node->attr('href'));
        $token = self::$container->get('security.csrf.token_manager')->getToken('invoice.delete_template');

        $this->request($client, '/invoice/template/' . $id . '/delete/' . $token);
        $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);
        $token = self::$container->get('security.csrf.token_manager')->getToken('project.pin_comment');

        $this->request($client, '/admin/project/' . $id . '/comment_pin/' . $token);
        self::assertEquals($this->createUrl('/admin/project/' . $id . '/comment_pin/' . $token), $node->attr('href'));
      <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>
      <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>