Skip to content

Sylius PayPal Plugin has an Order Manipulation Vulnerability after PayPal Checkout

Moderate severity GitHub Reviewed Published Mar 19, 2025 in Sylius/PayPalPlugin • Updated Mar 20, 2025

Package

composer sylius/paypal-plugin (Composer)

Affected versions

< 1.6.2
>= 1.7.0, < 1.7.2
>= 2.0.0, < 2.0.2

Patched versions

1.6.2
1.7.2
2.0.2

Description

A discovered vulnerability allows users to modify their shopping cart after completing the PayPal Checkout process and payment authorization. If a user initiates a PayPal transaction from a product page or the cart page and then returns to the order summary page, they can still manipulate the cart contents before finalizing the order. As a result, the order amount in Sylius may be higher than the amount actually captured by PayPal, leading to a scenario where merchants deliver products or services without full payment.

Impact

  • Users can exploit this flaw to receive products/services without paying the full amount.
  • Merchants may suffer financial losses due to underpaid orders.
  • Trust in the integrity of the payment process is compromised.

Patches

The issue is fixed in versions: 1.6.2, 1.7.2, 2.0.2 and above.

Workarounds

To resolve the problem in the end application without updating to the newest patches, there is a need to overwrite PayPalOrderCompleteProcessor with modified logic:

<?php

declare(strict_types=1);

namespace App\Processor;

use Sylius\Bundle\PayumBundle\Model\GatewayConfigInterface;
use Sylius\Component\Core\Model\OrderInterface;
use Sylius\Component\Core\Model\PaymentInterface;
use Sylius\Component\Core\Model\PaymentMethodInterface;
use Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface;

final class PayPalOrderCompleteProcessor
{
    public function __construct(private readonly PaymentStateManagerInterface $paymentStateManager) {
    }

    public function completePayPalOrder(OrderInterface $order): void
    {
        $payment = $order->getLastPayment(PaymentInterface::STATE_PROCESSING);
        if ($payment === null) {
            return;
        }

        /** @var PaymentMethodInterface $paymentMethod */
        $paymentMethod = $payment->getMethod();
        /** @var GatewayConfigInterface $gatewayConfig */
        $gatewayConfig = $paymentMethod->getGatewayConfig();

        if ($gatewayConfig->getFactoryName() !== 'sylius.pay_pal') {
            return;
        }

        try {
            $this->verify($payment);
        } catch (\Exception) {
            $this->paymentStateManager->cancel($payment);

            return;
        }

        $this->paymentStateManager->complete($payment);
    }

    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;
    }
}

IMPORTANT

For PayPalPlugin 2.x change:

$gatewayConfig->getFactoryName() !== 'sylius.pay_pal'

to

$gatewayConfig->getFactoryName() !== SyliusPayPalExtension::PAYPAL_FACTORY_NAME

Also there is a need to overwrite CompletePayPalOrderListener with modified logic:

<?php

declare(strict_types=1);

namespace App\EventListener\Workflow;

use App\Processor\PayPalOrderCompleteProcessor;
use Sylius\Component\Core\Model\OrderInterface;
use Symfony\Component\Workflow\Event\CompletedEvent;
use Webmozart\Assert\Assert;

final class CompletePayPalOrderListener
{
    public function __construct(private readonly PayPalOrderCompleteProcessor $completeProcessor)
    {
    }

    public function __invoke(CompletedEvent $event): void
    {
        /** @var OrderInterface $order */
        $order = $event->getSubject();
        Assert::isInstanceOf($order, OrderInterface::class);

        $this->completeProcessor->completePayPalOrder($order);
    }
}

And to overwrite CaptureAction with modified logic (if you didn't have it already):

<?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:

Sylius\PayPalPlugin\EventListener\Workflow\CompletePayPalOrderListener:
    class: App\EventListener\Workflow\CompletePayPalOrderListener
    public: true
    arguments:
        - '@Sylius\PayPalPlugin\Processor\PayPalOrderCompleteProcessor'
    tags: 
        - { name: 'kernel.event_listener', event: 'workflow.sylius_order_checkout.completed.complete', priority: 100 }
    
Sylius\PayPalPlugin\Processor\PayPalOrderCompleteProcessor:
    class: App\Processor\PayPalOrderCompleteProcessor
    public: true
    arguments:
        - '@Sylius\PayPalPlugin\Manager\PaymentStateManagerInterface'

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:

sylius_paypal.listener.workflow.complete_paypal_order:
    class: App\EventListener\Workflow\CompletePayPalOrderListener
    public: true
    arguments:
        - '@sylius_paypal.processor.paypal_order_complete'
    tags: 
        - { name: 'kernel.event_listener', event: 'workflow.sylius_order_checkout.completed.complete', priority: 100 }
    
sylius_paypal.processor.paypal_order_complete:
    class: App\Processor\PayPalOrderCompleteProcessor
    public: true
    arguments:
        - '@sylius_paypal.manager.payment_state'

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:

References

@GSadee GSadee published to Sylius/PayPalPlugin Mar 19, 2025
Published by the National Vulnerability Database Mar 19, 2025
Published to the GitHub Advisory Database Mar 19, 2025
Reviewed Mar 19, 2025
Last updated Mar 20, 2025

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Network
Attack complexity
Low
Privileges required
None
User interaction
Required
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
None

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:H/A:N

EPSS score

Exploit Prediction Scoring System (EPSS)

This score estimates the probability of this vulnerability being exploited within the next 30 days. Data provided by FIRST.
(12th percentile)

Weaknesses

CVE ID

CVE-2025-30152

GHSA ID

GHSA-hxg4-65p5-9w37

Source code

Loading Checking history
See something to contribute? Suggest improvements for this vulnerability.