Scott Robinson Technical Lead

Adobe's September 2026 Monthly Patch for Magento and B2B (MCLOUD-15053)

You can view the official release notes for APSB26-138 on the Adobe site.

Adobe’s September 2026 monthly patch for Magento landed today in magento/magento-cloud-patches 1.1.20 under the internal reference MCLOUD-15053. It is listed in patches.json as “Patch for Sep 2026 monthly release” and there are eleven files: one for each supported Open Source line and one for each supported B2B line. There is no Commerce (EE) build this month. August’s release shipped EE files alongside the others, so it is worth saying out loud before anyone goes looking for one.

MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.4-p18.patch
MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.5-p17.patch
MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.6-p15.patch
MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.7-p10.patch
MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.8-p5.patch
MCLOUD-15053__Sep_2026_monthly_release_ce__2.4.9.patch

MCLOUD-15053__Sep_2026_monthly_release_b2b__1.3.3-p18.patch
MCLOUD-15053__Sep_2026_monthly_release_b2b__1.3.4-p17.patch
MCLOUD-15053__Sep_2026_monthly_release_b2b__1.4.2-p10.patch
MCLOUD-15053__Sep_2026_monthly_release_b2b__1.5.2-p5.patch
MCLOUD-15053__Sep_2026_monthly_release_b2b__1.5.3.patch

This is separate from the APSB26-146 / VULN-39341 patch I wrote up earlier today. Different files, different bugs and you need both.

As with that one, I diffed the version builds against each other before reading any of them. This time they are not identical. The Open Source patch touches eight files on 2.4.6 and later but only seven on 2.4.4 and 2.4.5, and two of the fixes get progressively thinner the further back you go. The B2B patch is one file on every line except 1.5.3, which gets two. The version differences are the most useful thing in here if you run an older line, so I have called them out under each fix.

Open Source: the eight files

vendor/magento/framework/Escaper.php
vendor/magento/module-backup/Controller/Adminhtml/Index/Rollback.php
vendor/magento/module-customer/i18n/en_US.csv
vendor/magento/module-customer-graph-ql/Model/Context/AddUserInfoToContext.php
vendor/magento/module-import-export/Controller/Adminhtml/Export/File/Delete.php
vendor/magento/module-instant-purchase/Model/InstantPurchaseOptionLoadingFactory.php
vendor/magento/module-paypal/Controller/Express/AbstractExpress.php
vendor/magento/module-sales/view/adminhtml/web/order/create/scripts.js

The i18n change is a single new string, “Address not found.”, for the Instant Purchase fix. That leaves seven real changes.

Backup rollback ran before it told you no

Magento\Backup\Controller\Adminhtml\Index\Rollback::execute() used to open like this:

if (!$this->_objectManager->get(\Magento\Backup\Helper\Data::class)->isRollbackAllowed()) {
    $this->_forward('denied');
}

if (!$this->getRequest()->isAjax()) {
    return $this->_redirect('*/*/index');
}

isRollbackAllowed() is a thin wrapper around isAllowed('Magento_Backup::rollback'), so the intent is clear. The problem is the missing return. _forward() does not throw and it does not stop the action. It marks the request as not dispatched and sets the next action name, and then control comes straight back to the line after it. The front controller only picks up the forward once execute() has finished. So an admin user without the rollback resource got the “denied” page rendered for them, after the rollback had already run.

The patch deletes the runtime check and replaces it with the constant the framework actually looks at:

class Rollback extends \Magento\Backup\Controller\Adminhtml\Index implements HttpPostActionInterface
{
    /**
     * @see _isAllowed()
     */
    public const ADMIN_RESOURCE = 'Magento_Backup::rollback';

The parent controller declares Magento_Backup::backup, so before this the dispatcher’s own check was satisfied by anyone who could create a backup. Now _isAllowed() requires the child resource, and it runs in dispatch() before execute() is ever entered. Nothing to fall through.

Context on exposure: this is an authenticated admin action behind a POST with a form key, and backup functionality has been disabled by default since 2.3. But rollback is about as destructive as an admin action gets, and the reason Magento_Backup::rollback exists as its own ACL node is precisely so that you can hand out backup creation without handing out restore. That separation was not real until now.

Identical on all six lines.

Export file delete could reach outside the export folder

Magento\ImportExport\Controller\Adminhtml\Export\File\Delete takes a filename from the request and did this with it:

$directoryWrite = $this->filesystem->getDirectoryWrite(DirectoryList::VAR_IMPORT_EXPORT);
try {
    $directoryWrite->delete($directoryWrite->getAbsolutePath() . 'export/' . $fileName);

To be fair to the old code, Write::delete() calls validatePath() first, so ../../app/etc/env.php was never going to work. The directory object refuses anything that resolves outside its root. The catch is where that root is. DirectoryList::VAR_IMPORT_EXPORT maps to plain var, not to var/importexport, so the check only guaranteed the target stayed somewhere under var/. export/../log/system.log is a valid path under that root. So is export/../import_history, and so is export/.. itself. delete() handles directories too, recursively. An admin user whose role includes Magento_ImportExport::export could remove logs, reports, import history or the export directory wholesale, and the controller would report “File … deleted” as if nothing odd had happened.

The patch normalises the name, then insists the result is an existing file under export/:

$fileName = $directoryWrite->getDriver()->getRealPathSafety(DIRECTORY_SEPARATOR . $fileName);
$fileExist = $directoryWrite->isFile('export' . $fileName);
if (!$fileExist || !$this->isAllowedExportFile($fileName)) {
    $this->messageManager->addErrorMessage(__(
        'Sorry, but the data is invalid or the file is not uploaded.'
    ));
    return $resultRedirect;
}
$directoryWrite->delete($directoryWrite->getAbsolutePath() . 'export' . $fileName);

getRealPathSafety() collapses .. segments without touching the filesystem, so a traversal attempt ends up as a plain filename that then has to exist as a file. Directories fail the isFile() check outright.

Version difference: The isAllowedExportFile() half exists only on 2.4.9. It goes through Magento\ImportExport\Model\Export\FileInfo, which is new in 2.4.9 as part of the queue-based export work, and checks that the extension is one of the configured export formats. On 2.4.4-p18 through 2.4.8-p5 the class does not exist, so the condition is just if (!$fileExist). The traversal is closed on every line; the extension allow-list is a 2.4.9 extra.

GraphQL customer context trusted the wrong store

Magento\CustomerGraphQl\Model\Context\AddUserInfoToContext is the processor that turns a bearer token into the GraphQL context’s user id, user type and is_customer flag. It gets two changes.

The first is what happens when a token is a customer token but the customer does not pass the isCustomer() check. That can only happen in one situation: account sharing is set to per website and the customer’s website is not the one being queried. Before the patch the context came out with is_customer = false but the user id still set to the customer’s id. Anything downstream that consults getUserId() rather than the is_customer extension attribute would carry on as that customer, on a website they do not belong to. The patch zeroes the id:

if (!$isCustomer
    && !empty($currentUserId)
    && $currentUserType === UserContextInterface::USER_TYPE_CUSTOMER
) {
    $contextParameters->setUserId(0);
}

The second change is about which store the website check compares against. It used $this->storeManager->getStore(), and the patch replaces that with a method that reads the Store header off the request directly:

private function getEffectiveStore(): StoreInterface
{
    $storeCode = trim((string) $this->request->getHeader('Store'));
    if (!empty($storeCode)) {
        return $this->storeManager->getStore($storeCode);
    }
    return $this->storeManager->getStore();
}

The reason is ordering, and Adobe’s own comment in the matching B2B change spells it out: the context can be built during request validation, before header processing has switched the store manager to the requested store. You can see the sequence on 2.4.8 in Magento\GraphQlCache\Controller\Plugin\GraphQl::beforeDispatch(), which calls validateRequest() and only then processHeaders(). Any request validator that asks for the context, and the B2B company validator does exactly that, gets one built while the store manager is still on the default store. The website check then ran against the wrong website, and ContextFactory keeps hold of what it built. A token from website A, sent with a Store header for website B, was being compared against the default website, whichever that happened to be. Reading the header explicitly makes the check correct regardless of when it runs.

Version difference: This is the one where the older lines get more than the newer ones, not less. On 2.4.8-p5 and 2.4.9 the per-website check already existed and the patch adjusts it. On 2.4.4-p18, 2.4.5-p17, 2.4.6-p15 and 2.4.7-p10 the check did not exist at all. isCustomer() was simply “is there a non-empty id and is the type customer”. The patch backports the whole thing: it adds Share and StoreManagerInterface as nullable constructor arguments with object manager fallbacks, and introduces the website comparison alongside the two changes above. If you are on one of those lines with account sharing per website, this patch is the first time GraphQL has enforced it for customer tokens. Test your headless or PWA storefront against every website after applying, because behaviour that previously worked by accident will now be refused.

Instant Purchase accepted anyone’s address IDs

Magento\InstantPurchase\Controller\Button\PlaceOrder reads instant_purchase_shipping_address and instant_purchase_billing_address from the POST as integers and hands them to InstantPurchaseOptionLoadingFactory::create(). The factory loaded them with addressRepository->getById() and used them. There was no check that they belonged to the customer placing the order. The payment token lookup right above it is scoped to the customer id, so this is an inconsistency rather than a design decision.

$shippingAddress = $this->getAddress($shippingAddressId);
$billingAddress = $this->getAddress($billingAddressId);
if ((int)$shippingAddress->getCustomerId() !== $customerId ||
    (int)$billingAddress->getCustomerId() !== $customerId) {
    throw new NoSuchEntityException(__('Address not found.'));
}

Address entity ids are sequential integers. A logged-in customer with a saved card could place an instant purchase against any other customer’s address id, and get that customer’s name, street and telephone back in the order confirmation. Instant Purchase is enabled by default in config.xml, though the button only appears for a customer who already has a vaulted payment token and default addresses, so in practice the population that can reach this is stores running Braintree or another vault-capable gateway. It is a textbook insecure direct object reference and the fix is the right one: compare ownership, throw the same not-found error you would for a non-existent id, do not leak that the id was real.

Identical on all six lines.

PayPal Express pinned a quote to the session without checking whose it was

Magento\Paypal\Controller\Express\AbstractExpress::_initCheckout() stores the quote it is working with into the checkout session as the PayPal quote id, and _getQuote() later falls back to loading whatever quote id is stored there when the session has no quote of its own. The patch adds an ownership test before the pin:

if ($quote->getId() && $this->isQuoteAllowedForUser($quote)) {
    $this->_getCheckoutSession()->setPayPalQuoteId($quote->getId());
}
private function isQuoteAllowedForUser(CartInterface $quote): bool
{
    if ((int)$quote->getId() === (int)$this->_getCheckoutSession()->getQuoteId()) {
        return true;
    }

    return $this->_customerSession->isLoggedIn()
        && (int)$quote->getCustomerId() === (int)$this->_customerSession->getCustomerId();
}

A quote is only written back into the session if it is already the session’s quote, or the logged-in customer owns it. Anything else is not pinned, and so cannot be picked up again by the fallback in _getQuote(). The failure this prevents is a checkout session ending up bound to a cart that belongs to somebody else, which in a PayPal return flow means their items, their addresses and potentially their order.

Version difference: This file is not in the 2.4.4-p18 or 2.4.5-p17 patches at all. The patch does not say why. Either the code path differs enough on those lines that the hunk does not apply, or Adobe chose not to backport it. If you are on 2.4.4 or 2.4.5 with PayPal Express enabled you should know that this particular fix has not reached you.

Admin order creation rendered a VAT number as HTML

When an admin validates a VAT number on the order creation screen, order/create/scripts.js builds the result message by substituting the number into a translated template and shows it in a modal:

message = parameters.vatInvalidMessage.replace(/%s/, params.vat);

The modal renders HTML. The VAT value comes from the billing address form on that screen, which is prefilled from the customer’s saved address, which the customer typed in themselves on the storefront. So a customer sets their VAT id to a script payload, an admin creates an order for them and clicks validate and the payload runs in the admin session. Stored XSS from a customer-controlled field into the admin. The fix is exactly what you would expect:

var escapedVat = _.escape(params.vat);
...
message = parameters.vatInvalidMessage.replace(/%s/, escapedVat);

Underscore is added to the module’s dependencies for it. Identical on all six lines.

The URL escaper only decoded once

Magento\Framework\Escaper::escapeXssInUrl() is exposed on every block through AbstractBlock::escapeXssInUrl(), for URLs that came from a request and are about to go into an href. Core templates barely call it themselves; third-party and agency templates do, which is exactly the code that will not be in any Adobe test suite. It worked in three steps: html_entity_decode() the input, strip javascript:, data: and vbscript: schemes with a regex, then htmlspecialchars() the result with double_encode set to false so that any entities still present are left alone.

That last flag is the problem. Decode once means javascript: becomes javascript:. The scheme regex does not match that, because the j is an entity. htmlspecialchars() with double_encode = false leaves j exactly as it is. The browser then decodes the attribute value and sees javascript:. One layer of encoding more than the filter expected, and the filter is bypassed.

The patch decodes until the string stops changing:

private function decodeHtmlEntitiesToFixedPoint(string $data): string
{
    $iterationCap = 10;
    for ($iteration = 0; $iteration < $iterationCap; $iteration++) {
        $decoded = html_entity_decode($data);
        if ($decoded === $data) {
            return $data;
        }
        $data = $decoded;
    }

    return '';
}

Ten rounds is more than any legitimate URL needs, and if the input is still changing after ten it returns an empty string rather than guess. The scheme regex then runs against the fully decoded form, which is the only form worth checking.

If you have ever written your own “decode then filter” step anywhere, this is the pattern to look for. A single decode is a fixed assumption about how many layers the attacker used, and they get to choose that number.

Identical on all six lines.

B2B: two files, and one of them only on 1.5.3

vendor/magento/module-company/view/base/ui_component/company_form.xml
vendor/magento/module-company-graph-ql/Controller/HttpRequestValidator/CompanyValidator.php

Company form data was readable by any admin role

The first change is one line in the company form’s data source:

<dataSource name="company_form_data_source">
    <settings>
        <submitUrl path="company/index/save"/>
    </settings>
    <aclResource>Magento_Company::manage</aclResource>
    <dataProvider class="Magento\Company\Model\Company\DataProvider" name="company_form_data_source">

UI component forms load their data through mui/index/render, a generic admin endpoint that renders any registered component by namespace. That endpoint is gated by the admin session and by whatever aclResource the component’s data source declares, and nothing else. Without the declaration, mui/index/render?namespace=company_form&id=N would return the company record to any authenticated admin user regardless of role: company name, legal address, credit settings, the sales representative, the company admin’s details. The edit page itself was protected by its controller. The data behind it was not.

Adobe has been adding aclResource to grids and forms in security releases for years, and this is another one. It is on all five B2B lines.

CompanyValidator treated a zero user id as a customer

The second change is a companion to the Open Source GraphQL fix above. CompanyValidator is a request validator, which is why it carries the comment about the store scope not being ready yet. It decides whether the current context has a customer by checking:

if ($context->getUserId() === null
    || $context->getUserType() !== UserContextInterface::USER_TYPE_CUSTOMER
) {

Now that AddUserInfoToContext sets the user id to 0 for a customer token that fails the website check, 0 === null is false and the validator would carry on treating 0 as a real customer id. The patch changes the test to (int)$context->getUserId() <= 0, so zero takes the same fallback path as null.

Version difference: Only the 1.5.3 build contains this. The 1.5.2-p5, 1.4.2-p10, 1.3.4-p17 and 1.3.3-p18 patches carry the company_form.xml change alone, and this time there is a good reason. On 1.5.2 the validator does not look at the user id at all at this point. It checks $context->getExtensionAttributes()->getIsCustomer(), which is the flag the Open Source fix already sets correctly, so there is nothing to backport. The getUserId() === null test is a 1.5.3 rewrite, and this patch is fixing a regression that rewrite introduced.

Tested against 2.4.8

I checked the patches against a real 2.4.8-p5 installation with B2B 1.5.2-p3 before writing any of this, and the pre-patch code quoted above is from that vendor tree rather than from GitHub. git apply --check on the 2.4.8-p5 Open Source patch and the 1.5.2-p5 B2B patch both come back clean. The B2B one applies to a p3 install because company_form.xml has not changed between those patch levels. The 1.5.3 B2B build fails on the CompanyValidator hunk against 1.5.2, as it should.

One thing to be careful of: the 2.4.9 Open Source patch also passes git apply --check on a 2.4.8 tree. Every hunk’s context matches. It would then fatal the first time anyone deleted an export file, because it adds a use statement and a constructor argument for FileInfo, and that class does not exist below 2.4.9. Textual applicability is not the same as the right patch. Use the build named for your line.

What to do

Apply the patch for your line, and the B2B one if you have B2B. On Cloud it arrives with the magento/magento-cloud-patches 1.1.20 bump and is applied during deploy. Everywhere else, drop the .patch file into m2-hotfixes or apply it directly with git apply, then the usual:

bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush

Two classes pick up new nullable constructor arguments: AddUserInfoToContext on every line, and Export\File\Delete on 2.4.9 only. Both fall back to the object manager, so preferences and plugins on those classes keep working. If you have replaced AddUserInfoToContext or overridden _initCheckout() in a PayPal customisation, your copy keeps the old behaviour and you need to port the change by hand.

Then, specific to this release:

  • If you are on 2.4.4 or 2.4.5 with PayPal Express, you did not get the quote ownership fix. Know that.
  • If you are on 2.4.4 through 2.4.7 with account sharing per website and a GraphQL storefront, run your customer flows against every website. The website check is new to you.
  • Check the Backups and Import/Export grants on your admin roles. The rollback fix only matters for a role that holds backup without rollback, and the export fix for any role that holds export at all. Worth knowing which roles those are.

None of these are remote pre-authentication issues. Every one needs either an admin session or a customer session, and most need a specific feature enabled. That is the pattern of a monthly release rather than an emergency one. It is still nine changes in a single drop, and most of them are variations on the same mistake: the check existed, it was just in the wrong place, ran at the wrong time or stopped one layer too early.

You can read Adobe’s release notes for APSB26-138 over on their site.

Found this useful? Everything here is free and stays that way. If it saved you an afternoon, you can buy me a coffee.