CVE-2023-41336: Improper Input Validation (CWE-20)
value: ux-autocomplete is a JavaScript Autocomplete functionality for Symfony. Under certain circumstances, an attacker could successfully submit an entity id for an `EntityType` that is *not* part of the valid choices. The problem has been fixed in `symfony/ux-autocomplete` version 2.11.2.
CWE description: The product receives input or data, but it does
        not validate or incorrectly validates that the input has the
        properties that are required to process the data safely and
        correctly.
CVSS v3 base score: 6.5
Published: 2023-09-11T20:15Z
Repository: https://github.com/symfony/ux-autocomplete
Language: PHP
Files changed: src/Form/AutocompleteEntityTypeSubscriber.php, tests/Functional/AutocompleteFormRenderingTest.php
Commit date: 2023-09-11 08:51:28 -0400
Commit message: Fixing autocomplete security bug by using the query_builder

Diff: diff --git a/src/Form/AutocompleteEntityTypeSubscriber.php b/src/Form/AutocompleteEntityTypeSubscriber.php
index 4349dc3..3b774b9 100644
--- a/src/Form/AutocompleteEntityTypeSubscriber.php
+++ b/src/Form/AutocompleteEntityTypeSubscriber.php
@@ -9,13 +9,11 @@
  * file that was distributed with this source code.
  */
 
 namespace Symfony\UX\Autocomplete\Form;
 
-use Doctrine\Common\Collections\ArrayCollection;
 use Doctrine\ORM\EntityManagerInterface;
-use Doctrine\ORM\Query\Parameter;
 use Doctrine\ORM\Utility\PersisterHelper;
 use Symfony\Bridge\Doctrine\Form\Type\EntityType;
 use Symfony\Component\EventDispatcher\EventSubscriberInterface;
 use Symfony\Component\Form\FormEvent;
 use Symfony\Component\Form\FormEvents;
@@ -52,41 +50,44 @@ final class AutocompleteEntityTypeSubscriber implements EventSubscriberInterface
     {
         $data = $event->getData();
         $form = $event->getForm();
         $options = $form->get('autocomplete')->getConfig()->getOptions();
 
+        /** @var EntityManagerInterface $em */
+        $em = $options['em'];
+        $repository = $em->getRepository($options['class']);
+        $queryBuilder = $options['query_builder'] ?: $repository->createQueryBuilder('o');
+        $rootAlias = $queryBuilder->getRootAliases()[0];
+
         if (!isset($data['autocomplete']) || '' === $data['autocomplete']) {
             $options['choices'] = [];
         } else {
-            /** @var EntityManagerInterface $em */
-            $em = $options['em'];
-            $repository = $em->getRepository($options['class']);
-
             $idField = $options['id_reader']->getIdField();
             $idType = PersisterHelper::getTypeOfField($idField, $em->getClassMetadata($options['class']), $em)[0];
 
             if ($options['multiple']) {
                 $params = [];
                 $idx = 0;
 
                 foreach ($data['autocomplete'] as $id) {
-                    $params[":id_$idx"] = new Parameter("id_$idx", $id, $idType);
+                    $params[":id_$idx"] = [$id, $idType];
                     ++$idx;
                 }
 
-                $queryBuilder = $repository->createQueryBuilder('o');
-
                 if ($params) {
                     $queryBuilder
-                        ->where(sprintf("o.$idField IN (%s)", implode(', ', array_keys($params))))
-                        ->setParameters(new ArrayCollection($params));
+                        ->andWhere(sprintf("$rootAlias.$idField IN (%s)", implode(', ', array_keys($params))))
+                    ;
+                    foreach ($params as $key => $param) {
+                        $queryBuilder->setParameter($key, $param[0], $param[1]);
+                    }
                 }
 
                 $options['choices'] = $queryBuilder->getQuery()->getResult();
             } else {
-                $options['choices'] = $repository->createQueryBuilder('o')
-                    ->where("o.$idField = :id")
+                $options['choices'] = $queryBuilder
+                    ->andWhere("$rootAlias.$idField = :id")
                     ->setParameter('id', $data['autocomplete'], $idType)
                     ->getQuery()
                     ->getResult();
             }
         }
diff --git a/tests/Functional/AutocompleteFormRenderingTest.php b/tests/Functional/AutocompleteFormRenderingTest.php
index 0e8c2da..7cbef51 100644
--- a/tests/Functional/AutocompleteFormRenderingTest.php
+++ b/tests/Functional/AutocompleteFormRenderingTest.php
@@ -44,25 +44,38 @@ class AutocompleteFormRenderingTest extends KernelTestCase
     public function testCategoryFieldSubmitsCorrectly()
     {
         $firstCat = CategoryFactory::createOne(['name' => 'First cat']);
         CategoryFactory::createOne(['name' => 'in space']);
         CategoryFactory::createOne(['name' => 'ate pizza']);
+        $fooCat = CategoryFactory::createOne(['name' => 'I contain "foo" which CategoryAutocompleteType uses its query_builder option.']);
 
         $this->browser()
             ->throwExceptions()
             ->get('/test-form')
             // the field renders empty (but the placeholder is there)
             ->assertElementCount('#product_category_autocomplete option', 1)
             ->assertNotContains('First cat')
+
             ->post('/test-form', [
                 'body' => [
                     'product' => ['category' => ['autocomplete' => $firstCat->getId()]],
                 ],
             ])
+            // the option does NOT match something returned by query_builder
+            // so ONLY the placeholder shows up
+            ->assertElementCount('#product_category_autocomplete option', 1)
+            ->assertNotContains('First cat')
+
+            ->assertNotContains('First cat')
+            ->post('/test-form', [
+                'body' => [
+                    'product' => ['category' => ['autocomplete' => $fooCat->getId()]],
+                ],
+            ])
             // the one option + placeholder now shows up
             ->assertElementCount('#product_category_autocomplete option', 2)
-            ->assertContains('First cat')
+            ->assertContains('which CategoryAutocompleteType uses')
         ;
     }
 
     public function testProperlyLoadsChoicesWithIdValueObjects()
     {

Vulnerable code: use Doctrine\Common\Collections\ArrayCollection;
use Doctrine\ORM\Query\Parameter;
            /** @var EntityManagerInterface $em */
            $em = $options['em'];
            $repository = $em->getRepository($options['class']);

                    $params[":id_$idx"] = new Parameter("id_$idx", $id, $idType);
                $queryBuilder = $repository->createQueryBuilder('o');

                        ->where(sprintf("o.$idField IN (%s)", implode(', ', array_keys($params))))
                        ->setParameters(new ArrayCollection($params));
                $options['choices'] = $repository->createQueryBuilder('o')
                    ->where("o.$idField = :id")
            ->assertContains('First cat')
Fixed code:         /** @var EntityManagerInterface $em */
        $em = $options['em'];
        $repository = $em->getRepository($options['class']);
        $queryBuilder = $options['query_builder'] ?: $repository->createQueryBuilder('o');
        $rootAlias = $queryBuilder->getRootAliases()[0];

                    $params[":id_$idx"] = [$id, $idType];
                        ->andWhere(sprintf("$rootAlias.$idField IN (%s)", implode(', ', array_keys($params))))
                    ;
                    foreach ($params as $key => $param) {
                        $queryBuilder->setParameter($key, $param[0], $param[1]);
                    }
                $options['choices'] = $queryBuilder
                    ->andWhere("$rootAlias.$idField = :id")
        $fooCat = CategoryFactory::createOne(['name' => 'I contain "foo" which CategoryAutocompleteType uses its query_builder option.']);

            // the option does NOT match something returned by query_builder
            // so ONLY the placeholder shows up
            ->assertElementCount('#product_category_autocomplete option', 1)
            ->assertNotContains('First cat')

            ->assertNotContains('First cat')
            ->post('/test-form', [
                'body' => [
                    'product' => ['category' => ['autocomplete' => $fooCat->getId()]],
                ],
            ])
            ->assertContains('which CategoryAutocompleteType uses')