CVE-2021-3963: Cross-Site Request Forgery (CSRF) (CWE-352)
value: kimai2 is vulnerable to Cross-Site Request Forgery (CSRF)
CWE description: 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.
CVSS v3 base score: 4.3
Published: 2021-11-19T12:15Z
Repository: https://github.com/kevinpapst/kimai2
Language: PHP
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 date: 2021-11-16 10:17:26 +0100
Commit message: improve csrf handling (#2936)


Diff: diff --git a/src/Controller/CustomerController.php b/src/Controller/CustomerController.php
index 837884b0..c9f5b76c 100644
--- a/src/Controller/CustomerController.php
+++ b/src/Controller/CustomerController.php
@@ -39,10 +39,12 @@ use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 use Symfony\Component\Form\FormInterface;
 use Symfony\Component\HttpFoundation\RedirectResponse;
 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.
  *
  * @Route(path="/admin/customer")
@@ -155,17 +157,25 @@ final class CustomerController extends AbstractController
             'form' => $form->createView()
         ]);
     }
 
     /**
-     * @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) {
             $this->flashDeleteException($ex);
         }
@@ -194,23 +204,33 @@ final class CustomerController extends AbstractController
 
         return $this->redirectToRoute('customer_details', ['id' => $customer->getId()]);
     }
 
     /**
-     * @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]);
     }
 
     /**
      * @Route(path="/{id}/create_team", name="customer_team_create", methods={"GET"})
      * @Security("is_granted('create_team') and is_granted('permissions', customer)")
diff --git a/src/Controller/DoctorController.php b/src/Controller/DoctorController.php
index 7e1b5625..83e72355 100644
--- a/src/Controller/DoctorController.php
+++ b/src/Controller/DoctorController.php
@@ -62,11 +62,11 @@ class DoctorController extends AbstractController
      * @Security("is_granted('system_configuration')")
      */
     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');
         }
 
         $csrfTokenManager->refreshToken($token);
diff --git a/src/Controller/InvoiceController.php b/src/Controller/InvoiceController.php
index 1d160217..03d53949 100644
--- a/src/Controller/InvoiceController.php
+++ b/src/Controller/InvoiceController.php
@@ -258,11 +258,11 @@ final class InvoiceController extends AbstractController
      * @Route(path="/delete/{id}/{token}", name="admin_invoice_delete", methods={"GET"})
      */
     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');
         }
 
         $csrfTokenManager->refreshToken($token);
@@ -449,15 +449,23 @@ final class InvoiceController extends AbstractController
 
         return $this->renderTemplateForm($template, $request);
     }
 
     /**
-     * @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');
         } catch (Exception $ex) {
             $this->flashDeleteException($ex);
diff --git a/src/Controller/PermissionController.php b/src/Controller/PermissionController.php
index a0ba581d..3d7df9e2 100644
--- a/src/Controller/PermissionController.php
+++ b/src/Controller/PermissionController.php
@@ -207,11 +207,11 @@ final class PermissionController extends AbstractController
      * @Security("is_granted('role_permissions')")
      */
     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');
         }
 
         // make sure that the token can only be used once, so refresh it after successful submission
diff --git a/src/Controller/ProjectController.php b/src/Controller/ProjectController.php
index 39d07e70..c2800c6a 100644
--- a/src/Controller/ProjectController.php
+++ b/src/Controller/ProjectController.php
@@ -41,10 +41,12 @@ use Pagerfanta\Pagerfanta;
 use Sensio\Bundle\FrameworkExtraBundle\Configuration\Security;
 use Symfony\Component\EventDispatcher\EventDispatcherInterface;
 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.
  *
  * @Route(path="/admin/project")
@@ -177,17 +179,25 @@ final class ProjectController extends AbstractController
             'form' => $editForm->createView()
         ]);
     }
 
     /**
-     * @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) {
             $this->flashDeleteException($ex);
         }
@@ -216,23 +226,33 @@ final class ProjectController extends AbstractController
 
         return $this->redirectToRoute('project_details', ['id' => $project->getId()]);
     }
 
     /**
-     * @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]);
     }
 
     /**
      * @Route(path="/{id}/create_team", name="project_team_create", methods={"GET"})
      * @Security("is_granted('create_team') and is_granted('edit', project)")
diff --git a/src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php b/src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php
index 0ccf3868..f7a8281b 100644
--- a/src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php
+++ b/src/EventSubscriber/Actions/InvoiceTemplateSubscriber.php
@@ -34,9 +34,9 @@ class InvoiceTemplateSubscriber extends AbstractActionsSubscriber
             if (!$event->isIndexView()) {
                 $event->addBack($this->path('invoice'));
             }
             $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);
         }
     }
 }
diff --git a/templates/customer/details.html.twig b/templates/customer/details.html.twig
index f200d09c..097c4445 100644
--- a/templates/customer/details.html.twig
+++ b/templates/customer/details.html.twig
@@ -137,11 +137,11 @@
     {% endif %}
 
     {% 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 %}
 
 {% endblock %}
diff --git a/templates/embeds/comments.html.twig b/templates/embeds/comments.html.twig
index 25084b82..99d97d43 100644
--- a/templates/embeds/comments.html.twig
+++ b/templates/embeds/comments.html.twig
@@ -1,6 +1,6 @@
-{% 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 %}
     {% block box_body_class %}box-body-scrollable{% endblock %}
     {% block box_body %}
@@ -22,16 +22,16 @@
                             {{ comment.createdAt|date_full }}
                             &nbsp;
                         </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>
                     {{ widgets.user_avatar(comment.createdBy, false, 'direct-chat-img img-sm') }}
                     <div class="direct-chat-text">
diff --git a/templates/invoice/actions.html.twig b/templates/invoice/actions.html.twig
index d1fa98c4..e654110c 100644
--- a/templates/invoice/actions.html.twig
+++ b/templates/invoice/actions.html.twig
@@ -28,8 +28,8 @@
     {{ widgets.page_actions(event.actions) }}
 {% endmacro %}
 
 {% 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 %}
diff --git a/templates/project/details.html.twig b/templates/project/details.html.twig
index 1936551c..a4d2ec4b 100644
--- a/templates/project/details.html.twig
+++ b/templates/project/details.html.twig
@@ -142,11 +142,11 @@
     {% endif %}
 
     {% 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 %}
 
 {% endblock %}
diff --git a/tests/Controller/CustomerControllerTest.php b/tests/Controller/CustomerControllerTest.php
index f1d9f2cb..2d319e22 100644
--- a/tests/Controller/CustomerControllerTest.php
+++ b/tests/Controller/CustomerControllerTest.php
@@ -174,25 +174,49 @@ class CustomerControllerTest extends ControllerBaseTest
                 'message' => 'Blah foo bar',
             ]
         ]);
         $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);
         $this->assertAccessIsGranted($client, '/admin/customer/1/details');
         $form = $client->getCrawler()->filter('form[name=customer_comment_form]')->form();
@@ -209,16 +233,18 @@ class CustomerControllerTest extends ControllerBaseTest
         self::assertEquals(0, $node->count());
 
         $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()
     {
         $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
diff --git a/tests/Controller/InvoiceControllerTest.php b/tests/Controller/InvoiceControllerTest.php
index 56cdafd9..442f18c3 100644
--- a/tests/Controller/InvoiceControllerTest.php
+++ b/tests/Controller/InvoiceControllerTest.php
@@ -393,11 +393,13 @@ class InvoiceControllerTest extends ControllerBaseTest
 
         $fixture = new InvoiceTemplateFixtures();
         $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();
 
         $this->assertTrue($client->getResponse()->isSuccessful());
         $this->assertHasFlashSuccess($client);
diff --git a/tests/Controller/ProjectControllerTest.php b/tests/Controller/ProjectControllerTest.php
index 98dd5882..a14a34ba 100644
--- a/tests/Controller/ProjectControllerTest.php
+++ b/tests/Controller/ProjectControllerTest.php
@@ -259,12 +259,14 @@ class ProjectControllerTest extends ControllerBaseTest
         $node = $client->getCrawler()->filter('div.box#comments_box .box-body a.confirmation-link');
 
         $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');
         self::assertStringContainsString('There were no comments posted yet', $node->html());
     }
@@ -287,16 +289,18 @@ class ProjectControllerTest extends ControllerBaseTest
         self::assertEquals(0, $node->count());
 
         $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()
     {
         $client = $this->getClientForAuthenticatedUser(User::ROLE_ADMIN);
diff --git a/translations/flashmessages.de.xlf b/translations/flashmessages.de.xlf
index 55bbd83e..dbe4f2f1 100644
--- a/translations/flashmessages.de.xlf
+++ b/translations/flashmessages.de.xlf
@@ -52,8 +52,12 @@
       </trans-unit>
       <trans-unit id="wejAEcR" resname="action.upload.error">
         <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>
diff --git a/translations/flashmessages.en.xlf b/translations/flashmessages.en.xlf
index 3eee9f3c..8a86bf99 100644
--- a/translations/flashmessages.en.xlf
+++ b/translations/flashmessages.en.xlf
@@ -52,8 +52,12 @@
       </trans-unit>
       <trans-unit id="wejAEcR" resname="action.upload.error">
         <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>

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>