Packagist · sylius/sylius
Sylius: IDOR on Shop Payment Request API endpoints
The GET /api/v2/shop/payment-requests/{hash} and PUT /api/v2/shop/payment-requests/{hash} endpoints look up the payment request solely by the hash from the URL. No ownership check is performed against the authenticated customer or the underlying order.
An attacker who obtains a payment request hash can:
payment IRI in the response, recover the underlying order's tokenValue (which itself grants access to the full order, items, addresses, customer email, totals);target_path, after_path). These fields are used by the front-end controller to redirect the user after the payment, so an attacker can flip them to an attacker-controlled URL and intercept the buyer.The hash is a UUID, so it has to be obtained out-of-band (logs, shared links, referrer headers, a co-located client), but once it is known no other credential is required, neither authentication nor knowledge of the order token.
The creation endpoint POST /api/v2/shop/orders/{tokenValue}/payment-requests shares the same flaw: it resolves the target order solely from the tokenValue in the URL without verifying that the caller owns the order.
The issue is fixed in versions: 2.0.18, 2.1.15, 2.2.6.
Until you can upgrade, apply the following workaround. It enforces ownership on the existing endpoints, so that:
404 Not Found.GET operationCreate file src/ApiPlatform/QueryExtension/PaymentRequestOwnershipExtension.php:
<?php
declare(strict_types=1);
namespace App\ApiPlatform\QueryExtension;
use ApiPlatform\Doctrine\Orm\Extension\QueryItemExtensionInterface;
use ApiPlatform\Doctrine\Orm\Util\QueryNameGeneratorInterface;
use ApiPlatform\Metadata\Operation;
use Doctrine\ORM\QueryBuilder;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Bundle\ApiBundle\SectionResolver\ShopApiSection;
use Sylius\Bundle\CoreBundle\SectionResolver\SectionProviderInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
final readonly class PaymentRequestOwnershipExtension implements QueryItemExtensionInterface
{
public function __construct(
private SectionProviderInterface $sectionProvider,
private UserContextInterface $userContext,
) {
}
public function applyToItem(
QueryBuilder $queryBuilder,
QueryNameGeneratorInterface $queryNameGenerator,
string $resourceClass,
array $identifiers,
?Operation $operation = null,
array $context = [],
): void {
if (!is_a($resourceClass, PaymentRequestInterface::class, true)) {
return;
}
if (!$this->sectionProvider->getSection() instanceof ShopApiSection) {
return;
}
$rootAlias = $queryBuilder->getRootAliases()[0];
$paymentJoin = $queryNameGenerator->generateJoinAlias('payment');
$orderJoin = $queryNameGenerator->generateJoinAlias('order');
$customerJoin = $queryNameGenerator->generateJoinAlias('customer');
$userJoin = $queryNameGenerator->generateJoinAlias('user');
$createdByGuestParameterName = $queryNameGenerator->generateParameterName('createdByGuest');
$queryBuilder
->innerJoin(sprintf('%s.payment', $rootAlias), $paymentJoin)
->innerJoin(sprintf('%s.order', $paymentJoin), $orderJoin)
->leftJoin(sprintf('%s.customer', $orderJoin), $customerJoin)
->leftJoin(sprintf('%s.user', $customerJoin), $userJoin)
;
$user = $this->userContext->getUser();
if ($user instanceof ShopUserInterface) {
$customerParam = $queryNameGenerator->generateParameterName('customer');
$queryBuilder
->andWhere($queryBuilder->expr()->eq(sprintf('%s.customer', $orderJoin), sprintf(':%s', $customerParam)))
->setParameter($customerParam, $user->getCustomer())
;
return;
}
$queryBuilder
->andWhere(
$queryBuilder->expr()->orX(
$queryBuilder->expr()->isNull($userJoin),
$queryBuilder->expr()->isNull(sprintf('%s.customer', $orderJoin)),
$queryBuilder->expr()->andX(
$queryBuilder->expr()->isNotNull($userJoin),
$queryBuilder->expr()->eq(sprintf('%s.createdByGuest', $orderJoin), sprintf(':%s', $createdByGuestParameterName)),
),
),
)
->setParameter($createdByGuestParameterName, true)
;
}
}
PUT state providerCreate file src/ApiPlatform/StateProvider/PaymentRequestOwnershipProvider.php:
<?php
declare(strict_types=1);
namespace App\ApiPlatform\StateProvider;
use ApiPlatform\Metadata\Operation;
use ApiPlatform\State\ProviderInterface;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\ShopUserInterface;
use Sylius\Component\Payment\Model\PaymentRequestInterface;
/** @implements ProviderInterface<PaymentRequestInterface> */
final readonly class PaymentRequestOwnershipProvider implements ProviderInterface
{
/** @param ProviderInterface<PaymentRequestInterface> $inner */
public function __construct(
private ProviderInterface $inner,
private UserContextInterface $userContext,
) {
}
public function provide(Operation $operation, array $uriVariables = [], array $context = []): array|object|null
{
$paymentRequest = $this->inner->provide($operation, $uriVariables, $context);
if (!$paymentRequest instanceof PaymentRequestInterface) {
return $paymentRequest;
}
if (!$this->isAccessible($paymentRequest)) {
return null;
}
return $paymentRequest;
}
private function isAccessible(PaymentRequestInterface $paymentRequest): bool
{
$payment = $paymentRequest->getPayment();
if (!$payment instanceof PaymentInterface) {
return false;
}
$order = $payment->getOrder();
if (!$order instanceof OrderInterface) {
return false;
}
$user = $this->userContext->getUser();
if ($user instanceof ShopUserInterface) {
$customer = $user->getCustomer();
return $customer instanceof CustomerInterface && $order->getCustomer() === $customer;
}
$customer = $order->getCustomer();
return null === $customer
|| null === $customer->getUser()
|| $order->isCreatedByGuest();
}
}
POST creation endpoint with a command-bus middlewareThe POST /api/v2/shop/orders/{tokenValue}/payment-requests operation is a messenger: input operation: it dispatches a Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest command whose orderTokenValue comes straight from the URL, so no query extension or state provider runs. Add a middleware on the Sylius command bus that loads the order, applies the same ownership rule, and aborts with 404 before the handler runs.
Create file src/Messenger/Middleware/PaymentRequestOwnershipMiddleware.php:
<?php
declare(strict_types=1);
namespace App\Messenger\Middleware;
use Sylius\Bundle\ApiBundle\Command\Payment\AddPaymentRequest;
use Sylius\Bundle\ApiBundle\Context\UserContextInterface;
use
Is your project exposed to this? Stateward checks every dependency on every pull request and flags it only if your code actually reaches it.
Check my repoSources: CISA KEV (public domain), OSV.dev & GitHub Advisory Database (CC-BY-4.0), FIRST EPSS, NVD/CWE (public domain). Served live from the Stateward advisory database.