+ "details": "### Impact\nAn authenticated Insecure Direct Object Reference (IDOR) vulnerability exists in multiple shop LiveComponents due to unvalidated resource IDs accepted via `#[LiveArg]` parameters. Unlike props, which are protected by LiveComponent's `@checksum`, `args` are fully user-controlled - any action that accepts a resource ID via `#[LiveArg]` and loads it with `->find()` without ownership validation is vulnerable.\n\nCheckout address **FormComponent** (`addressFieldUpdated` action): Accepts an `addressId` via `#[LiveArg]` and loads it without verifying ownership, exposing another user's first name, last name, company, phone number, street, city, postcode, and country.\n\nCart **WidgetComponent** (`refreshCart` action): Accepts a `cartId` via `#[LiveArg]` and loads any order directly from the repository, exposing order total and item count.\n\nCart **SummaryComponent** (`refreshCart` action): Accepts a `cartId` via `#[LiveArg]` and loads any order directly from the repository, exposing subtotal, discount, shipping cost, taxes (excluded and included), and order total.\n\nSince `sylius_order` contains both active carts (`state=cart`) and completed orders (`state=new/fulfilled`) in the same ID space, the cart IDOR exposes data from all orders, not just active carts.\n\n### Patches\nThe issue is fixed in versions: 2.0.16, 2.1.12, 2.2.3 and above.\n\n### Workarounds\n\nOverride vulnerable LiveComponent classes at the project level to add authorization checks to `#[LiveArg]` parameters.\n\n#### Step 1. Exclude component overrides from default autowiring\n\nIn `config/services.yaml`, add `Twig/Component` to the exclude list to prevent duplicate service registration:\n\n```yaml\nApp\\:\n resource: '../src/*'\n exclude: '../src/{Entity,Kernel.php,Twig/Components}'\n```\n\n#### Step 2. Override checkout address FormComponent\n\nCreate `src/Twig/Components/Checkout/Address/FormComponent.php`:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components\\Checkout\\Address;\n\nuse Sylius\\Bundle\\ShopBundle\\Twig\\Component\\Checkout\\Address\\AddressBookComponent;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\ResourceFormComponentTrait;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\TemplatePropTrait;\nuse Sylius\\Component\\Core\\Model\\OrderInterface;\nuse Sylius\\Component\\Core\\Model\\ShopUserInterface;\nuse Sylius\\Component\\Core\\Repository\\AddressRepositoryInterface;\nuse Sylius\\Component\\Core\\Repository\\OrderRepositoryInterface;\nuse Sylius\\Component\\Customer\\Context\\CustomerContextInterface;\nuse Sylius\\Component\\User\\Repository\\UserRepositoryInterface;\nuse Symfony\\Component\\Form\\FormFactoryInterface;\nuse Symfony\\Component\\Form\\FormInterface;\nuse Symfony\\UX\\LiveComponent\\Attribute\\AsLiveComponent;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveArg;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveListener;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveProp;\nuse Symfony\\UX\\LiveComponent\\Attribute\\PreReRender;\n\n#[AsLiveComponent]\nclass FormComponent\n{\n /** @use ResourceFormComponentTrait<OrderInterface> */\n use ResourceFormComponentTrait;\n use TemplatePropTrait;\n\n #[LiveProp]\n public bool $emailExists = false;\n\n /**\n * @param OrderRepositoryInterface<OrderInterface> $repository\n * @param UserRepositoryInterface<ShopUserInterface> $shopUserRepository\n */\n public function __construct(\n OrderRepositoryInterface $repository,\n FormFactoryInterface $formFactory,\n string $resourceClass,\n string $formClass,\n protected readonly CustomerContextInterface $customerContext,\n protected readonly UserRepositoryInterface $shopUserRepository,\n protected readonly AddressRepositoryInterface $addressRepository,\n ) {\n $this->initialize($repository, $formFactory, $resourceClass, $formClass);\n }\n\n #[PreReRender(priority: -100)]\n public function checkEmailExist(): void\n {\n $email = $this->formValues['customer']['email'] ?? null;\n if (null !== $email) {\n $this->emailExists = $this->shopUserRepository->findOneByEmail($email) !== null;\n }\n }\n\n #[LiveListener(AddressBookComponent::SYLIUS_SHOP_ADDRESS_UPDATED)]\n public function addressFieldUpdated(#[LiveArg] mixed $addressId, #[LiveArg] string $field): void\n {\n $customer = $this->customerContext->getCustomer();\n if (null === $customer) {\n return;\n }\n\n // Fix: findOneByCustomer instead of find — validates ownership\n $address = $this->addressRepository->findOneByCustomer((string) $addressId, $customer);\n if (null === $address) {\n return;\n }\n\n $newAddress = [];\n $newAddress['firstName'] = $address->getFirstName();\n $newAddress['lastName'] = $address->getLastName();\n $newAddress['phoneNumber'] = $address->getPhoneNumber();\n $newAddress['company'] = $address->getCompany();\n $newAddress['countryCode'] = $address->getCountryCode();\n if ($address->getProvinceCode() !== null) {\n $newAddress['provinceCode'] = $address->getProvinceCode();\n }\n if ($address->getProvinceName() !== null) {\n $newAddress['provinceName'] = $address->getProvinceName();\n }\n $newAddress['street'] = $address->getStreet();\n $newAddress['city'] = $address->getCity();\n $newAddress['postcode'] = $address->getPostcode();\n\n $this->formValues[$field] = $newAddress;\n }\n\n protected function instantiateForm(): FormInterface\n {\n return $this->formFactory->create(\n $this->formClass,\n $this->resource,\n ['customer' => $this->customerContext->getCustomer()],\n );\n }\n}\n```\n\n#### Step 3. Override cart WidgetComponent\n\nCreate `src/Twig/Components/Cart/WidgetComponent.php`:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components\\Cart;\n\nuse Sylius\\Bundle\\ShopBundle\\Twig\\Component\\Cart\\FormComponent;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\ResourceLivePropTrait;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\TemplatePropTrait;\nuse Sylius\\Component\\Core\\Model\\OrderInterface;\nuse Sylius\\Component\\Core\\Repository\\OrderRepositoryInterface;\nuse Sylius\\Component\\Order\\Context\\CartContextInterface;\nuse Sylius\\Component\\Order\\Context\\CartNotFoundException;\nuse Sylius\\Resource\\Model\\ResourceInterface;\nuse Sylius\\TwigHooks\\LiveComponent\\HookableLiveComponentTrait;\nuse Symfony\\UX\\LiveComponent\\Attribute\\AsLiveComponent;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveArg;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveListener;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveProp;\nuse Symfony\\UX\\LiveComponent\\DefaultActionTrait;\nuse Symfony\\UX\\TwigComponent\\Attribute\\PreMount;\n\n#[AsLiveComponent]\nclass WidgetComponent\n{\n use DefaultActionTrait;\n use HookableLiveComponentTrait;\n use TemplatePropTrait;\n\n /** @use ResourceLivePropTrait<OrderInterface> */\n use ResourceLivePropTrait;\n\n #[LiveProp(hydrateWith: 'hydrateResource', dehydrateWith: 'dehydrateResource')]\n public ?ResourceInterface $cart = null;\n\n public function __construct(\n protected readonly CartContextInterface $cartContext,\n OrderRepositoryInterface $orderRepository,\n ) {\n $this->initialize($orderRepository);\n }\n\n #[PreMount]\n public function initializeCart(): void\n {\n $this->cart = $this->getCart();\n }\n\n #[LiveListener(FormComponent::SYLIUS_SHOP_CART_CHANGED)]\n #[LiveListener(FormComponent::SYLIUS_SHOP_CART_CLEARED)]\n public function refreshCart(#[LiveArg] mixed $cartId = null): void\n {\n // Fix: ignore user-supplied cartId, always load from session\n $this->cart = $this->getCart();\n }\n\n private function getCart(): ?OrderInterface\n {\n try {\n return $this->cartContext->getCart();\n } catch (CartNotFoundException) {\n return null;\n }\n\n return $cart;\n }\n}\n```\n\n#### Step 4. Override cart SummaryComponent\n\nCreate `src/Twig/Components/Cart/SummaryComponent.php`:\n\n```php\n<?php\n\ndeclare(strict_types=1);\n\nnamespace App\\Twig\\Components\\Cart;\n\nuse Sylius\\Bundle\\ShopBundle\\Twig\\Component\\Cart\\FormComponent;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\ResourceLivePropTrait;\nuse Sylius\\Bundle\\UiBundle\\Twig\\Component\\TemplatePropTrait;\nuse Sylius\\Component\\Core\\Model\\OrderInterface;\nuse Sylius\\Component\\Core\\Repository\\OrderRepositoryInterface;\nuse Sylius\\Resource\\Model\\ResourceInterface;\nuse Sylius\\TwigHooks\\LiveComponent\\HookableLiveComponentTrait;\nuse Symfony\\UX\\LiveComponent\\Attribute\\AsLiveComponent;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveArg;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveListener;\nuse Symfony\\UX\\LiveComponent\\Attribute\\LiveProp;\nuse Symfony\\UX\\LiveComponent\\DefaultActionTrait;\n\n#[AsLiveComponent]\nclass SummaryComponent\n{\n use DefaultActionTrait;\n use HookableLiveComponentTrait;\n\n /** @use ResourceLivePropTrait<OrderInterface> */\n use ResourceLivePropTrait;\n use TemplatePropTrait;\n\n #[LiveProp(hydrateWith: 'hydrateResource', dehydrateWith: 'dehydrateResource')]\n public ?ResourceInterface $cart = null;\n\n /** @param OrderRepositoryInterface<OrderInterface> $orderRepository */\n public function __construct(OrderRepositoryInterface $orderRepository)\n {\n $this->initialize($orderRepository);\n }\n\n #[LiveListener(FormComponent::SYLIUS_SHOP_CART_CHANGED)]\n public function refreshCart(#[LiveArg] mixed $cartId): void\n {\n // Fix: ignore user-supplied cartId, reload from checksummed cart prop\n if ($this->cart === null) {\n return;\n }\n\n $this->cart = $this->hydrateResource($this->cart->getId());\n }\n}\n```\n\n#### Step 5. Register overridden services\n\nIn `config/services.yaml`, add:\n\n```yaml\n sylius_shop.twig.component.checkout.address.form:\n class: App\\Twig\\Components\\Checkout\\Address\\FormComponent\n arguments:\n $repository: '@sylius.repository.order'\n $formFactory: '@form.factory'\n $resourceClass: '%sylius.model.order.class%'\n $formClass: 'Sylius\\Bundle\\ShopBundle\\Form\\Type\\Checkout\\AddressType'\n $customerContext: '@sylius.context.customer'\n $shopUserRepository: '@sylius.repository.shop_user'\n $addressRepository: '@sylius.repository.address'\n tags:\n - { name: 'sylius.live_component.shop', key: 'sylius_shop:checkout:address:form' }\n\n sylius_shop.twig.component.cart.widget:\n class: App\\Twig\\Components\\Cart\\WidgetComponent\n arguments:\n $cartContext: '@sylius.context.cart.composite'\n $orderRepository: '@sylius.repository.order'\n tags:\n - { name: 'sylius.live_component.shop', key: 'sylius_shop:cart:widget' }\n\n sylius_shop.twig.component.cart.summary:\n class: App\\Twig\\Components\\Cart\\SummaryComponent\n arguments:\n $orderRepository: '@sylius.repository.order'\n tags:\n - { name: 'sylius.live_component.shop', key: 'sylius_shop:cart:summary' }\n```\n\n#### Step 6. Clear cache\n\n```bash\nphp bin/console cache:clear\n```\n\n### Reporters\n\nWe would like to extend our gratitude to the following individuals for their detailed reporting and responsible disclosure of this vulnerability:\n- Peter Stöckli (@p-)\n- Man Yue Mo (@m-y-mo)\n- The [GitHub Security Lab](https://securitylab.github.com) team\n\n### For more information\nIf you have any questions or comments about this advisory:\n\n- Open an issue in [Sylius issues](https://github.com/Sylius/Sylius/issues?q=sort%3Aupdated-desc+is%3Aissue+is%3Aopen)\n- Email us at [security@sylius.com](mailto:security@sylius.com)",
0 commit comments