diff --git a/app/code/Magento/Paypal/Controller/Payflow/ReturnUrl.php b/app/code/Magento/Paypal/Controller/Payflow/ReturnUrl.php index d2a14febe54dd..5a32269b1b795 100644 --- a/app/code/Magento/Paypal/Controller/Payflow/ReturnUrl.php +++ b/app/code/Magento/Paypal/Controller/Payflow/ReturnUrl.php @@ -18,6 +18,10 @@ */ class ReturnUrl extends Payflow implements CsrfAwareActionInterface, HttpGetActionInterface { + private const ORDER_INCREMENT_ID = 'INVNUM'; + + private const SILENT_POST_HASH = 'secure_silent_post_hash'; + /** * @var array of allowed order states on frontend */ @@ -63,23 +67,18 @@ public function execute() $this->_view->loadLayout(false); /** @var \Magento\Checkout\Block\Onepage\Success $redirectBlock */ $redirectBlock = $this->_view->getLayout()->getBlock($this->_redirectBlockName); - - if ($this->_checkoutSession->getLastRealOrderId()) { - /** @var \Magento\Sales\Model\Order $order */ - $order = $this->_orderFactory->create()->loadByIncrementId($this->_checkoutSession->getLastRealOrderId()); - - if ($order->getIncrementId()) { - if ($this->checkOrderState($order)) { - $redirectBlock->setData('goto_success_page', true); + $order = $this->getOrderFromRequest(); + if ($order) { + if ($this->checkOrderState($order)) { + $redirectBlock->setData('goto_success_page', true); + } else { + if ($this->checkPaymentMethod($order)) { + $gotoSection = $this->_cancelPayment((string)$this->getRequest()->getParam('RESPMSG')); + $redirectBlock->setData('goto_section', $gotoSection); + $redirectBlock->setData('error_msg', __('Your payment has been declined. Please try again.')); } else { - if ($this->checkPaymentMethod($order)) { - $gotoSection = $this->_cancelPayment((string)$this->getRequest()->getParam('RESPMSG')); - $redirectBlock->setData('goto_section', $gotoSection); - $redirectBlock->setData('error_msg', __('Your payment has been declined. Please try again.')); - } else { - $redirectBlock->setData('goto_section', false); - $redirectBlock->setData('error_msg', __('Requested payment method does not match with order.')); - } + $redirectBlock->setData('goto_section', false); + $redirectBlock->setData('error_msg', __('Requested payment method does not match with order.')); } } } @@ -87,6 +86,29 @@ public function execute() $this->_view->renderLayout(); } + /** + * Returns an order from request. + * + * @return Order|null + */ + private function getOrderFromRequest(): ?Order + { + $orderId = $this->getRequest()->getParam(self::ORDER_INCREMENT_ID); + if (!$orderId) { + return null; + } + + $order = $this->_orderFactory->create()->loadByIncrementId($orderId); + $storedHash = (string)$order->getPayment()->getAdditionalInformation(self::SILENT_POST_HASH); + $requestHash = (string)$this->getRequest()->getParam('USER2'); + if (empty($storedHash) || empty($requestHash) || !hash_equals($storedHash, $requestHash)) { + return null; + } + $this->_checkoutSession->setLastRealOrderId($orderId); + + return $order; + } + /** * Check order state * diff --git a/app/code/Magento/Paypal/Plugin/TransparentSessionChecker.php b/app/code/Magento/Paypal/Plugin/TransparentSessionChecker.php index d53fd183c1942..5d950f6c346e5 100644 --- a/app/code/Magento/Paypal/Plugin/TransparentSessionChecker.php +++ b/app/code/Magento/Paypal/Plugin/TransparentSessionChecker.php @@ -20,6 +20,8 @@ class TransparentSessionChecker */ private $disableSessionUrls = [ 'paypal/transparent/redirect', + 'paypal/payflowadvanced/returnUrl', + 'paypal/payflow/returnUrl', 'paypal/hostedpro/return', ]; diff --git a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php index 2b1d6526ab2a8..5e27a33d1201b 100644 --- a/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php +++ b/app/code/Magento/Paypal/Test/Unit/Controller/Payflow/ReturnUrlTest.php @@ -32,6 +32,8 @@ class ReturnUrlTest extends TestCase { const LAST_REAL_ORDER_ID = '000000001'; + const SILENT_POST_HASH = 'abcdfg'; + /** * @var ReturnUrl */ @@ -142,7 +144,7 @@ protected function setUp(): void $this->checkoutSession = $this->getMockBuilder(Session::class) ->disableOriginalConstructor() - ->setMethods(['getLastRealOrderId', 'getLastRealOrder', 'restoreQuote']) + ->setMethods(['setLastRealOrderId', 'getLastRealOrder', 'restoreQuote']) ->getMock(); $this->paymentFailures = $this->getMockBuilder(PaymentFailuresInterface::class) @@ -177,8 +179,15 @@ public function testExecuteAllowedOrderState($state) $this->withLayout(); $this->withOrder(self::LAST_REAL_ORDER_ID, $state); - $this->checkoutSession->method('getLastRealOrderId') - ->willReturn(self::LAST_REAL_ORDER_ID); + $this->request->method('getParam') + ->willReturnMap([ + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', self::SILENT_POST_HASH], + ]); + + $this->checkoutSession->expects($this->once()) + ->method('setLastRealOrderId') + ->with(self::LAST_REAL_ORDER_ID); $this->block->method('setData') ->with('goto_success_page', true) @@ -202,6 +211,45 @@ public function allowedOrderStateDataProvider() ]; } + /** + * Checks a test case when silent post hash validation fails. + * + * @param string $requestHash + * @param string $orderHash + * @dataProvider invalidHashVariations + */ + public function testFailedHashValidation(string $requestHash, string $orderHash) + { + $this->withLayout(); + $this->withOrder(self::LAST_REAL_ORDER_ID, Order::STATE_PROCESSING, $orderHash); + + $this->request->method('getParam') + ->willReturnMap([ + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', $requestHash], + ]); + + $this->checkoutSession->expects($this->never()) + ->method('setLastRealOrderId') + ->with(self::LAST_REAL_ORDER_ID); + + $this->returnUrl->execute(); + } + + /** + * Gets list of allowed order states. + * + * @return array + */ + public function invalidHashVariations() + { + return [ + ['requestHash' => '', 'orderHash' => self::SILENT_POST_HASH], + ['requestHash' => self::SILENT_POST_HASH, 'orderHash' => ''], + ['requestHash' => 'abcd', 'orderHash' => 'dcba'], + ]; + } + /** * Checks a test case when action processes order with not allowed state. * @@ -218,8 +266,11 @@ public function testExecuteNotAllowedOrderState($state, $restoreQuote, $expected $this->withCheckoutSession(self::LAST_REAL_ORDER_ID, $restoreQuote); $this->request->method('getParam') - ->with('RESPMSG') - ->willReturn($errMessage); + ->willReturnMap([ + ['RESPMSG', $errMessage], + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', self::SILENT_POST_HASH], + ]); $this->payment->method('getMethod') ->willReturn(Config::METHOD_PAYFLOWLINK); @@ -261,8 +312,14 @@ public function testCheckRejectByPaymentMethod() $this->withLayout(); $this->withOrder(self::LAST_REAL_ORDER_ID, Order::STATE_NEW); - $this->checkoutSession->method('getLastRealOrderId') - ->willReturn(self::LAST_REAL_ORDER_ID); + $this->checkoutSession->expects($this->once()) + ->method('setLastRealOrderId') + ->with(self::LAST_REAL_ORDER_ID); + $this->request->method('getParam') + ->willReturnMap([ + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', self::SILENT_POST_HASH], + ]); $this->withBlockContent(false, 'Requested payment method does not match with order.'); @@ -285,8 +342,11 @@ public function testCheckXSSEscaped($errorMsg, $errorMsgEscaped) $this->withCheckoutSession(self::LAST_REAL_ORDER_ID, true); $this->request->method('getParam') - ->with('RESPMSG') - ->willReturn($errorMsg); + ->willReturnMap([ + ['RESPMSG', $errorMsg], + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', self::SILENT_POST_HASH], + ]); $this->checkoutHelper->method('cancelCurrentOrder') ->with(self::equalTo($errorMsgEscaped)); @@ -323,8 +383,11 @@ public function testCheckAdvancedAcceptingByPaymentMethod() $this->withCheckoutSession(self::LAST_REAL_ORDER_ID, true); $this->request->method('getParam') - ->with('RESPMSG') - ->willReturn('message'); + ->willReturnMap([ + ['RESPMSG', 'message'], + ['INVNUM', self::LAST_REAL_ORDER_ID], + ['USER2', self::SILENT_POST_HASH], + ]); $this->withBlockContent('paymentMethod', 'Your payment has been declined. Please try again.'); @@ -347,9 +410,10 @@ public function testCheckAdvancedAcceptingByPaymentMethod() * * @param string $incrementId * @param string $state + * @param string $hash * @return void */ - private function withOrder($incrementId, $state) + private function withOrder($incrementId, $state, $hash = self::SILENT_POST_HASH) { $this->orderFactory->method('create') ->willReturn($this->order); @@ -366,6 +430,8 @@ private function withOrder($incrementId, $state) $this->order->method('getPayment') ->willReturn($this->payment); + $this->payment->method('getAdditionalInformation') + ->willReturn($hash); } /** @@ -390,8 +456,8 @@ private function withLayout() */ private function withCheckoutSession($orderId, $restoreQuote) { - $this->checkoutSession->method('getLastRealOrderId') - ->willReturn($orderId); + $this->checkoutSession->method('setLastRealOrderId') + ->with($orderId); $this->checkoutSession->method('getLastRealOrder') ->willReturn($this->order); diff --git a/app/code/Magento/Paypal/etc/csp_whitelist.xml b/app/code/Magento/Paypal/etc/csp_whitelist.xml index f9296332910e6..ee83e2864ab89 100644 --- a/app/code/Magento/Paypal/etc/csp_whitelist.xml +++ b/app/code/Magento/Paypal/etc/csp_whitelist.xml @@ -36,6 +36,14 @@ www.paypal.com www.sandbox.paypal.com + pilot-payflowlink.paypal.com + + + + + www.paypal.com + www.sandbox.paypal.com + pilot-payflowlink.paypal.com diff --git a/app/code/Magento/Paypal/view/frontend/web/js/view/payment/method-renderer/iframe-methods.js b/app/code/Magento/Paypal/view/frontend/web/js/view/payment/method-renderer/iframe-methods.js index 7fb94a7e2348e..bd779567a39b5 100644 --- a/app/code/Magento/Paypal/view/frontend/web/js/view/payment/method-renderer/iframe-methods.js +++ b/app/code/Magento/Paypal/view/frontend/web/js/view/payment/method-renderer/iframe-methods.js @@ -74,6 +74,7 @@ define([ if (this.iframeIsLoaded) { document.getElementById(this.getCode() + '-iframe') .contentWindow.location.reload(); + this.paymentReady(false); } this.paymentReady(true); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled.php new file mode 100644 index 0000000000000..31c666c8fc84b --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled.php @@ -0,0 +1,22 @@ +requireDataFixture('Magento/Sales/_files/order.php'); + +$objectManager = Bootstrap::getObjectManager(); +/** @var Order $order */ +$order = $objectManager->get(OrderInterfaceFactory::class)->create()->loadByIncrementId('100000001'); +/** @var OrderManagementInterface $orderManagement */ +$orderManagement = $objectManager->create(OrderManagementInterface::class); +$orderManagement->place($order); +$orderManagement->cancel($order->getEntityId()); diff --git a/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled_rollback.php b/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled_rollback.php new file mode 100644 index 0000000000000..5ac1e380561b4 --- /dev/null +++ b/dev/tests/integration/testsuite/Magento/Sales/_files/order_canceled_rollback.php @@ -0,0 +1,30 @@ +get(OrderRepositoryInterface::class); +/** @var OrderInterface $order */ +$order = $objectManager->get(OrderInterfaceFactory::class)->create()->loadByIncrementId('100000001'); +/** @var Registry $registry */ +$registry = $objectManager->get(Registry::class); +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', true); + +$orderRepository->delete($order); + +$registry->unregister('isSecureArea'); +$registry->register('isSecureArea', false); + +Resolver::getInstance()->requireDataFixture('Magento/Catalog/_files/product_simple_rollback.php');