$ cat adobes-september-2026-monthly-patch-for-magento-and-b2b-mcloud-15053.md
Adobe's September 2026 Monthly Patch for Magento and B2B (MCLOUD-15053)
Explains the eleven files in Adobe's September 2026 monthly patch MCLOUD-15053 for Magento Open Source and B2B, and notes that there is no Commerce build this month. Covers the backup rollback permission fix, the export file delete path check, and a GraphQL customer context change, including which fixes differ on older release lines.
dor.ky08 Sept 2026 · 8 min read · summary
Read on dor.ky ↗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 anyone going looking for one this month will not find it.
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 a
source
This is a summary of an article first published by dor.ky. The full piece is on their site.