Skip to content

APSB26-73 guest cart check rejects persistent carts, breaking checkout when Magento_Persistent is enabled #41090

Description

@joshbourke

Preconditions and environment

  1. Magento Open Source / Adobe Commerce 2.4.8-p5 with the APSB26-73 security update applied
    (isolated patch 248p5-2026-07-001-CE). Confirmed on magento/module-quote 101.2.8-p4 and
    magento/module-checkout 100.4.8-p5.
  2. Magento_Persistent enabled, which is the only extra requirement:
    • Stores → Configuration → Customers → Persistent Shopping Cart → Enable Persistence = Yes
    • Persist Shopping Cart = Yes
    • Clear Persistence on Sign Out = No
  3. A registered customer with at least one saved address.

This is not the same as #40991 / #40996 / #40997 / #41000 / #41062, which are about the patch
failing to apply or to compile. This is about its behaviour once correctly applied.

Steps to reproduce

  1. Log in on the storefront with Remember Me checked.
  2. Add a product to the cart.
  3. End the session while keeping the persistent cookie: delete the PHPSESSID cookie in devtools and
    leave persistent_shopping_cart in place. (Waiting out session.gc_maxlifetime has the same effect.)
  4. Reload. The cart is still there and the header shows the "Not you?" link — the shopper is
    remembered but not logged in, which is Magento_Persistent working as designed.
  5. Proceed to checkout, fill in the shipping address and continue to the next step.

Expected result

Checkout completes. Before APSB26-73 the persistent cart was carried through the guest checkout
endpoints and converted to a guest cart at order submit by
Magento\Persistent\Model\Plugin\ConvertCustomerCartToGuest::beforeSubmit().

Actual result

Checkout fails at the shipping step with:

Could not find a cart with ID '<masked_id>'

The quote still exists, is active, and its quote_id_mask row is present and correct.

Root cause

APSB26-73 adds Magento\Quote\Model\GuestCart\GetGuestCart:

public function checkIsGuestCart(int $customerId, string $maskedCartId): void
{
    if ($customerId !== 0) {
        throw new NoSuchEntityException(
            __("Could not find a cart with ID '%masked_cart_id'", ['masked_cart_id' => $maskedCartId])
        );
    }
}

Magento_Persistent deliberately produces exactly that state — a quote that still carries
customer_id but is addressed by a masked ID — and it does so for the whole duration of checkout:

  1. The masked ID is created for a customer-owned cart on purpose.
    Magento\Checkout\Model\Session::getQuote() creates a quote_id_mask row when
    !isQuoteMasked() && !$this->_customerSession->isLoggedIn() && $this->getQuoteId().
    For a remembered shopper isLoggedIn() is false (it ends in && !$this->getIsCustomerEmulated(),
    and Magento\Persistent\Observer\EmulateCustomerObserver sets setIsCustomerEmulated(true)), so the
    mask is created while the quote still has customer_id.

  2. The shopper is routed to the guest endpoints on purpose.
    Magento\Persistent\Model\Checkout\ConfigProviderPlugin (mask_quote_id_substitutor) overwrites
    quoteData.entity_id in the checkout config with that masked ID whenever
    isEnabled() && isPersistent() && !isLoggedIn().

  3. The conversion that would make the cart legal happens at the very end.
    ConvertCustomerCartToGuest::beforeSubmit() on Magento\Quote\Model\QuoteManagement is what nulls
    customer_id. Everything between step 1 and submit is a customer-owned cart being driven through
    the guest endpoints — which is precisely the state the new check rejects.

So the check fires before the conversion it is waiting for can ever run.

Affected call sites added by the patch:

Class Method Checkout step blocked
Magento\Checkout\Model\GuestShippingInformationManagement saveAddressInformation shipping address
Magento\Checkout\Model\GuestTotalsInformationManagement calculate totals
Magento\Checkout\Model\GuestPaymentInformationManagement savePaymentInformation payment
Magento\Quote\Model\GuestCart\GuestCartRepository get any guest cart read

The payment step shows the conflict most clearly. Magento\Persistent\Model\Checkout\GuestPaymentInformationManagementPlugin::beforeSavePaymentInformation()
runs first and sets customer_is_guest = true, but it does not null customer_id — that is submit's
job — so the check inside the method body still throws. Note that Magento_Persistent registers that
plugin in etc/webapi_rest/di.xml and etc/webapi_soap/di.xml as well as etc/frontend/di.xml, so
persistent carts are expected to work over the REST checkout endpoints.

Reproducing without a browser

Any quote with a customer_id and a quote_id_mask row reproduces it directly:

$om->get(\Magento\Quote\Api\GuestCartRepositoryInterface::class)->get($maskedId);
// Magento\Framework\Exception\NoSuchEntityException:
//   Could not find a cart with ID '<masked_id>'
//   GuestCartRepository.php:60  GetGuestCart->checkIsGuestCart(<customer_id>, '<masked_id>')

Impact

Any store with Persistent Shopping Cart enabled loses checkout for every shopper whose session has
expired while their remember-me cookie is still valid — the exact scenario the module exists to serve.

Suggested fix

Magento_Persistent should perform the guest conversion when it hands the cart to an unauthenticated
shopper rather than at submit, so customer_id is already cleared before any guest endpoint is
called. QuoteManager::setGuest() already does this work; it simply runs too late.

Alternatively GetGuestCart could permit the cart when the request carries the persistent session of
the customer who owns it — i.e. Magento\Persistent\Helper\Session::isPersistent() and the persistent
session's customer_id matches the quote's. That preserves the security boundary, since an attacker
holding a masked ID would still need that customer's persistent_shopping_cart cookie.

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    Status
    Ready for Confirmation

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions