Skip to content

What's the proper way to redirect from external payment Gateway with POST redirect and still use SameSite: Lax?  #38889

Open
@ioweb-gr

Description

@ioweb-gr

Summary

I've read many of the SameSite issues and possible solutions / workarounds on the repo issues, however so far the only real way of making this work is setting SameSite to None.

My question is about how to make it work with Lax mode and how Paypal integration works.

I've been checking the paypal module and it seems to preserve the session for the logged in user, it uses this plugin

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
declare(strict_types=1);

namespace Magento\Paypal\Plugin;

use Magento\Framework\App\Request\Http;
use Magento\Framework\Session\SessionStartChecker;

/**
 * Intended to preserve session cookie after submitting POST form from PayPal to Magento controller.
 */
class TransparentSessionChecker
{
    /**
     * @var string[]
     */
    private $disableSessionUrls = [
        'paypal/transparent/redirect',
        'paypal/payflowadvanced/returnUrl',
        'paypal/payflow/returnUrl',
        'paypal/hostedpro/return',
    ];

    /**
     * @var Http
     */
    private $request;

    /**
     * @param Http $request
     */
    public function __construct(
        Http $request
    ) {
        $this->request = $request;
    }

    /**
     * Prevents session starting while instantiating PayPal transparent redirect controller.
     *
     * @param SessionStartChecker $subject
     * @param bool $result
     * @return bool
     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
     */
    public function afterCheck(SessionStartChecker $subject, bool $result): bool
    {
        if ($result === false) {
            return false;
        }

        foreach ($this->disableSessionUrls as $url) {
            if (strpos((string)$this->request->getPathInfo(), $url) !== false) {
                return false;
            }
        }

        return true;
    }
}

This effectively makes the session not be destroyed and the user stays logged in.

However on the initial controller which handles the response in my case

cardlink_checkout/payment/response

When fetching the CheckoutSession via Factory or via Proxy or Plain with the class through DI, it's an empty session.

I've read further into the paypal integration and saw that it uses an intermediate redirect by intercepting the initial POST request

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
namespace Magento\Paypal\Controller\Transparent;

use Magento\Framework\App\Action\Action;
use Magento\Framework\App\Action\Context;
use Magento\Framework\App\Action\HttpPostActionInterface;
use Magento\Framework\App\CsrfAwareActionInterface;
use Magento\Framework\App\Request\InvalidRequestException;
use Magento\Framework\App\RequestInterface;
use Magento\Framework\Controller\ResultInterface;
use Magento\Framework\Exception\LocalizedException;
use Magento\Framework\View\Result\LayoutFactory;
use Magento\Payment\Model\Method\Logger;
use Magento\Paypal\Model\Payflow\Transparent;

/**
 * Class for redirecting the Paypal response result to Magento controller.
 */
class Redirect extends Action implements CsrfAwareActionInterface, HttpPostActionInterface
{
    /**
     * @var LayoutFactory
     */
    private $resultLayoutFactory;

    /**
     * @var Transparent
     */
    private $transparent;

    /**
     * @var Logger
     */
    private $logger;

    /**
     * @param Context $context
     * @param LayoutFactory $resultLayoutFactory
     * @param Transparent $transparent
     * @param Logger $logger
     */
    public function __construct(
        Context $context,
        LayoutFactory $resultLayoutFactory,
        Transparent $transparent,
        Logger $logger
    ) {
        $this->resultLayoutFactory = $resultLayoutFactory;
        $this->transparent = $transparent;
        $this->logger = $logger;

        parent::__construct($context);
    }

    /**
     * @inheritdoc
     */
    public function createCsrfValidationException(
        RequestInterface $request
    ): ?InvalidRequestException {
        return null;
    }

    /**
     * @inheritdoc
     */
    public function validateForCsrf(RequestInterface $request): ?bool
    {
        return true;
    }

    /**
     * Saves the payment in quote
     *
     * @return ResultInterface
     * @throws LocalizedException
     */
    public function execute()
    {
        $gatewayResponse = (array)$this->getRequest()->getPostValue();
        $this->logger->debug(
            ['PayPal PayflowPro redirect:' => $gatewayResponse],
            $this->transparent->getDebugReplacePrivateDataKeys(),
            $this->transparent->getDebugFlag()
        );

        $resultLayout = $this->resultLayoutFactory->create();
        $resultLayout->addDefaultHandle();
        $resultLayout->getLayout()->getUpdate()->load(['transparent_payment_redirect']);

        return $resultLayout;
    }
}

Which effectively renders a form which submits again the same data but from the same domain

<?php
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */

/** @var \Magento\Payment\Block\Transparent\Redirect $block */
$params = $block->getPostParams();
$redirectUrl = $block->getRedirectUrl();
?>
<html>
<head></head>
<body onload="document.forms['proxy_form'].submit()">
<form id="proxy_form" action="<?= $block->escapeUrl($redirectUrl) ?>"
      method="POST" hidden enctype="application/x-www-form-urlencoded" class="no-display">
    <?php foreach ($params as $name => $value):?>
        <input value="<?= $block->escapeHtmlAttr($value) ?>" name="<?= $block->escapeHtmlAttr($name) ?>" type="hidden"/>
    <?php endforeach?>
</form>
</body>
</html>

the URL is declared in di.xml

<?xml version="1.0"?>
<!--
/**
 * Copyright © Magento, Inc. All rights reserved.
 * See COPYING.txt for license details.
 */
-->
<layout xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="urn:magento:framework:View/Layout/etc/layout_generic.xsd">
    <container name="root" label="Root">
        <block class="Magento\Payment\Block\Transparent\Redirect" name="transparent_redirect" template="Magento_Payment::transparent/redirect.phtml">
            <arguments>
                <argument name="route_path" xsi:type="string">paypal/transparent/response</argument>
            </arguments>
        </block>
    </container>
</layout>

However when reaching the response controller equivalent in the module I'm trying to patch, I still can't get the CheckoutSession properly and it's entirely empty.

Furthermore, when setting the data to it for the LastSuccessQuoteId and redirecting to checkout/onepage/success controller, I notice that there, the checkoutSession is restored entirely to the previous state, before my modification. Thus because the LastSuccessQuoteId is not there in that state, I'm redirected to cart instead.

So I'm assuming here that something extra is being done in the paypal gateway to retain the actual CheckoutSession and modify that one instead of creating a new empty one.

However I'm unable to find any type of documentation regarding this. In the official docs, there's no mention of how to tackle SameSite and also it is left in the responsibility of each payment gateway to fix it. However this is a problem for Magento itself and us developers trying to fix the gateway modules without nuking the settings to make the cookies set Samesite to "None"

As far as I can see in the network tab, the PHPSESSID is there in all requests, but the cookie isn't.

On redirecting to payment provider

image
image

On redirecting to the response handler which creates the "proxy" form as expected nothing is there
image

On the actual processor everything is there except the PHPSESSID cookie for some reason in the response and in the request the PHPSESS ID is actually there
image
image

And on the checkout/onepage/success controller which can also see the original CheckoutSession I can see everything there as well.

image

So my question is what's the missing step to actually fetch the proper CheckoutSession after the "proxy" form redirects to the actual processor.

Assuming of course what paypal module shows in transparent/redirect classes / phtml files is the correct approach.

If not, what's the correct approach to achieve this while still using SameSite = Lax.

Examples

cardlink.zip

here's an example module provided by Cardlink a major payment gateway in Greece.

Their proposal was to change SameSite to "None" as well.

I'm particularly curious as to why the information for the session is transferred properly when redirecting from the responseProcessor to checkout/onepage/success controller on redirect 2 while not transferred properly when redirecting from the response controller to the responseProcessor on redirect 1 because if the information is there in redirect 2, why wouldn't it be available in redirect 1?

Any help is appreciated

Proposed solution

No response

Release note

No response

Triage and priority

  • Severity: S0 - Affects critical data or functionality and leaves users without workaround.
  • Severity: S1 - Affects critical data or functionality and forces users to employ a workaround.
  • Severity: S2 - Affects non-critical data or functionality and forces users to employ a workaround.
  • Severity: S3 - Affects non-critical data or functionality and does not force users to employ a workaround.
  • Severity: S4 - Affects aesthetics, professional look and feel, “quality” or “usability”.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Area: PaymentsComponent: PaymentIssue: ConfirmedGate 3 Passed. Manual verification of the issue completed. Issue is confirmedPriority: P3May be fixed according to the position in the backlog.Progress: ready for devReported on 2.4.xIndicates original Magento version for the Issue report.Reproduced on 2.4.xThe issue has been reproduced on latest 2.4-develop branchTriage: Dev.ExperienceIssue related to Developer Experience and needs help with Triage to Confirm or Reject it

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions