GHSA-pqq3-q84h-pj6x is a medium-severity (CVSS 6.5) CWE-472 vulnerability in sylius/paypal-plugin. A fix is available for sylius/paypal-plugin — see the affected versions and patch details below.
Sylius PayPal Plugin Payment Amount Manipulation Vulnerability
Exploitation Status
No confirmed exploitation observed yet
- CISA’s own triage has not observed active exploitation or public proof-of-concept code for this CVE as of its last assessment.
Exploitation and automatability from CISA’s SSVC triage for GHSA-pqq3-q84h-pj6x.
EPSS Exploitation Probability
EPSS (Exploit Prediction Scoring System) is a daily probability model maintained by FIRST.org. It estimates the likelihood a CVE will be exploited in production environments within the next 30 days, derived from real-world threat intelligence signals.
How urgent is this, really
GHSA-pqq3-q84h-pj6x plotted by exploitation likelihood (EPSS) against impact (CVSS). The shaded corner — EPSS 50%+ and CVSS 7.0+ — is where this CVE doesn't sit, though severity or exploitability alone can still warrant action.
Where this sits among everything scored
Of 378,567 CVEs with a current EPSS score, this one falls in the < 10% band (highlighted). Real counts from FIRST.org, not a sample — log-scaled since the landscape is heavily right-skewed.
Real-World Exposure
sylius/paypal-plugin🐘sylius/paypal-plugin🐘sylius/paypal-pluginReal-time download stats are indexed for npm and PyPI packages. This vulnerability affects Packagist packages — download data is not available via public APIs for these ecosystems.
Description
A vulnerability allows users to manipulate the final payment amount processed by PayPal. If a user modifies the item quantity in their shopping cart after initiating the PayPal Checkout process, PayPal will not receive the updated total amount. As a result, PayPal captures only the initially transmitted amount, while Sylius incorrectly considers the order fully paid based on the modified total. This flaw can be exploited both accidentally and intentionally, potentially enabling fraud by allowing customers to pay less than the actual order value.
Impact
- Attackers can intentionally pay less than the actual total order amount.
- Business owners may suffer financial losses due to underpaid orders.
- Integrity of payment processing is compromised.
Patches
The issue is fixed in versions: 1.6.1, 1.7.1, 2.0.1 and above.
Workarounds
To resolve the problem in the end application without updating to the newest patches, there is a need to overwrite ProcessPayPalOrderAction with modified logic:
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\Persistence\ObjectManager;
use SM\Factory\FactoryInterface as StateMachineFactoryInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Abstraction\StateMachine\WinzouStateMachineAdapter;
use Sylius\Component\Core\Factory\AddressFactoryInterface;
use Sylius\Component\Core\Model\CustomerInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\Component\Core\OrderCheckoutTransitions;
use Sylius\Component\Core\Repository\CustomerRepositoryInterface;
use Sylius\Component\Resource\Factory\FactoryInterface;
use Sylius\PayPalPlugin\Api\CacheAuthorizeClientApiInterface;
use Sylius\PayPalPlugin\Api\OrderDetailsApiInterface;
use Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface;
use Sylius\PayPalPlugin\Provider\OrderProviderInterface;
use Sylius\PayPalPlugin\Verifier\PaymentAmountVerifierInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
final class ProcessPayPalOrderAction
{
public function __construct(
private readonly CustomerRepositoryInterface $customerRepository,
private readonly FactoryInterface $customerFactory,
private readonly AddressFactoryInterface $addressFactory,
private readonly ObjectManager $orderManager,
private readonly StateMachineFactoryInterface|StateMachineInterface $stateMachineFactory,
private readonly PaymentStateManagerInterface $paymentStateManager,
private readonly CacheAuthorizeClientApiInterface $authorizeClientApi,
private readonly OrderDetailsApiInterface $orderDetailsApi,
private readonly OrderProviderInterface $orderProvider,
) {
}
public function __invoke(Request $request): Response
{
$orderId = $request->request->getInt('orderId');
$order = $this->orderProvider->provideOrderById($orderId);
/** @var PaymentInterface $payment */
$payment = $order->getLastPayment(PaymentInterface::STATE_CART);
$data = $this->getOrderDetails((string) $request->request->get('payPalOrderId'), $payment);
/** @var CustomerInterface|null $customer */
$customer = $order->getCustomer();
if ($customer === null) {
$customer = $this->getOrderCustomer($data['payer']);
$order->setCustomer($customer);
}
$purchaseUnit = (array) $data['purchase_units'][0];
$address = $this->addressFactory->createNew();
if ($order->isShippingRequired()) {
$name = explode(' ', $purchaseUnit['shipping']['name']['full_name']);
$address->setLastName(array_pop($name) ?? '');
$address->setFirstName(implode(' ', $name));
$address->setStreet($purchaseUnit['shipping']['address']['address_line_1']);
$address->setCity($purchaseUnit['shipping']['address']['admin_area_2']);
$address->setPostcode($purchaseUnit['shipping']['address']['postal_code']);
$address->setCountryCode($purchaseUnit['shipping']['address']['country_code']);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_ADDRESS);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_SELECT_SHIPPING);
} else {
$address->setFirstName($customer->getFirstName());
$address->setLastName($customer->getLastName());
$defaultAddress = $customer->getDefaultAddress();
$address->setStreet($defaultAddress ? $defaultAddress->getStreet() : '');
$address->setCity($defaultAddress ? $defaultAddress->getCity() : '');
$address->setPostcode($defaultAddress ? $defaultAddress->getPostcode() : '');
$address->setCountryCode($data['payer']['address']['country_code']);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_ADDRESS);
}
$order->setShippingAddress(clone $address);
$order->setBillingAddress(clone $address);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_SELECT_PAYMENT);
$this->orderManager->flush();
try {
$this->verify($payment, $data);
} catch (\Exception) {
$this->paymentStateManager->cancel($payment);
return new JsonResponse(['orderID' => $order->getId()]);
}
$this->paymentStateManager->create($payment);
$this->paymentStateManager->process($payment);
return new JsonResponse(['orderID' => $order->getId()]);
}
private function getOrderCustomer(array $customerData): CustomerInterface
{
/** @var CustomerInterface|null $existingCustomer */
$existingCustomer = $this->customerRepository->findOneBy(['email' => $customerData['email_address']]);
if ($existingCustomer !== null) {
return $existingCustomer;
}
/** @var CustomerInterface $customer */
$customer = $this->customerFactory->createNew();
$customer->setEmail($customerData['email_address']);
$customer->setFirstName($customerData['name']['given_name']);
$customer->setLastName($customerData['name']['surname']);
return $customer;
}
private function getOrderDetails(string $id, PaymentInterface $payment): array
{
/** @var PaymentMethodInterface $paymentMethod */
$paymentMethod = $payment->getMethod();
$token = $this->authorizeClientApi->authorize($paymentMethod);
return $this->orderDetailsApi->get($token, $id);
}
private function getStateMachine(): StateMachineInterface
{
if ($this->stateMachineFactory instanceof StateMachineFactoryInterface) {
return new WinzouStateMachineAdapter($this->stateMachineFactory);
}
return $this->stateMachineFactory;
}
private function verify(PaymentInterface $payment, array $paypalOrderDetails): void
{
$totalAmount = $this->getTotalPaymentAmountFromPaypal($paypalOrderDetails);
if ($payment->getAmount() !== $totalAmount) {
throw new \Exception();
}
}
private function getTotalPaymentAmountFromPaypal(array $paypalOrderDetails): int
{
if (!isset($paypalOrderDetails['purchase_units']) || !is_array($paypalOrderDetails['purchase_units'])) {
return 0;
}
$totalAmount = 0;
foreach ($paypalOrderDetails['purchase_units'] as $unit) {
$stringAmount = $unit['amount']['value'] ?? '0';
$totalAmount += (int) ($stringAmount * 100);
}
return $totalAmount;
}
}
Also there is a need to overwrite CompletePayPalOrderFromPaymentPageAction with modified logic:
<?php
declare(strict_types=1);
namespace App\Controller;
use Doctrine\Persistence\ObjectManager;
use SM\Factory\FactoryInterface;
use Sylius\Abstraction\StateMachine\StateMachineInterface;
use Sylius\Abstraction\StateMachine\WinzouStateMachineAdapter;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\OrderCheckoutTransitions;
use Sylius\Component\Order\Processor\OrderProcessorInterface;
use Sylius\PayPalPlugin\Exception\PaymentAmountMismatchException;
use Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface;
use Sylius\PayPalPlugin\Provider\OrderProviderInterface;
use Sylius\PayPalPlugin\Verifier\PaymentAmountVerifierInterface;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Generator\UrlGeneratorInterface;
final class CompletePayPalOrderFromPaymentPageAction
{
public function __construct(
private readonly PaymentStateManagerInterface $paymentStateManager,
private readonly UrlGeneratorInterface $router,
private readonly OrderProviderInterface $orderProvider,
private readonly FactoryInterface|StateMachineInterface $stateMachine,
private readonly ObjectManager $orderManager,
private readonly OrderProcessorInterface $orderProcessor,
) {
}
public function __invoke(Request $request): Response
{
$orderId = $request->attributes->getInt('id');
$order = $this->orderProvider->provideOrderById($orderId);
/** @var PaymentInterface $payment */
$payment = $order->getLastPayment(PaymentInterface::STATE_PROCESSING);
try {
$this->verify($payment);
} catch (\Exception) {
$this->paymentStateManager->cancel($payment);
$order->removePayment($payment);
$this->orderProcessor->process($order);
return new JsonResponse([
'return_url' => $this->router->generate('sylius_shop_checkout_complete', [], UrlGeneratorInterface::ABSOLUTE_URL),
]);
}
$this->paymentStateManager->complete($payment);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_SELECT_PAYMENT);
$this->getStateMachine()->apply($order, OrderCheckoutTransitions::GRAPH, OrderCheckoutTransitions::TRANSITION_COMPLETE);
$this->orderManager->flush();
$request->getSession()->set('sylius_order_id', $order->getId());
return new JsonResponse([
'return_url' => $this->router->generate('sylius_shop_order_thank_you', [], UrlGeneratorInterface::ABSOLUTE_URL),
]);
}
private function getStateMachine(): StateMachineInterface
{
if ($this->stateMachine instanceof FactoryInterface) {
return new WinzouStateMachineAdapter($this->stateMachine);
}
return $this->stateMachine;
}
private function verify(PaymentInterface $payment): void
{
$totalAmount = $this->getTotalPaymentAmountFromPaypal($payment);
if ($payment->getOrder()->getTotal() !== $totalAmount) {
throw new \Exception();
}
}
private function getTotalPaymentAmountFromPaypal(PaymentInterface $payment): int
{
$details = $payment->getDetails();
return $details['payment_amount'] ?? 0;
}
}
And to overwrite CaptureAction with modified logic:
<?php
declare(strict_types=1);
namespace App\Payum\Action;
use Payum\Core\Action\ActionInterface;
use Payum\Core\Exception\RequestNotSupportedException;
use Payum\Core\Request\Capture;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\PayPalPlugin\Api\CacheAuthorizeClientApiInterface;
use Sylius\PayPalPlugin\Api\CreateOrderApiInterface;
use Sylius\PayPalPlugin\Payum\Action\StatusAction;
use Sylius\PayPalPlugin\Provider\UuidProviderInterface;
final class CaptureAction implements ActionInterface
{
public function __construct(
private CacheAuthorizeClientApiInterface $authorizeClientApi,
private CreateOrderApiInterface $createOrderApi,
private UuidProviderInterface $uuidProvider,
) {
}
/** @param Capture $request */
public function execute($request): void
{
RequestNotSupportedException::assertSupports($this, $request);
/** @var PaymentInterface $payment */
$payment = $request->getModel();
/** @var PaymentMethodInterface $paymentMethod */
$paymentMethod = $payment->getMethod();
$token = $this->authorizeClientApi->authorize($paymentMethod);
$referenceId = $this->uuidProvider->provide();
$content = $this->createOrderApi->create($token, $payment, $referenceId);
if ($content['status'] === 'CREATED') {
$payment->setDetails([
'status' => StatusAction::STATUS_CAPTURED,
'paypal_order_id' => $content['id'],
'reference_id' => $referenceId,
'payment_amount' => $payment->getAmount(),
]);
}
}
public function supports($request): bool
{
return
$request instanceof Capture &&
$request->getModel() instanceof PaymentInterface
;
}
}
After that, register services in the container when using PayPal 1.x:
services:
App\Controller\ProcessPayPalOrderAction:
class: App\Controller\ProcessPayPalOrderAction
public: true
arguments:
- '@sylius.repository.customer'
- '@sylius.factory.customer'
- '@sylius.factory.address'
- '@sylius.manager.order'
- '@sylius_abstraction.state_machine'
- '@Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface'
- '@Sylius\PayPalPlugin\Api\CacheAuthorizeClientApiInterface'
- '@Sylius\PayPalPlugin\Api\OrderDetailsApiInterface'
- '@Sylius\PayPalPlugin\Provider\OrderProviderInterface'
Sylius\PayPalPlugin\Controller\ProcessPayPalOrderAction:
alias: App\Controller\ProcessPayPalOrderAction
App\Controller\CompletePayPalOrderFromPaymentPageAction:
class: App\Controller\CompletePayPalOrderFromPaymentPageAction
public: true
arguments:
- '@Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface'
- '@router'
- '@Sylius\PayPalPlugin\Provider\OrderProviderInterface'
- '@sylius_abstraction.state_machine'
- '@sylius.manager.order'
- '@sylius.order_processing.order_processor'
Sylius\PayPalPlugin\Controller\CompletePayPalOrderFromPaymentPageAction:
alias: App\Controller\CompletePayPalOrderFromPaymentPageAction
Sylius\PayPalPlugin\Payum\Action\CaptureAction:
class: App\Payum\Action\CaptureAction
public: true
arguments:
- '@Sylius\PayPalPlugin\Api\CacheAuthorizeClientApiInterface'
- '@Sylius\PayPalPlugin\Api\CreateOrderApiInterface'
- '@Sylius\PayPalPlugin\Provider\UuidProviderInterface'
tags:
- { name: 'payum.action', factory: 'sylius.pay_pal', alias: 'payum.action.capture' }
or when using PayPal 2.x:
services:
App\Controller\ProcessPayPalOrderAction:
class: App\Controller\ProcessPayPalOrderAction
public: true
arguments:
- '@sylius.repository.customer'
- '@sylius.factory.customer'
- '@sylius.factory.address'
- '@sylius.manager.order'
- '@sylius_abstraction.state_machine'
- '@sylius_paypal.manager.payment_state'
- '@sylius_paypal.api.cache_authorize_client'
- '@sylius_paypal.api.order_details'
- '@sylius_paypal.provider.order'
sylius_paypal.controller.process_paypal_order:
alias: App\Controller\ProcessPayPalOrderAction
App\Controller\CompletePayPalOrderFromPaymentPageAction:
class: App\Controller\CompletePayPalOrderFromPaymentPageAction
public: true
arguments:
- '@sylius_paypal.manager.payment_state'
- '@router'
- '@sylius_paypal.provider.order'
- '@sylius_abstraction.state_machine'
- '@sylius.manager.order'
- '@sylius.order_processing.order_processor'
sylius_paypal.controller.complete_paypal_order_from_payment_page:
alias: App\Controller\CompletePayPalOrderFromPaymentPageAction
sylius_paypal.payum.action.capture:
class: App\Payum\Action\CaptureAction
public: true
arguments:
- '@sylius_paypal.api.cache_authorize_client'
- '@sylius_paypal.api.create_order'
- '@sylius_paypal.provider.uuid'
tags:
- { name: 'payum.action', factory: 'sylius.paypal', alias: 'payum.action.capture' }
For more information
If you have any questions or comments about this advisory:
- Open an issue in Sylius issues
- Email us at [email protected]
Affected Packages
| Ecosystem | Package | Vulnerable range | Fix |
|---|---|---|---|
| 🐘Packagist | sylius/paypal-plugin | all versions | 1.6.1composer require sylius/paypal-plugin:^1.6.1 |
| 🐘Packagist | sylius/paypal-plugin | ≥ 1.7.0&&< 1.7.1 | 1.7.1composer require sylius/paypal-plugin:^1.7.1 |
| 🐘Packagist | sylius/paypal-plugin | ≥ 2.0.0&&< 2.0.1 | 2.0.1composer require sylius/paypal-plugin:^2.0.1 |
Detection & mitigation playbook
Open-source dependencyDetect
Scan your dependency tree (package-lock.json, pnpm-lock.yaml, requirements.txt, go.sum, etc.) for sylius/paypal-plugin, including transitive dependencies — a direct dependency you never call can still pull in a vulnerable version.
Fix
Update sylius/paypal-plugin to 1.6.1 or later, then make sure no transitive (indirect) dependency still pins the vulnerable range — O3 confirms GHSA-pqq3-q84h-pj6x is resolved across your whole dependency graph.
Workarounds
If you can't upgrade right away: gate or disable the affected feature, validate untrusted input at the boundary, and avoid passing attacker-controlled data into the vulnerable path. O3's runtime protection blocks exploitation in production as an interim safeguard until the upgrade lands.
How O3 protects you
O3 Security's impact-aware SCA analyses which vulnerable code paths your application actually calls, so a match like GHSA-pqq3-q84h-pj6x can be triaged on real exposure rather than presence alone.
Tailored to GHSA-pqq3-q84h-pj6x. Runtime protection reduces exposure until a permanent patch is applied and verified — it complements patching, it doesn't replace it.
Frequently Asked Questions
Is GHSA-pqq3-q84h-pj6x in your dependencies?
O3 Security finds GHSA-pqq3-q84h-pj6x across Packagist dependencies, including transitive ones, and its impact-aware SCA ranks findings by whether your code actually calls the vulnerable path.