<?xml version="1.0" encoding="utf-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/">
<channel>
    <title>Scott Robinson</title>
    <link>https://dor.ky</link>
    <description>Technical Lead keeping complex Adobe Commerce systems trading, and diagnosing the faults that cross system boundaries.</description>
    <language>en</language>
    <atom:link href="https://dor.ky/feed.xml" rel="self" type="application/rss+xml"/>
    
    <item>
        <title>Exporting Reward Points per Customer in Adobe Commerce</title>
        <link>https://dor.ky/post/export-reward-points-per-customer-adobe-commerce-sql</link>
        <guid isPermaLink="true">https://dor.ky/post/export-reward-points-per-customer-adobe-commerce-sql</guid>
        <pubDate>Thu, 17 Sep 2026 00:00:00 +0000</pubDate>
        
        <description>Magento_Reward gives you one customer at a time and no report, so it is a SQL job: where the balance lives, how to value it in currency and the three things that quietly make the number wrong</description>
        <content:encoded><![CDATA[<p>Adobe Commerce and <code>Magento_Reward</code> provide no native method of exporting reward points.</p>
<p>You can however export them with SQL:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span>
    <span class="hl-type">ce</span>.<span class="hl-property">entity_id</span>                                   <span class="hl-keyword">AS</span> <span class="hl-type">customer_id</span>,
    <span class="hl-type">ce</span>.<span class="hl-property">email</span>,
    <span class="hl-property">TRIM</span>(<span class="hl-property">CONCAT</span>(<span class="hl-property">COALESCE</span>(<span class="hl-type">ce</span>.<span class="hl-property">firstname</span>, '<span class="hl-value"></span>'), '<span class="hl-value"> </span>', <span class="hl-property">COALESCE</span>(<span class="hl-type">ce</span>.<span class="hl-property">lastname</span>, '<span class="hl-value"></span>'))) <span class="hl-keyword">AS</span> <span class="hl-type">customer_name</span>,
    <span class="hl-type">cg</span>.<span class="hl-property">customer_group_code</span>                         <span class="hl-keyword">AS</span> <span class="hl-type">customer_group</span>,
    <span class="hl-type">sw</span>.<span class="hl-property">name</span>                                        <span class="hl-keyword">AS</span> <span class="hl-type">website</span>,
    <span class="hl-type">ce</span>.<span class="hl-property">created_at</span>                                  <span class="hl-keyword">AS</span> <span class="hl-type">account_created</span>,
    <span class="hl-property">COALESCE</span>(<span class="hl-property">SUM</span>(<span class="hl-type">mr</span>.<span class="hl-property">points_balance</span>), <span class="hl-property">0</span>)            <span class="hl-keyword">AS</span> <span class="hl-type">points_balance</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">customer_entity</span> ce
<span class="hl-keyword">LEFT JOIN</span> <span class="hl-type">magento_reward</span> mr <span class="hl-keyword">ON</span> <span class="hl-type">mr</span>.<span class="hl-property">customer_id</span> = <span class="hl-type">ce</span>.<span class="hl-property">entity_id</span>
<span class="hl-keyword">LEFT JOIN</span> <span class="hl-type">customer_group</span>  cg <span class="hl-keyword">ON</span> <span class="hl-type">cg</span>.<span class="hl-property">customer_group_id</span> = <span class="hl-type">ce</span>.<span class="hl-property">group_id</span>
<span class="hl-keyword">LEFT JOIN</span> <span class="hl-type">store_website</span>   sw <span class="hl-keyword">ON</span> <span class="hl-type">sw</span>.<span class="hl-property">website_id</span> = <span class="hl-type">ce</span>.<span class="hl-property">website_id</span>
<span class="hl-keyword">GROUP BY</span> <span class="hl-type">ce</span>.<span class="hl-property">entity_id</span>, <span class="hl-type">ce</span>.<span class="hl-property">email</span>, <span class="hl-type">ce</span>.<span class="hl-property">firstname</span>, <span class="hl-type">ce</span>.<span class="hl-property">lastname</span>,
         <span class="hl-type">cg</span>.<span class="hl-property">customer_group_code</span>, <span class="hl-type">sw</span>.<span class="hl-property">name</span>, <span class="hl-type">ce</span>.<span class="hl-property">created_at</span>
<span class="hl-keyword">ORDER BY</span> points_balance <span class="hl-keyword">DESC</span>, <span class="hl-type">ce</span>.<span class="hl-property">entity_id</span>;
</pre>
<p>That gives you every customer account, with a balance of <code>0</code> for the ones who have never earned a point, sorted with the biggest balances first.</p>
<h2><a id="content-checking-balance-history" href="#content-checking-balance-history" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Checking Balance History</h2>
<p>If a specific balance looks wrong, the history table is the audit trail. Deltas for one customer:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> <span class="hl-type">h</span>.<span class="hl-property">created_at</span>, <span class="hl-type">h</span>.<span class="hl-property">action</span>, <span class="hl-type">h</span>.<span class="hl-property">points_delta</span>, <span class="hl-type">h</span>.<span class="hl-property">points_balance</span>,
       <span class="hl-type">h</span>.<span class="hl-property">is_expired</span>, <span class="hl-type">h</span>.<span class="hl-property">expired_at_static</span>, <span class="hl-type">h</span>.<span class="hl-property">expired_at_dynamic</span>, <span class="hl-type">h</span>.<span class="hl-property">comment</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">magento_reward_history</span> h
<span class="hl-keyword">JOIN</span> <span class="hl-type">magento_reward</span> mr <span class="hl-keyword">ON</span> <span class="hl-type">mr</span>.<span class="hl-property">reward_id</span> = <span class="hl-type">h</span>.<span class="hl-property">reward_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">mr</span>.<span class="hl-property">customer_id</span> = :customer_id
<span class="hl-keyword">ORDER BY</span> <span class="hl-type">h</span>.<span class="hl-property">history_id</span>;
</pre>
<p><code>points_balance</code> on each history row is the running total as it stood after that event, so the last row should equal <code>magento_reward.points_balance</code>. When it does not, the usual cause is the expiry backlog above. Expiry itself is written as a reversing row with <code>is_expired = 1</code> and <code>is_duplicate_of</code> pointing at the history row it cancels, so the original earn stays visible.</p>
<p>I checked the schema, the cron and the rate lookup against Adobe Commerce 2.4.8-p5 with <code>magento/module-reward</code> 101.2.8, and the same files are byte for byte identical on 2.4.7-p9 running 101.2.7-p5.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>StyleSmuggler Remediation: Invalidating Every Magento Admin Session and Password</title>
        <link>https://dor.ky/post/invalidate-all-magento-admin-sessions-sql</link>
        <guid isPermaLink="true">https://dor.ky/post/invalidate-all-magento-admin-sessions-sql</guid>
        <pubDate>Fri, 11 Sep 2026 00:00:00 +0000</pubDate>
        
        <description>Patching StyleSmuggler does not evict anyone already holding a session cookie: the two statements that do, what admin_user_session.status actually means and how to get back in afterwards</description>
        <content:encoded><![CDATA[<p>This post follows on from the <a href="https://sansec.io/research/stylesmuggler-0day">StyleSmuggler exploit</a> coverage: <a href="/post/cve-2026-75650-apsb26-patches-analysis">the VULN-39341 patch analysis</a> and <a href="/post/removing-adobe-fastly-rules-cve-2026-75650-admin-page-save">removing Adobe’s Fastly rules that break admin page saves</a>.</p>
<p>Patching CVE-2026-75650 closes the hole. It does nothing about anyone already inside. If the exploit was used against you before you patched, the attacker may be sitting on a valid admin session cookie, and possibly on a copy of your <code>admin_user</code> table. Neither expires because you deployed a patch.</p>
<p>There is no admin UI for this and no <code>bin/magento</code> command that does it. The quickest way to end every admin session and every admin password at once is two SQL statements:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">UPDATE</span> admin_user <span class="hl-keyword">SET</span> password = '<span class="hl-value">invalid</span>';
<span class="hl-keyword">UPDATE</span> admin_user_session <span class="hl-keyword">SET</span> status = 3 <span class="hl-keyword">WHERE</span> status = 1;
</pre>
<p>Run them in that order, on the live database, and everyone is out.</p>
<h2><a id="content-wiping-every-admin-password-hash" href="#content-wiping-every-admin-password-hash" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Wiping every admin password hash</h2>
<p>That is the point of the first statement. A stolen hash is now useless and so is a password the attacker already cracked or phished, because nothing they can type will match.</p>
<p>It does not throw a 500 on the next login attempt, which is the thing worth knowing before you run it. <code>Magento\Framework\Encryption\Encryptor::isValidHash()</code> calls <code>explodePasswordHash()</code>, which splits on <code>:</code> and expects three parts. The string <code>invalid</code> has none, so it throws <code>RuntimeException('Hash is not a password hash')</code>. That is caught one frame up by a <code>catch (\Throwable)</code> which falls back to <code>$recreated = $this-&gt;hash($password)</code>, and the subsequent <code>Security::compareStrings()</code> fails. The admin gets the ordinary “The account sign-in was incorrect or your account is disabled temporarily” message. No fatal, no stack trace, no clue to anyone probing the login form that the column has been wiped.</p>
<h2><a id="content-ending-every-live-session" href="#content-ending-every-live-session" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Ending every live session</h2>
<p>The second statement does that. <code>admin_user_session</code> is owned by <code>Magento_Security</code>, and its <code>status</code> column is declared in <code>vendor/magento/module-security/etc/db_schema.xml</code> as an unsigned <code>smallint</code> defaulting to <code>1</code>. There is no lookup table, no enum and no foreign key on it, so the meaning of the number lives entirely in PHP.</p>
<h3><a id="content-where-the-constants-are" href="#content-where-the-constants-are" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Where the constants are</h3>
<p>They are class constants on <code>Magento\Security\Model\AdminSessionInfo</code>, which is <code>vendor/magento/module-security/Model/AdminSessionInfo.php</code>. All four in full:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">const</span> <span class="hl-property">LOGGED_OUT</span> = 0;
<span class="hl-keyword">public</span> <span class="hl-keyword">const</span> <span class="hl-property">LOGGED_IN</span> = 1;
<span class="hl-keyword">public</span> <span class="hl-keyword">const</span> <span class="hl-property">LOGGED_OUT_BY_LOGIN</span> = 2;
<span class="hl-keyword">public</span> <span class="hl-keyword">const</span> <span class="hl-property">LOGGED_OUT_MANUALLY</span> = 3;
</pre>
<p>What each one means in practice:</p>
<ul>
<li><strong><code>0</code>, <code>LOGGED_OUT</code></strong>: a normal end to a session. Written by <code>AdminSessionsManager::processLogout()</code> when someone signs out, by <code>AdminSessionInfo::checkActivity()</code> when the row is older than the configured admin session lifetime and by <code>UserExpirationManager</code> when an account’s expiry date passes.</li>
<li><strong><code>1</code>, <code>LOGGED_IN</code></strong>: live. This is the only value the plugin below treats as valid, and the only one you need to match on.</li>
<li><strong><code>2</code>, <code>LOGGED_OUT_BY_LOGIN</code></strong>: the same account signed in somewhere else and the config is set to allow a single session. Produces “Someone logged into this account from another device or browser. Your current session is terminated.”</li>
<li><strong><code>3</code>, <code>LOGGED_OUT_MANUALLY</code></strong>: somebody deliberately ended the session from outside it. Produces “Your current session is terminated by another user of this account.”</li>
</ul>
<p>There is a fifth value that is not on this class at all. <code>AdminSessionsManager::LOGOUT_REASON_USER_LOCKED = 10</code> lives on <code>Magento\Security\Model\AdminSessionsManager</code> and is only ever used to pick a logout message, never written to the column. Do not use it in SQL.</p>
<h3><a id="content-why-3-is-the-right-number" href="#content-why-3-is-the-right-number" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why 3 is the right number</h3>
<p><code>LOGGED_OUT_MANUALLY</code> is not an arbitrary pick. It is exactly what core writes when an admin uses the “Log Out All Other Sessions” button on their account page. That controller is <code>Magento\Security\Controller\Adminhtml\Session\LogoutAll</code>, and it calls <code>AdminSessionsManager::logoutOtherUserSessions()</code>, which ends with:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$collection</span>-&gt;<span class="hl-property">setDataToAll</span>(<span class="hl-value">'status'</span>, <span class="hl-type">\Magento\Security\Model\AdminSessionInfo</span>::<span class="hl-property">LOGGED_OUT_MANUALLY</span>)
    -&gt;<span class="hl-property">save</span>();
</pre>
<p>The same method is called internally when a password changes. So the SQL above is the core behaviour with the per-user filter taken off: instead of “every other session belonging to me”, it is “every live session belonging to anyone”.</p>
<h3><a id="content-how-the-status-gets-enforced" href="#content-how-the-status-gets-enforced" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>How the status gets enforced</h3>
<p>Enforcement is a plugin, <code>security_admin_sessions_prolong</code>, declared in <code>vendor/magento/module-security/etc/adminhtml/di.xml</code> and pointing at <code>Magento\Security\Model\Plugin\AuthSession::aroundProlong()</code>. Every admin request prolongs the session, and that method starts with:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (!<span class="hl-variable">$this</span>-&gt;<span class="hl-property">sessionsManager</span>-&gt;<span class="hl-property">getCurrentSession</span>()-&gt;<span class="hl-property">isLoggedInStatus</span>()) {
    <span class="hl-variable">$session</span>-&gt;<span class="hl-property">destroy</span>();
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">addUserLogoutNotification</span>();
    <span class="hl-keyword">return</span> <span class="hl-keyword">null</span>;
}
</pre>
<p><code>isLoggedInStatus()</code> is a straight <code>getData('status') == self::LOGGED_IN</code> comparison against the database row. Set it to anything else and the next request the attacker makes destroys their session server side. You do not need to flush Redis or clear <code>var/session</code> for this to take effect, because the check is against the row, not the session payload.</p>
<p>The message the user sees comes from <code>AdminSessionsManager::getLogoutReasonMessageByStatus()</code>, a <code>switch</code> over those same constants. That is the practical argument for 3 over 0: your own staff get told the session was terminated rather than being silently dumped at the login screen.</p>
<h2><a id="content-getting-back-in" href="#content-getting-back-in" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Getting back in</h2>
<p>You have just locked yourself out too, so know the way back before you run it.</p>
<p>The forgot-password flow still works. It sets a fresh <code>rp_token</code> and the reset writes a new hash without ever validating the old one, so any admin whose email address is correct and whose mail is actually being delivered can recover on their own. Test that transactional email is working <em>before</em> you wipe the column, not after.</p>
<p>Failing that, create a new admin from the CLI:</p>
<pre data-lang="bash" class="notranslate">bin/magento admin:user:create \
  <span class="hl-generic">--admin-user</span>=recovery \
  <span class="hl-generic">--admin-password</span>=<span class="hl-value">'...'</span> \
  <span class="hl-generic">--admin-email</span>=you@example.com \
  <span class="hl-generic">--admin-firstname</span>=Recovery \
  <span class="hl-generic">--admin-lastname</span>=Admin
</pre>
<p>If you want the option of putting things back exactly as they were, take a copy of the column first:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">CREATE TABLE</span> admin_user_password_backup <span class="hl-keyword">AS</span> <span class="hl-keyword">SELECT</span> <span class="hl-property">user_id</span>, <span class="hl-property">username</span>, <span class="hl-property">password</span> <span class="hl-keyword">FROM</span> <span class="hl-type">admin_user</span>;
</pre>
<p>Think about whether you want that, though. After a suspected compromise the old hashes are the thing you are trying to get rid of, and a table full of them sitting in the same database is a liability. Drop it once everyone has reset.</p>
<h2><a id="content-caveats" href="#content-caveats" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Caveats</h2>
<h3><a id="content-magentosecurity-has-to-be-enabled" href="#content-magentosecurity-has-to-be-enabled" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Magento_Security has to be enabled</h3>
<p>With the module off the second statement does nothing. No plugin, no status check and the rows are inert. Some projects disable that module to stop the “session terminated” behaviour annoying staff who share logins. Check with <code>bin/magento module:status Magento_Security</code> first. If it is off, flush the session storage instead: <code>redis-cli -n &lt;session-db&gt; FLUSHDB</code>, or <code>rm -rf var/session/*</code> on a file-backed store.</p>
<h3><a id="content-sessionid-is-not-what-it-looks-like" href="#content-sessionid-is-not-what-it-looks-like" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>session_id is not what it looks like</h3>
<p>The column is declared <code>varchar(1)</code> and commented “Deprecated: Session ID value no longer used” in <code>db_schema.xml</code>. Do not try to target individual sessions through it.</p>
<h3><a id="content-this-covers-admin-sessions-only" href="#content-this-covers-admin-sessions-only" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>This covers admin sessions only</h3>
<p>Integration and API access is separate, in <code>oauth_token</code> and the integration tables. Customer sessions are separate again. If the compromise reached the point of creating an integration, revoking admin passwords does not touch it, and that is the persistence mechanism I would expect to find rather than a session cookie.</p>
<h3><a id="content-patch-first-then-invalidate" href="#content-patch-first-then-invalidate" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Patch first, then invalidate</h3>
<p>Kicking everyone out of an unpatched store buys you minutes. The order is patch, deploy, then invalidate, then have everyone reset through the forgot-password flow.</p>
<p>I ran the code paths above against an Adobe Commerce 2.4.8-p5 installation, <code>magento/module-security</code> 100.4.8-p4. The constants and the plugin have been stable for a long time, but check them on your own version before trusting a number out of a blog post.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Removing Adobe&#039;s CVE-2026-75650 Fastly Rules That Break Admin Page Saves</title>
        <link>https://dor.ky/post/removing-adobe-fastly-rules-cve-2026-75650-admin-page-save</link>
        <guid isPermaLink="true">https://dor.ky/post/removing-adobe-fastly-rules-cve-2026-75650-admin-page-save</guid>
        <pubDate>Thu, 10 Sep 2026 00:00:00 +0000</pubDate>
        
        <description>The accord-rce VCL snippets Adobe pushed for APSB26-146 return a 403 on any page containing a template directive, which is most of them: how to read, clone, delete and validate your way back out</description>
        <content:encoded><![CDATA[<p>Adobe’s response to CVE-2026-75650 did not stop at <a href="/post/cve-2026-75650-apsb26-patches-analysis">the composer patches</a> and the cloud-patches 1.1.21 release. On Adobe Commerce on cloud infrastructure, a set of edge rules also arrived on the Fastly service, added as custom VCL snippets called <code>accord-rce</code> and <code>accord_rce_1</code>.</p>
<p>They are a virtual patch. The point of them is to reject requests carrying the template-directive payloads that the APSB26-146 bulletin covers, at the CDN, before the origin sees them. For a project that cannot deploy the real patch immediately, that is a reasonable stopgap.</p>
<p>The problem is what else they reject.</p>
<h2><a id="content-the-symptom" href="#content-the-symptom" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The symptom</h2>
<p>Saving a CMS page or block in the admin panel fails. Depending on the theme and the admin’s own error handling you get a Fastly <code>403</code> page, a redirect back to the edit form with the content silently unsaved, or a JavaScript error where the save POST returned HTML instead of the JSON the grid expected. Nothing appears in <code>var/log/exception.log</code> or <code>var/log/system.log</code>, because the request never arrived. Magento is not involved.</p>
<p>This is a false positive, and an obvious one once you see it. The snippets match on the shape of a template directive in a request. Page content in Magento is <em>made</em> of template directives: <code>{{block class=&quot;...&quot;}}</code>, <code>{{widget type=&quot;...&quot;}}</code>, <code>{{media url=&quot;...&quot;}}</code>. A content editor saving a page that contains a widget is posting the exact pattern the rule was written to catch. So is anyone saving an email template, a dynamic block or a product description with a media directive in it.</p>
<p>The tell is that the failure follows the content, not the user. Same admin, same page, save a plain paragraph and it works, paste a widget back in and it 403s.</p>
<h2><a id="content-read-them-before-you-delete-them" href="#content-read-them-before-you-delete-them" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Read them before you delete them</h2>
<p>Do not delete blind. The snippet contents are not documented anywhere by Adobe, they were pushed to your service rather than committed to your repository, and once the version is gone the only copy is in Fastly’s version history. Record them first:</p>
<pre data-lang="bash" class="notranslate">fastly service vcl snippet describe <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">112</span> <span class="hl-generic">--name</span>=accord-rce
fastly service vcl snippet describe <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">112</span> <span class="hl-generic">--name</span>=accord_rce_1
</pre>
<p>Swap <code>112</code> for your active version. If you do not know it, <code>fastly service describe --service-id=&quot;$SID&quot;</code> prints it, and I covered listing and switching versions in more detail in <a href="/post/adobe-commerce-fastly-change-or-activate-different-service-version">this post on Fastly service versions</a>.</p>
<p>Keep that output. It tells you the type (<code>recv</code>, <code>fetch</code> and so on), the priority and the match itself, which is what you need if you later decide to put a narrower version of the rule back.</p>
<h2><a id="content-clone-the-active-version" href="#content-clone-the-active-version" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Clone the active version</h2>
<p>Fastly versions are immutable once activated, so you cannot edit the live one. Clone it:</p>
<pre data-lang="bash" class="notranslate">fastly service-version clone <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=active
</pre>
<p>That prints the new version number, for example <code>Version: 113</code>. Use your own number everywhere below.</p>
<h2><a id="content-delete-both-snippets" href="#content-delete-both-snippets" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Delete both snippets</h2>
<pre data-lang="bash" class="notranslate">fastly service vcl snippet delete <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">113</span> <span class="hl-generic">--name</span>=accord-rce
fastly service vcl snippet delete <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">113</span> <span class="hl-generic">--name</span>=accord_rce_1
</pre>
<p>Both. <code>accord_rce_1</code> is easy to miss because the naming is inconsistent, one hyphenated and one underscored with a numeric suffix, and leaving either in place leaves the 403 in place.</p>
<h2><a id="content-verify-then-validate" href="#content-verify-then-validate" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Verify, then validate</h2>
<p>List what is left on the new version:</p>
<pre data-lang="bash" class="notranslate">fastly service vcl snippet list <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">113</span>
</pre>
<p>Neither name should appear. Everything else should, including the Adobe Commerce module’s own generated snippets and any custom ones you have added yourself, such as <a href="/post/blocking-polyshell-sessionreaper-access-fastly-magento">the SessionReaper and PolyShell media blocks</a>.</p>
<p>Then make sure the version still compiles before you put traffic on it:</p>
<pre data-lang="bash" class="notranslate">fastly service-version validate <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">113</span>
</pre>
<p>This matters more than it looks. Removing a snippet can break a later one that referenced a variable or a backend it declared, and a VCL that fails to compile on activation takes the site down rather than falling back.</p>
<h2><a id="content-activate" href="#content-activate" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Activate</h2>
<pre data-lang="bash" class="notranslate">fastly service-version activate <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">113</span>
</pre>
<p>Activation is near instant across the edge. Go and save the CMS page that was failing. It should save.</p>
<p>Rolling back is the same command pointed at the old number:</p>
<pre data-lang="bash" class="notranslate">fastly service-version activate <span class="hl-generic">--service-id</span>=<span class="hl-value">&quot;$SID&quot;</span> <span class="hl-generic">--version</span>=<span class="hl-number">112</span>
</pre>
<p>Two things to watch afterwards. The Adobe Commerce Fastly module can push its own VCL from the admin panel, under <strong>Stores &gt; Configuration &gt; Advanced &gt; System &gt; Full Page Cache &gt; Fastly Configuration</strong>, and clicking upload there creates a further version. That is fine, it clones from active, so it will not resurrect the deleted snippets. But if <code>accord-rce</code> reappears on a new version you did not create, someone or something re-applied it, and on a cloud project that means asking Adobe support who pushed it and telling them why you removed it.</p>
<h2><a id="content-removing-the-rules-does-not-fix-the-vulnerability" href="#content-removing-the-rules-does-not-fix-the-vulnerability" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Removing the rules does not fix the vulnerability</h2>
<p>The snippets were a virtual patch for CVE-2026-75650. Deleting them because they break content editing puts you back to unmitigated unless the real fix is in place. Confirm it is before or at the same time as you activate the new version:</p>
<ul>
<li>On cloud, take the <code>magento/magento-cloud-patches</code> 1.1.21 bump and check <code>vendor/bin/ece-patches status | grep 15587</code> reports it applied. The entry pins exact patch levels, so a project one patch release behind gets nothing from it.</li>
<li>Off cloud, apply the VULN-39341 composer patch for your line, or the identical <code>MCLOUD-15587</code> file out of the 1.1.21 package, then <code>setup:upgrade</code>, <code>setup:di:compile</code> and <code>cache:flush</code>.</li>
</ul>
<p>I went through both routes, what the patches change and where an extended <code>BlockFactory</code> still leaves you exposed, in <a href="/post/cve-2026-75650-apsb26-patches-analysis">the patch analysis</a>.</p>
<p>If you do want an edge rule as well, write your own from the snippet contents you saved, scoped to the storefront and excluding <code>/admin</code> or whatever your admin front name is. The reason Adobe’s version breaks is that it does not make that distinction.</p>
<p>Adobe’s bulletin is <a href="https://helpx.adobe.com/security/products/magento/apsb26-146.html">APSB26-146</a>.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe&#039;s September 2026 Monthly Patch for Magento and B2B (MCLOUD-15053)</title>
        <link>https://dor.ky/post/adobe-september-2026-monthly-patch-mcloud-15053</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-september-2026-monthly-patch-mcloud-15053</guid>
        <pubDate>Tue, 08 Sep 2026 17:51:00 +0000</pubDate>
        
        <description>Eleven patch files, no EE build and fixes that thin out the further back your line goes: a rollback that ran before it said no, an export delete rooted at var, a GraphQL context checked against the wrong store and a URL escaper that only decoded once</description>
        <content:encoded><![CDATA[<p>You can view the <a href="https://helpx.adobe.com/security/products/magento/apsb26-138.html">official release notes for APSB26-138 on the Adobe site</a>.</p>
<p>Adobe’s September 2026 monthly patch for Magento landed today in <code>magento/magento-cloud-patches</code> 1.1.20 under the internal reference MCLOUD-15053. It is listed in <code>patches.json</code> 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.</p>
<pre data-lang="txt" class="notranslate">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
</pre>
<p>This is separate from the <a href="/post/cve-2026-75650-apsb26-patches-analysis">APSB26-146 / VULN-39341 patch</a> I wrote up earlier today. Different files, different bugs and you need both.</p>
<p>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.</p>
<h2><a id="content-open-source-the-eight-files" href="#content-open-source-the-eight-files" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Open Source: the eight files</h2>
<pre data-lang="txt" class="notranslate">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
</pre>
<p>The i18n change is a single new string, “Address not found.”, for the Instant Purchase fix. That leaves seven real changes.</p>
<h2><a id="content-backup-rollback-ran-before-it-told-you-no" href="#content-backup-rollback-ran-before-it-told-you-no" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Backup rollback ran before it told you no</h2>
<p><code>Magento\Backup\Controller\Adminhtml\Index\Rollback::execute()</code> used to open like this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (!<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_objectManager</span>-&gt;<span class="hl-property">get</span>(<span class="hl-type">\Magento\Backup\Helper\Data</span>::<span class="hl-keyword">class</span>)-&gt;<span class="hl-property">isRollbackAllowed</span>()) {
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_forward</span>(<span class="hl-value">'denied'</span>);
}

<span class="hl-keyword">if</span> (!<span class="hl-variable">$this</span>-&gt;<span class="hl-property">getRequest</span>()-&gt;<span class="hl-property">isAjax</span>()) {
    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_redirect</span>(<span class="hl-value">'*/*/index'</span>);
}
</pre>
<p><code>isRollbackAllowed()</code> is a thin wrapper around <code>isAllowed('Magento_Backup::rollback')</code>, so the intent is clear. The problem is the missing <code>return</code>. <code>_forward()</code> 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 <code>execute()</code> has finished. So an admin user without the rollback resource got the “denied” page rendered for them, after the rollback had already run.</p>
<p>The patch deletes the runtime check and replaces it with the constant the framework actually looks at:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Rollback</span> <span class="hl-keyword">extends</span> \Magento\Backup\Controller\Adminhtml\Index <span class="hl-keyword">implements</span><span class="hl-type"> HttpPostActionInterface
</span>{
    <span class="hl-comment">/**
     * <span class="hl-value">@see</span> _isAllowed()
     */</span>
    <span class="hl-keyword">public</span> <span class="hl-keyword">const</span> <span class="hl-property">ADMIN_RESOURCE</span> = <span class="hl-value">'Magento_Backup::rollback'</span>;
</pre>
<p>The parent controller declares <code>Magento_Backup::backup</code>, so before this the dispatcher’s own check was satisfied by anyone who could create a backup. Now <code>_isAllowed()</code> requires the child resource, and it runs in <code>dispatch()</code> before <code>execute()</code> is ever entered. Nothing to fall through.</p>
<p>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 <code>Magento_Backup::rollback</code> 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.</p>
<p>Identical on all six lines.</p>
<h2><a id="content-export-file-delete-could-reach-outside-the-export-folder" href="#content-export-file-delete-could-reach-outside-the-export-folder" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Export file delete could reach outside the export folder</h2>
<p><code>Magento\ImportExport\Controller\Adminhtml\Export\File\Delete</code> takes a <code>filename</code> from the request and did this with it:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$directoryWrite</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">filesystem</span>-&gt;<span class="hl-property">getDirectoryWrite</span>(<span class="hl-type">DirectoryList</span>::<span class="hl-property">VAR_IMPORT_EXPORT</span>);
<span class="hl-keyword">try</span> {
    <span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">delete</span>(<span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">getAbsolutePath</span>() . <span class="hl-value">'export/'</span> . <span class="hl-variable">$fileName</span>);
</pre>
<p>To be fair to the old code, <code>Write::delete()</code> calls <code>validatePath()</code> first, so <code>../../app/etc/env.php</code> was never going to work. The directory object refuses anything that resolves outside its root. The catch is where that root is. <code>DirectoryList::VAR_IMPORT_EXPORT</code> maps to plain <code>var</code>, not to <code>var/importexport</code>, so the check only guaranteed the target stayed somewhere under <code>var/</code>. <code>export/../log/system.log</code> is a valid path under that root. So is <code>export/../import_history</code>, and so is <code>export/..</code> itself. <code>delete()</code> handles directories too, recursively. An admin user whose role includes <code>Magento_ImportExport::export</code> could remove logs, reports, import history or the export directory wholesale, and the controller would report “File … deleted” as if nothing odd had happened.</p>
<p>The patch normalises the name, then insists the result is an existing file under <code>export/</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$fileName</span> = <span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">getDriver</span>()-&gt;<span class="hl-property">getRealPathSafety</span>(<span class="hl-property">DIRECTORY_SEPARATOR</span> . <span class="hl-variable">$fileName</span>);
<span class="hl-variable">$fileExist</span> = <span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">isFile</span>(<span class="hl-value">'export'</span> . <span class="hl-variable">$fileName</span>);
<span class="hl-keyword">if</span> (!<span class="hl-variable">$fileExist</span> <span class="hl-operator">||</span> !<span class="hl-variable">$this</span>-&gt;<span class="hl-property">isAllowedExportFile</span>(<span class="hl-variable">$fileName</span>)) {
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">messageManager</span>-&gt;<span class="hl-property">addErrorMessage</span>(<span class="hl-property">__</span>(
        <span class="hl-value">'Sorry, but the data is invalid or the file is not uploaded.'</span>
    ));
    <span class="hl-keyword">return</span> <span class="hl-variable">$resultRedirect</span>;
}
<span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">delete</span>(<span class="hl-variable">$directoryWrite</span>-&gt;<span class="hl-property">getAbsolutePath</span>() . <span class="hl-value">'export'</span> . <span class="hl-variable">$fileName</span>);
</pre>
<p><code>getRealPathSafety()</code> collapses <code>..</code> 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 <code>isFile()</code> check outright.</p>
<p><strong>Version difference:</strong> The <code>isAllowedExportFile()</code> half exists only on 2.4.9. It goes through <code>Magento\ImportExport\Model\Export\FileInfo</code>, 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 <code>if (!$fileExist)</code>. The traversal is closed on every line; the extension allow-list is a 2.4.9 extra.</p>
<h2><a id="content-graphql-customer-context-trusted-the-wrong-store" href="#content-graphql-customer-context-trusted-the-wrong-store" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>GraphQL customer context trusted the wrong store</h2>
<p><code>Magento\CustomerGraphQl\Model\Context\AddUserInfoToContext</code> is the processor that turns a bearer token into the GraphQL context’s user id, user type and <code>is_customer</code> flag. It gets two changes.</p>
<p>The first is what happens when a token is a customer token but the customer does not pass the <code>isCustomer()</code> 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 <code>is_customer = false</code> but the user id still set to the customer’s id. Anything downstream that consults <code>getUserId()</code> rather than the <code>is_customer</code> extension attribute would carry on as that customer, on a website they do not belong to. The patch zeroes the id:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (!<span class="hl-variable">$isCustomer</span>
    <span class="hl-operator">&amp;&amp;</span> !<span class="hl-keyword">empty</span>(<span class="hl-variable">$currentUserId</span>)
    <span class="hl-operator">&amp;&amp;</span> <span class="hl-variable">$currentUserType</span> === <span class="hl-type">UserContextInterface</span>::<span class="hl-property">USER_TYPE_CUSTOMER</span>
) {
    <span class="hl-variable">$contextParameters</span>-&gt;<span class="hl-property">setUserId</span>(0);
}
</pre>
<p>The second change is about <em>which</em> store the website check compares against. It used <code>$this-&gt;storeManager-&gt;getStore()</code>, and the patch replaces that with a method that reads the <code>Store</code> header off the request directly:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">getEffectiveStore</span>(): <span class="hl-type">StoreInterface</span>
{
    <span class="hl-variable">$storeCode</span> = <span class="hl-property">trim</span>((<span class="hl-type">string</span>) <span class="hl-variable">$this</span>-&gt;<span class="hl-property">request</span>-&gt;<span class="hl-property">getHeader</span>(<span class="hl-value">'Store'</span>));
    <span class="hl-keyword">if</span> (!<span class="hl-keyword">empty</span>(<span class="hl-variable">$storeCode</span>)) {
        <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">storeManager</span>-&gt;<span class="hl-property">getStore</span>(<span class="hl-variable">$storeCode</span>);
    }
    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">storeManager</span>-&gt;<span class="hl-property">getStore</span>();
}
</pre>
<p>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 <code>Magento\GraphQlCache\Controller\Plugin\GraphQl::beforeDispatch()</code>, which calls <code>validateRequest()</code> and only then <code>processHeaders()</code>. 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 <code>ContextFactory</code> keeps hold of what it built. A token from website A, sent with a <code>Store</code> 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.</p>
<p><strong>Version difference:</strong> 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. <code>isCustomer()</code> was simply “is there a non-empty id and is the type customer”. The patch backports the whole thing: it adds <code>Share</code> and <code>StoreManagerInterface</code> 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.</p>
<h2><a id="content-instant-purchase-accepted-anyones-address-ids" href="#content-instant-purchase-accepted-anyones-address-ids" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Instant Purchase accepted anyone’s address IDs</h2>
<p><code>Magento\InstantPurchase\Controller\Button\PlaceOrder</code> reads <code>instant_purchase_shipping_address</code> and <code>instant_purchase_billing_address</code> from the POST as integers and hands them to <code>InstantPurchaseOptionLoadingFactory::create()</code>. The factory loaded them with <code>addressRepository-&gt;getById()</code> 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.</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$shippingAddress</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">getAddress</span>(<span class="hl-variable">$shippingAddressId</span>);
<span class="hl-variable">$billingAddress</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">getAddress</span>(<span class="hl-variable">$billingAddressId</span>);
<span class="hl-keyword">if</span> ((<span class="hl-type">int</span>)<span class="hl-variable">$shippingAddress</span>-&gt;<span class="hl-property">getCustomerId</span>() !== <span class="hl-variable">$customerId</span> <span class="hl-operator">||</span>
    (<span class="hl-type">int</span>)<span class="hl-variable">$billingAddress</span>-&gt;<span class="hl-property">getCustomerId</span>() !== <span class="hl-variable">$customerId</span>) {
    <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">NoSuchEntityException</span>(<span class="hl-property">__</span>(<span class="hl-value">'Address not found.'</span>));
}
</pre>
<p>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 <code>config.xml</code>, 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.</p>
<p>Identical on all six lines.</p>
<h2><a id="content-paypal-express-pinned-a-quote-to-the-session-without-checking-whose-it-was" href="#content-paypal-express-pinned-a-quote-to-the-session-without-checking-whose-it-was" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>PayPal Express pinned a quote to the session without checking whose it was</h2>
<p><code>Magento\Paypal\Controller\Express\AbstractExpress::_initCheckout()</code> stores the quote it is working with into the checkout session as the PayPal quote id, and <code>_getQuote()</code> 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:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (<span class="hl-variable">$quote</span>-&gt;<span class="hl-property">getId</span>() <span class="hl-operator">&amp;&amp;</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">isQuoteAllowedForUser</span>(<span class="hl-variable">$quote</span>)) {
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_getCheckoutSession</span>()-&gt;<span class="hl-property">setPayPalQuoteId</span>(<span class="hl-variable">$quote</span>-&gt;<span class="hl-property">getId</span>());
}
</pre>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">isQuoteAllowedForUser</span>(<span class="hl-injection"><span class="hl-type">CartInterface</span> $quote</span>): <span class="hl-type">bool</span>
{
    <span class="hl-keyword">if</span> ((<span class="hl-type">int</span>)<span class="hl-variable">$quote</span>-&gt;<span class="hl-property">getId</span>() === (<span class="hl-type">int</span>)<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_getCheckoutSession</span>()-&gt;<span class="hl-property">getQuoteId</span>()) {
        <span class="hl-keyword">return</span> <span class="hl-keyword">true</span>;
    }

    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_customerSession</span>-&gt;<span class="hl-property">isLoggedIn</span>()
        <span class="hl-operator">&amp;&amp;</span> (<span class="hl-type">int</span>)<span class="hl-variable">$quote</span>-&gt;<span class="hl-property">getCustomerId</span>() === (<span class="hl-type">int</span>)<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_customerSession</span>-&gt;<span class="hl-property">getCustomerId</span>();
}
</pre>
<p>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 <code>_getQuote()</code>. 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.</p>
<p><strong>Version difference:</strong> 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.</p>
<h2><a id="content-admin-order-creation-rendered-a-vat-number-as-html" href="#content-admin-order-creation-rendered-a-vat-number-as-html" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Admin order creation rendered a VAT number as HTML</h2>
<p>When an admin validates a VAT number on the order creation screen, <code>order/create/scripts.js</code> builds the result message by substituting the number into a translated template and shows it in a modal:</p>
<pre data-lang="js" class="notranslate">message = parameters.<span class="hl-property">vatInvalidMessage</span>.<span class="hl-property">replace</span>(/%s/, params.<span class="hl-property">vat</span>);
</pre>
<p>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 what you would expect:</p>
<pre data-lang="js" class="notranslate"><span class="hl-keyword">var</span> escapedVat = _.<span class="hl-property">escape</span>(params.<span class="hl-property">vat</span>);
...
message = parameters.<span class="hl-property">vatInvalidMessage</span>.<span class="hl-property">replace</span>(/%s/, escapedVat);
</pre>
<p>Underscore is added to the module’s dependencies for it. Identical on all six lines.</p>
<h2><a id="content-the-url-escaper-only-decoded-once" href="#content-the-url-escaper-only-decoded-once" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The URL escaper only decoded once</h2>
<p><code>Magento\Framework\Escaper::escapeXssInUrl()</code> is exposed on every block through <code>AbstractBlock::escapeXssInUrl()</code>, for URLs that came from a request and are about to go into an <code>href</code>. 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: <code>html_entity_decode()</code> the input, strip <code>javascript:</code>, <code>data:</code> and <code>vbscript:</code> schemes with a regex, then <code>htmlspecialchars()</code> the result with <code>double_encode</code> set to <code>false</code> so that any entities still present are left alone.</p>
<p>That last flag is the problem. Decode once means <code>&amp;amp;#106;avascript:</code> becomes <code>&amp;#106;avascript:</code>. The scheme regex does not match that, because the <code>j</code> is an entity. <code>htmlspecialchars()</code> with <code>double_encode = false</code> leaves <code>&amp;#106;</code> exactly as it is. The browser then decodes the attribute value and sees <code>javascript:</code>. One layer of encoding more than the filter expected, and the filter is bypassed.</p>
<p>The patch decodes until the string stops changing:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">decodeHtmlEntitiesToFixedPoint</span>(<span class="hl-injection"><span class="hl-type">string</span> $data</span>): <span class="hl-type">string</span>
{
    <span class="hl-variable">$iterationCap</span> = 10;
    <span class="hl-keyword">for</span> (<span class="hl-variable">$iteration</span> = 0; <span class="hl-variable">$iteration</span> &lt; <span class="hl-variable">$iterationCap</span>; <span class="hl-variable">$iteration</span>++) {
        <span class="hl-variable">$decoded</span> = <span class="hl-property">html_entity_decode</span>(<span class="hl-variable">$data</span>);
        <span class="hl-keyword">if</span> (<span class="hl-variable">$decoded</span> === <span class="hl-variable">$data</span>) {
            <span class="hl-keyword">return</span> <span class="hl-variable">$data</span>;
        }
        <span class="hl-variable">$data</span> = <span class="hl-variable">$decoded</span>;
    }

    <span class="hl-keyword">return</span> <span class="hl-value">''</span>;
}
</pre>
<p>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.</p>
<p>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.</p>
<p>Identical on all six lines.</p>
<h2><a id="content-b2b-two-files-and-one-of-them-only-on-153" href="#content-b2b-two-files-and-one-of-them-only-on-153" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>B2B: two files, and one of them only on 1.5.3</h2>
<pre data-lang="txt" class="notranslate">vendor/magento/module-company/view/base/ui_component/company_form.xml
vendor/magento/module-company-graph-ql/Controller/HttpRequestValidator/CompanyValidator.php
</pre>
<h2><a id="content-company-form-data-was-readable-by-any-admin-role" href="#content-company-form-data-was-readable-by-any-admin-role" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Company form data was readable by any admin role</h2>
<p>The first change is one line in the company form’s data source:</p>
<pre data-lang="xml" class="notranslate">&lt;<span class="hl-keyword">dataSource</span> <span class="hl-property">name</span>=&quot;company_form_data_source&quot;&gt;
    &lt;<span class="hl-keyword">settings</span>&gt;
        &lt;<span class="hl-keyword">submitUrl</span> <span class="hl-property">path</span>=&quot;company/index/save&quot;/&gt;
    &lt;/<span class="hl-keyword">settings</span>&gt;
    &lt;<span class="hl-keyword">aclResource</span>&gt;Magento_Company::manage&lt;/<span class="hl-keyword">aclResource</span>&gt;
    &lt;<span class="hl-keyword">dataProvider</span> <span class="hl-property">class</span>=&quot;Magento\Company\Model\Company\DataProvider&quot; <span class="hl-property">name</span>=&quot;company_form_data_source&quot;&gt;
</pre>
<p>UI component forms load their data through <code>mui/index/render</code>, a generic admin endpoint that renders any registered component by namespace. That endpoint is gated by the admin session and by whatever <code>aclResource</code> the component’s data source declares, and nothing else. Without the declaration, <code>mui/index/render?namespace=company_form&amp;id=N</code> 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.</p>
<p>Adobe has been adding <code>aclResource</code> to grids and forms in security releases for years, and this is another one. It is on all five B2B lines.</p>
<h2><a id="content-companyvalidator-treated-a-zero-user-id-as-a-customer" href="#content-companyvalidator-treated-a-zero-user-id-as-a-customer" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>CompanyValidator treated a zero user id as a customer</h2>
<p>The second change is a companion to the Open Source GraphQL fix above. <code>CompanyValidator</code> 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:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (<span class="hl-variable">$context</span>-&gt;<span class="hl-property">getUserId</span>() === <span class="hl-keyword">null</span>
    <span class="hl-operator">||</span> <span class="hl-variable">$context</span>-&gt;<span class="hl-property">getUserType</span>() !== <span class="hl-type">UserContextInterface</span>::<span class="hl-property">USER_TYPE_CUSTOMER</span>
) {
</pre>
<p>Now that <code>AddUserInfoToContext</code> sets the user id to <code>0</code> for a customer token that fails the website check, <code>0 === null</code> is false and the validator would carry on treating <code>0</code> as a real customer id. The patch changes the test to <code>(int)$context-&gt;getUserId() &lt;= 0</code>, so zero takes the same fallback path as null.</p>
<p><strong>Version difference:</strong> 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 <code>company_form.xml</code> 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 <code>$context-&gt;getExtensionAttributes()-&gt;getIsCustomer()</code>, which is the flag the Open Source fix already sets correctly, so there is nothing to backport. The <code>getUserId() === null</code> test is a 1.5.3 rewrite, and this patch is fixing a regression that rewrite introduced.</p>
<h2><a id="content-tested-against-248" href="#content-tested-against-248" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Tested against 2.4.8</h2>
<p>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. <code>git apply --check</code> 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 <code>company_form.xml</code> has not changed between those patch levels. The 1.5.3 B2B build fails on the <code>CompanyValidator</code> hunk against 1.5.2, as it should.</p>
<p>One thing to be careful of: the 2.4.9 Open Source patch also passes <code>git apply --check</code> 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 <code>use</code> statement and a constructor argument for <code>FileInfo</code>, 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.</p>
<h2><a id="content-what-to-do" href="#content-what-to-do" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>What to do</h2>
<p>Apply the patch for your line, and the B2B one if you have B2B. On Cloud it arrives with the <code>magento/magento-cloud-patches</code> 1.1.20 bump and is applied during deploy. Everywhere else, drop the <code>.patch</code> file into <code>m2-hotfixes</code> or apply it directly with <code>git apply</code>, then the usual:</p>
<pre data-lang="bash" class="notranslate">bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
</pre>
<p>Two classes pick up new nullable constructor arguments: <code>AddUserInfoToContext</code> on every line, and <code>Export\File\Delete</code> on 2.4.9 only. Both fall back to the object manager, so preferences and plugins on those classes keep working. If you have <em>replaced</em> <code>AddUserInfoToContext</code> or overridden <code>_initCheckout()</code> in a PayPal customisation, your copy keeps the old behaviour and you need to port the change by hand.</p>
<p>Then, specific to this release:</p>
<ul>
<li>If you are on 2.4.4 or 2.4.5 with PayPal Express, you did not get the quote ownership fix. Know that.</li>
<li>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.</li>
<li>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. Know which roles those are.</li>
</ul>
<p>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.</p>
<p>You can read Adobe’s release notes for APSB26-138 <a href="https://helpx.adobe.com/security/products/magento/apsb26-138.html">over on their site</a>.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>CVE-2026-75650 / APSB26-146: Magento built the object before it checked the class type</title>
        <link>https://dor.ky/post/cve-2026-75650-apsb26-patches-analysis</link>
        <guid isPermaLink="true">https://dor.ky/post/cve-2026-75650-apsb26-patches-analysis</guid>
        <pubDate>Tue, 08 Sep 2026 09:34:00 +0000</pubDate>
        
        <description>Adobe shipped the same eight-file fix to every supported Magento version: two ordering bugs, an untyped request parameter and three ACL checks that moved</description>
        <content:encoded><![CDATA[<p>This post is about <a href="https://experienceleague.adobe.com/en/docs/commerce-knowledge-base/kb/announcements/commerce-apsb26-146">CVE-2026-75650 / APSB26-146</a>.</p>
<p>This post discusses the <a href="https://sansec.io/research/stylesmuggler-0day">StyleSmuggler exploit</a>, discovered by my good friends at <a href="https://sansec.io">Sansec</a>.</p>
<p>Adobe has shipped a set of composer patches under the internal reference VULN-39341, one build for each supported Magento line: 2.4.4-p18, 2.4.5-p17, 2.4.6-p15, 2.4.7-p10, 2.4.8-p5 and 2.4.9. I diffed all six against each other first, because that usually tells you something. They are byte-for-byte the same fix. The only differences are hunk offsets, blob hashes and one line where the older branch still uses <code>const</code> instead of <code>public const</code>. Every supported version gets the identical eight-file change, which means the bug has been in the platform since at least 2.4.4 and probably a lot longer.</p>
<p>What follows is an analysis of what the patch closes, based on the patch itself and on reading the pre-patch code in a real 2.4.7 vendor tree. There is no working exploit here and I am not going to publish one. The interesting part is the shape of the bugs, because two of them are patterns you have almost certainly written yourself.</p>
<h2><a id="content-the-eight-files" href="#content-the-eight-files" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The eight files</h2>
<pre data-lang="txt" class="notranslate">vendor/magento/framework/View/Element/BlockFactory.php
vendor/magento/framework/View/Layout/Generator/Block.php
vendor/magento/framework/Webapi/ErrorProcessor.php
vendor/magento/module-backend/Model/Widget/Grid/Row/UrlGeneratorFactory.php
vendor/magento/module-email/Block/Adminhtml/Template/Preview.php
vendor/magento/module-email/Model/AbstractTemplate.php
vendor/magento/module-newsletter/Block/Adminhtml/Queue/Preview.php
vendor/magento/module-newsletter/Block/Adminhtml/Template/Preview.php
pub/errors/processor.php
</pre>
<p>Three distinct problems, and they are worth separating because they carry very different risk.</p>
<h2><a id="content-problem-one-instantiate-first-type-check-second" href="#content-problem-one-instantiate-first-type-check-second" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Problem one: instantiate first, type-check second</h2>
<p>This is the important one. <code>BlockFactory::createBlock()</code> looked like this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">createBlock</span>(<span class="hl-injection">$blockName, <span class="hl-type">array</span> $arguments = []</span>)
{
    <span class="hl-variable">$blockName</span> = <span class="hl-property">ltrim</span>(<span class="hl-variable">$blockName</span>, <span class="hl-value">'\\'</span>);
    <span class="hl-variable">$block</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">objectManager</span>-&gt;<span class="hl-property">create</span>(<span class="hl-variable">$blockName</span>, <span class="hl-variable">$arguments</span>);
    <span class="hl-keyword">if</span> (!<span class="hl-variable">$block</span> <span class="hl-keyword">instanceof</span> <span class="hl-type">BlockInterface</span>) {
        <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">\LogicException</span>(<span class="hl-variable">$blockName</span> . <span class="hl-value">' does not implement BlockInterface'</span>);
    }
    ...
</pre>
<p>Read the order of operations. The class is constructed, and only then is it asked whether it was ever allowed to exist. By the time the <code>instanceof</code> check runs and throws, the constructor has already executed. So has every constructor in the dependency graph the object manager resolved to build it, and so has any <code>__destruct</code> that fires when the rejected object is garbage collected.</p>
<p>That is arbitrary object instantiation, which in PHP is a well-worn route to worse things. You are not looking for a class that does something useful when you call a method on it, because you never get to call a method. You are looking for a class whose <em>construction</em> has a side effect: one that writes a file, opens a connection, deserializes something or hands a value to a sink further down. Magento’s object manager can construct a very large share of the classes in the codebase, plus everything in <code>vendor/</code>, and the arguments array is under the caller’s control too.</p>
<p>The fix is to resolve the DI preference for the requested name and type-check the string, before anything gets built:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$resolvedType</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">objectManagerConfig</span>-&gt;<span class="hl-property">getInstanceType</span>(
    <span class="hl-variable">$this</span>-&gt;<span class="hl-property">objectManagerConfig</span>-&gt;<span class="hl-property">getPreference</span>(<span class="hl-variable">$blockName</span>)
);
<span class="hl-keyword">if</span> (!<span class="hl-property">is_a</span>(<span class="hl-variable">$resolvedType</span>, <span class="hl-type">BlockInterface</span>::<span class="hl-keyword">class</span>, <span class="hl-keyword">true</span>)) {
    <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">\LogicException</span>(<span class="hl-variable">$blockName</span> . <span class="hl-value">' does not implement BlockInterface'</span>);
}
<span class="hl-variable">$block</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">objectManager</span>-&gt;<span class="hl-property">create</span>(<span class="hl-variable">$blockName</span>, <span class="hl-variable">$arguments</span>);
</pre>
<p><code>is_a()</code> with the third argument set to <code>true</code> accepts a class name string rather than an object, so the check happens with nothing instantiated. Resolving the preference first matters, because the name passed in is frequently an interface or a virtual type, and checking the raw name would reject legitimate blocks.</p>
<p>The same pattern, and the same fix, in <code>UrlGeneratorFactory::createUrlGenerator()</code>. Before:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$rowUrlGenerator</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_objectManager</span>-&gt;<span class="hl-property">create</span>(<span class="hl-variable">$generatorClassName</span>, <span class="hl-variable">$arguments</span>);
<span class="hl-keyword">if</span> (<span class="hl-keyword">false</span> === <span class="hl-variable">$rowUrlGenerator</span> <span class="hl-keyword">instanceof</span> <span class="hl-type">GeneratorInterface</span>) {
    <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">\InvalidArgumentException</span>(<span class="hl-value">'Passed wrong parameters'</span>);
}
</pre>
<p>After, with Adobe’s own comment left in the patch:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">// Validate the type BEFORE instantiation.</span>
<span class="hl-keyword">if</span> (!<span class="hl-property">is_a</span>(<span class="hl-variable">$generatorClassName</span>, <span class="hl-type">GeneratorInterface</span>::<span class="hl-keyword">class</span>, <span class="hl-keyword">true</span>)) {
    <span class="hl-keyword">throw</span> <span class="hl-keyword">new</span> <span class="hl-type">\InvalidArgumentException</span>(<span class="hl-value">'Passed wrong parameters'</span>);
}
<span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_objectManager</span>-&gt;<span class="hl-property">create</span>(<span class="hl-variable">$generatorClassName</span>, <span class="hl-variable">$arguments</span>);
</pre>
<p>That one is reached from <code>Magento\Backend\Block\Widget\Grid\ColumnSet::__construct()</code>, which reads the generator class name straight out of block data:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$generatorClassName</span> = <span class="hl-type">\Magento\Backend\Model\Widget\Grid\Row\UrlGenerator</span>::<span class="hl-keyword">class</span>;
<span class="hl-keyword">if</span> (<span class="hl-keyword">isset</span>(<span class="hl-variable">$data</span>[<span class="hl-value">'rowUrl'</span>])) {
    <span class="hl-variable">$rowUrlParams</span> = <span class="hl-variable">$data</span>[<span class="hl-value">'rowUrl'</span>];
    <span class="hl-keyword">if</span> (<span class="hl-keyword">isset</span>(<span class="hl-variable">$rowUrlParams</span>[<span class="hl-value">'generatorClass'</span>])) {
        <span class="hl-variable">$generatorClassName</span> = <span class="hl-variable">$rowUrlParams</span>[<span class="hl-value">'generatorClass'</span>];
    }
</pre>
<p>Anywhere block data is influenced by something less trustworthy than a developer editing layout XML, that is a class-name sink.</p>
<h2><a id="content-why-the-block-factory-is-the-one-that-matters" href="#content-why-the-block-factory-is-the-one-that-matters" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why the block factory is the one that matters</h2>
<p>A factory buried in the framework only matters if something can feed it a name. Something can.</p>
<p><code>Magento\Email\Model\Template\Filter::blockDirective()</code> is what implements the <code>{{block class=&quot;...&quot;}}</code> directive:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (<span class="hl-keyword">isset</span>(<span class="hl-variable">$blockParameters</span>[<span class="hl-value">'class'</span>])) {
    <span class="hl-variable">$block</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">_layout</span>-&gt;<span class="hl-property">createBlock</span>(<span class="hl-variable">$blockParameters</span>[<span class="hl-value">'class'</span>], <span class="hl-keyword">null</span>, [<span class="hl-value">'data'</span> =&gt; <span class="hl-variable">$blockParameters</span>]);
}
</pre>
<p><code>Layout::createBlock()</code> goes to <code>Layout\Generator\Block::getBlockInstance()</code>, which goes to <code>BlockFactory::createBlock()</code>. And <code>Magento\Cms\Model\Template\Filter</code> is a one-line subclass:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">class</span> <span class="hl-type">Filter</span> <span class="hl-keyword">extends</span> \Magento\Email\Model\Template\Filter
</pre>
<p>So the directive is live in CMS pages, CMS blocks and widget content, and the result renders on the storefront to anonymous visitors. Pre-patch, the class named in that directive was constructed before anyone asked whether it was a block. An admin whose role grants nothing but CMS editing could reach a construction primitive across most of the codebase, and see the fallout on a public page. That is a privilege escalation inside the admin, not just a hardening nit.</p>
<p>The email and newsletter template editors reach the same directive by the same route, which is why the preview blocks are in this patch at all.</p>
<p>There is a small companion change in <code>Layout\Generator\Block</code> that tells you the new check throws in places the old code never expected an exception:</p>
<pre data-lang="php" class="notranslate">-            } <span class="hl-keyword">catch</span> (\ReflectionException <span class="hl-variable">$e</span>) {
+            } <span class="hl-keyword">catch</span> (\ReflectionException | \LogicException <span class="hl-variable">$e</span>) {
</pre>
<p>Without it, a layout naming a non-block class would surface the new <code>LogicException</code> as a fatal instead of a logged critical. Worth knowing if you have a theme or a module that names something odd in layout XML: post-patch it will log and render empty rather than blow up, but it also will not silently construct the object any more.</p>
<h2><a id="content-problem-two-request-parameters-typed-as-whatever-arrived" href="#content-problem-two-request-parameters-typed-as-whatever-arrived" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Problem two: request parameters typed as “whatever arrived”</h2>
<p><code>Magento\Email\Block\Adminhtml\Template\Preview::_toHtml()</code> builds an unsaved template out of raw request input:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$template</span>-&gt;<span class="hl-property">setTemplateType</span>(<span class="hl-variable">$request</span>-&gt;<span class="hl-property">getParam</span>(<span class="hl-value">'type'</span>));
<span class="hl-variable">$template</span>-&gt;<span class="hl-property">setTemplateText</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_maliciousCode</span>-&gt;<span class="hl-property">filter</span>(<span class="hl-variable">$request</span>-&gt;<span class="hl-property">getParam</span>(<span class="hl-value">'text'</span>)));
<span class="hl-variable">$template</span>-&gt;<span class="hl-property">setTemplateStyles</span>(<span class="hl-variable">$request</span>-&gt;<span class="hl-property">getParam</span>(<span class="hl-value">'styles'</span>));
</pre>
<p><code>getParam()</code> returns whatever the query string or POST body says, and PHP will happily give you an array from <code>text[]=a&amp;text[]=b</code>. The filter in the middle does not stop that. <code>Magento\Framework\Filter\Input\MaliciousCode::filter()</code> is documented as taking <code>string|array</code>, and it runs <code>preg_replace()</code> over the value, which returns an array when handed one:</p>
<pre data-lang="php" class="notranslate"><span class="hl-comment">/**
 * <span class="hl-value">@param</span> <span class="hl-type">string|array</span> <span class="hl-variable">$value</span>
 * <span class="hl-value">@return</span> <span class="hl-type">string|array</span>
 */</span>
<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">filter</span>(<span class="hl-injection">$value</span>)
{
    <span class="hl-variable">$replaced</span> = 0;
    <span class="hl-keyword">do</span> {
        <span class="hl-variable">$value</span> = <span class="hl-property">preg_replace</span>(<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_expressions</span>, <span class="hl-value">''</span>, <span class="hl-variable">$value</span> ?? <span class="hl-value">''</span>, -1, <span class="hl-variable">$replaced</span>);
    } <span class="hl-keyword">while</span> (<span class="hl-variable">$replaced</span> !== 0);
</pre>
<p>So an array, or a nested array, survived into <code>setTemplateText()</code>, which is plain magic-method <code>setData()</code> with no type at all, and from there into the template filter which expects a string throughout. The patch replaces the magic setters with real ones on <code>Magento\Email\Model\AbstractTemplate</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">setTemplateText</span>(<span class="hl-injection">$value</span>)
{
    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">setData</span>(<span class="hl-value">'template_text'</span>, <span class="hl-property">is_string</span>(<span class="hl-variable">$value</span>) <span class="hl-operator">?</span> <span class="hl-variable">$value</span> : <span class="hl-value">''</span>);
}

<span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">setTemplateStyles</span>(<span class="hl-injection">$value</span>)
{
    <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">setData</span>(<span class="hl-value">'template_styles'</span>, <span class="hl-property">is_string</span>(<span class="hl-variable">$value</span>) <span class="hl-operator">?</span> <span class="hl-variable">$value</span> : <span class="hl-value">''</span>);
}
</pre>
<p>Coerce to empty string rather than throw, so nothing that legitimately passes null keeps working. Note also that <code>styles</code> was never passed through the malicious-code filter at all, and still is not: the string coercion is the whole of its protection.</p>
<p>The lesson generalises past this patch. <code>getParam()</code> is not a string. Every <code>setSomething($request-&gt;getParam(...))</code> in your own code is an untyped hand-off, and Magento’s magic setters will store an array as cheerfully as a string.</p>
<h2><a id="content-problem-three-attacker-controlled-strings-written-to-disk-as-php-adjacent-files" href="#content-problem-three-attacker-controlled-strings-written-to-disk-as-php-adjacent-files" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Problem three: attacker-controlled strings written to disk as PHP-adjacent files</h2>
<p>Two near-identical fixes, one in the Web API error path and one in the public error page.</p>
<p><code>Magento\Framework\Webapi\ErrorProcessor::_saveFatalErrorReport()</code> before:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">directoryWrite</span>-&gt;<span class="hl-property">create</span>(<span class="hl-value">'report/api'</span>);
<span class="hl-variable">$reportId</span> = <span class="hl-property">abs</span>((<span class="hl-type">int</span>)(<span class="hl-property">microtime</span>(<span class="hl-keyword">true</span>) * <span class="hl-property">random_int</span>(100, 1000)));
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">directoryWrite</span>-&gt;<span class="hl-property">writeFile</span>(<span class="hl-value">'report/api/'</span> . <span class="hl-variable">$reportId</span>, <span class="hl-variable">$this</span>-&gt;<span class="hl-property">serializer</span>-&gt;<span class="hl-property">serialize</span>(<span class="hl-variable">$reportData</span>));
</pre>
<p>After:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">if</span> (<span class="hl-property">is_string</span>(<span class="hl-variable">$reportData</span>)) {
    <span class="hl-variable">$reportData</span> = <span class="hl-property">str_replace</span>(<span class="hl-value">'&lt;?'</span>, <span class="hl-value">'&lt; ?'</span>, <span class="hl-variable">$reportData</span>);
}
<span class="hl-variable">$this</span>-&gt;<span class="hl-property">directoryWrite</span>-&gt;<span class="hl-property">writeFile</span>(
    <span class="hl-value">'report/api/'</span> . <span class="hl-variable">$reportId</span>,
    <span class="hl-type">self</span>::<span class="hl-property">REPORT_EXECUTION_GUARD</span> . <span class="hl-property">PHP_EOL</span> . <span class="hl-variable">$this</span>-&gt;<span class="hl-property">serializer</span>-&gt;<span class="hl-property">serialize</span>(<span class="hl-variable">$reportData</span>)
);
</pre>
<p>where the guard is the string <code>&lt;?php exit; ?&gt;</code>. <code>pub/errors/processor.php</code> gets the same treatment for the storefront reports under <code>var/report/</code>, with a recursive walk because that report data is an array:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">sanitizeReportData</span>(<span class="hl-injection"><span class="hl-type">array</span> $data</span>): <span class="hl-type">array</span>
{
    <span class="hl-property">array_walk_recursive</span>(<span class="hl-variable">$data</span>, <span class="hl-keyword">static</span> <span class="hl-keyword">function</span> (<span class="hl-injection">&amp;$value</span>) {
        <span class="hl-keyword">if</span> (<span class="hl-property">is_string</span>(<span class="hl-variable">$value</span>)) {
            <span class="hl-variable">$value</span> = <span class="hl-property">str_replace</span>(<span class="hl-value">'&lt;?'</span>, <span class="hl-value">'&lt; ?'</span>, <span class="hl-variable">$value</span>);
        }
    });
    <span class="hl-keyword">return</span> <span class="hl-variable">$data</span>;
}
</pre>
<p>and a matching reader that strips the guard back off, so existing report viewing still works:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">readReportFile</span>(<span class="hl-injection"><span class="hl-type">string</span> $reportFile</span>): <span class="hl-type">string</span>
{
    <span class="hl-variable">$contents</span> = (<span class="hl-type">string</span>)<span class="hl-property">file_get_contents</span>(<span class="hl-variable">$reportFile</span>);
    <span class="hl-variable">$guard</span> = <span class="hl-type">self</span>::<span class="hl-property">REPORT_EXECUTION_GUARD</span>;
    <span class="hl-keyword">if</span> (<span class="hl-property">strncmp</span>(<span class="hl-variable">$contents</span>, <span class="hl-variable">$guard</span>, <span class="hl-property">strlen</span>(<span class="hl-variable">$guard</span>)) === 0) {
        <span class="hl-variable">$contents</span> = <span class="hl-property">ltrim</span>(<span class="hl-property">substr</span>(<span class="hl-variable">$contents</span>, <span class="hl-property">strlen</span>(<span class="hl-variable">$guard</span>)), <span class="hl-value">&quot;\r\n&quot;</span>);
    }
    <span class="hl-keyword">return</span> <span class="hl-variable">$contents</span>;
}
</pre>
<p>The point is not that Magento was executing these files. It was not. The point is that any request that fatals, on <code>/rest/...</code>, on <code>/graphql</code> or on the storefront, gave you a file on disk in a predictable directory whose contents you substantially controlled, including PHP open tags. That is a write primitive parked one misconfiguration away from execution. All it needs is a webserver that serves <code>var/</code> because someone loosened a location block, an <code>include</code> or <code>file_get_contents</code> anywhere that takes a path fragment, a backup tool that copies <code>var/report</code> somewhere web-visible or a template engine reached through problem one. <code>&lt;?php exit; ?&gt;</code> in front of the payload makes the file inert as a PHP script no matter how it eventually gets included, and neutralising <code>&lt;?</code> to <code>&lt; ?</code> means the body cannot reopen it.</p>
<p>This is the same defence Magento already applies to generated files, and the same reason Laravel writes <code>&lt;?php exit; ?&gt;</code> at the head of its session files. It is cheap and it is unconditional, which is exactly what you want in an error handler that by definition runs when things are already wrong.</p>
<h2><a id="content-the-acl-additions-and-what-they-are-actually-for" href="#content-the-acl-additions-and-what-they-are-actually-for" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The ACL additions, and what they are actually for</h2>
<p>Three preview blocks pick up an admin resource constant and a check at the top of <code>_toHtml()</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">const</span> <span class="hl-property">ADMIN_RESOURCE</span> = <span class="hl-value">'Magento_Email::template'</span>;
...
<span class="hl-keyword">protected</span> <span class="hl-keyword">function</span> <span class="hl-property">_toHtml</span>()
{
    <span class="hl-keyword">if</span> (!<span class="hl-variable">$this</span>-&gt;<span class="hl-property">_authorization</span>-&gt;<span class="hl-property">isAllowed</span>(<span class="hl-type">self</span>::<span class="hl-property">ADMIN_RESOURCE</span>)) {
        <span class="hl-keyword">return</span> <span class="hl-value">''</span>;
    }
</pre>
<p>plus <code>Magento_Newsletter::template</code> on the newsletter template preview and <code>Magento_Newsletter::queue</code> on the queue preview.</p>
<p>It is worth being precise about what this does and does not mean, because it is easy to read a new <code>isAllowed()</code> call as proof that an endpoint was open. It was not. The controllers in front of these blocks were already gated. <code>Magento\Email\Controller\Adminhtml\Email\Template</code> carries <code>public const ADMIN_RESOURCE = 'Magento_Email::template'</code>, the newsletter template controller carries <code>Magento_Newsletter::template</code>, and <code>Magento\Newsletter\Controller\Adminhtml\Queue</code> not only declares <code>Magento_Newsletter::queue</code> but overrides <code>_isAllowed()</code>. The admin router requires a session for all of them. Nothing in this patch is a pre-authentication issue, and nobody is hitting these URLs anonymously.</p>
<p>What the block-level check buys you is protection for the case where the block renders somewhere other than behind its own controller. Given problem one, that case is not hypothetical: <code>{{block class=&quot;Magento\Email\Block\Adminhtml\Template\Preview&quot;}}</code> in content processed by the CMS filter is the exact scenario, and the block reads <code>type</code>, <code>text</code> and <code>styles</code> off the current request whatever that request happens to be. Moving the authorisation check from the route to the block means the check travels with the code that reads the input. That is the right place for it.</p>
<h2><a id="content-what-to-do" href="#content-what-to-do" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>What to do</h2>
<p>Apply the patch. It is one composer patch per version line, it touches no schema and no DI wiring you own, and there is nothing to configure afterwards. Clear generated code and recompile as usual, because <code>BlockFactory</code> picks up a new constructor argument:</p>
<pre data-lang="bash" class="notranslate">bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
</pre>
<p>The new second constructor argument on <code>BlockFactory</code> is nullable with an object-manager fallback, so a plugin or a preference of your own on that class will keep working. If you have extended <code>BlockFactory</code> or <code>UrlGeneratorFactory</code> and reimplemented <code>createBlock</code> or <code>createUrlGenerator</code>, your override silently keeps the old behaviour. Check for that first, it is the one realistic way to apply this patch and stay vulnerable.</p>
<p>Two things worth doing while you are in there, neither of which is in the patch:</p>
<p>Confirm your webserver cannot serve <code>var/</code>, <code>generated/</code> or <code>pub/media</code> as anything executable. The nginx sample config gets this right; hand-rolled configs and Apache setups with a loose <code>&lt;Directory&gt;</code> block frequently do not. The report guard makes an inert file, but you would rather not be relying on it.</p>
<p>Grep your own code for the instantiate-then-check pattern, because it is a natural thing to write:</p>
<pre data-lang="bash" class="notranslate">grep <span class="hl-generic">-rn</span> <span class="hl-generic">--include</span>=*.php <span class="hl-generic">-A</span> <span class="hl-number">4</span> <span class="hl-value">&quot;objectManager-&gt;create(&quot;</span> app/code <span class="hl-operator">|</span> grep instanceof
</pre>
<p>Any hit where the argument came from a request parameter, block data, layout XML from a merchant-editable source or a database column, is the same bug. The fix is the same one line: <code>is_a($className, TheInterface::class, true)</code> before you construct anything.</p>
<h2><a id="content-why-this-one-is-worth-reading-the-diff-for" href="#content-why-this-one-is-worth-reading-the-diff-for" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why this one is worth reading the diff for</h2>
<p>Most Magento security patches are a sanitizer added somewhere obvious. This one is a set of ordering bugs, and ordering bugs are invisible in review because every line involved looks correct. <code>$this-&gt;objectManager-&gt;create()</code> is correct. <code>instanceof BlockInterface</code> is correct. Putting them in that sequence is the entire vulnerability, and no amount of static analysis was going to flag it.</p>
<p>The same is true of the report writers. Nothing was executing those files, no test would fail and the code had presumably looked fine to everyone who read it since 2.4.0. It took someone asking a different question: not “is this correct” but “what does this give an attacker that they did not have before”.</p>
<h2><a id="content-invalidating-admin-passwords-afterwards" href="#content-invalidating-admin-passwords-afterwards" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Invalidating admin passwords afterwards</h2>
<p>A few people in the Magento Open Source Slack have asked for the quickest way to invalidate admin passwords after rotating encryption keys. Do it in SQL:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">UPDATE</span> admin_user <span class="hl-keyword">SET</span> password = '<span class="hl-value">invalid</span>';
<span class="hl-keyword">UPDATE</span> admin_user_session <span class="hl-keyword">SET</span> status = 3 <span class="hl-keyword">WHERE</span> status = 1;
</pre>
<p>then truncate the <code>tfa_user_config</code> table and clear out your session storage.</p>
<p>I’ve written <a href="/post/invalidate-all-magento-admin-sessions-sql">more on doing the above in this post</a>.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Magento contactUs GraphQL Mutation Isn&#039;t Protected by reCAPTCHA</title>
        <link>https://dor.ky/post/magento-contactus-graphql-recaptcha-bypass</link>
        <guid isPermaLink="true">https://dor.ky/post/magento-contactus-graphql-recaptcha-bypass</guid>
        <pubDate>Wed, 02 Sep 2026 00:00:00 +0000</pubDate>
        
        <description>How a Magento 2.4.8 store can be flooded with contact enquiry emails through the contactUs GraphQL mutation, which bypasses the reCAPTCHA that protects the contact form, and how to backport the 2.4.9 fix</description>
        <content:encoded><![CDATA[<p>A store I work on started receiving a flood of contact enquiry emails, a couple of hundred in the space of a few minutes, all from a single host. The contact form has reCAPTCHA enabled and has done for over a year, so on the face of it this shouldn’t have been possible. The reCAPTCHA config was correct, the keys were in place and the form was rejecting bots exactly as it should.</p>
<p>The emails kept arriving. The contact form was never the way in.</p>
<h2><a id="content-recaptcha-on-the-contact-form-only-covers-the-frontend-area" href="#content-recaptcha-on-the-contact-form-only-covers-the-frontend-area" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>reCAPTCHA on the contact form only covers the frontend area</h2>
<p>When you enable reCAPTCHA for “Contact Us” in Magento, the protection comes from the <code>Magento_ReCaptchaContact</code> module. If you look at what that module actually ships, it wires up a single observer:</p>
<pre data-lang="xml" class="notranslate">&lt;<span class="hl-keyword">event</span> <span class="hl-property">name</span>=&quot;controller_action_predispatch_contact_index_post&quot;&gt;
    &lt;<span class="hl-keyword">observer</span> <span class="hl-property">name</span>=&quot;recaptcha_on_contact_form&quot;
              <span class="hl-property">instance</span>=&quot;Magento\ReCaptchaContact\Observer\ContactFormObserver&quot;/&gt;
&lt;/<span class="hl-keyword">event</span>&gt;
</pre>
<p>That observer runs on <code>controller_action_predispatch_contact_index_post</code>, the controller action behind the standard <code>POST /contacts/index/post/</code> form submission. No token, and the request is redirected back to the form without sending anything. This part works. I confirmed it by posting to the form endpoint with a valid form key and no reCAPTCHA response, and the submission was correctly rejected.</p>
<p>The problem is that the contact form controller isn’t the only way to send that email.</p>
<h2><a id="content-the-contactus-graphql-mutation-reaches-the-same-mailer" href="#content-the-contactus-graphql-mutation-reaches-the-same-mailer" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The contactUs GraphQL mutation reaches the same mailer</h2>
<p>Magento ships a <code>contactUs</code> GraphQL mutation as part of <code>Magento_ContactGraphQl</code>, and it’s enabled by default. Its resolver does this:</p>
<pre data-lang="php" class="notranslate"><span class="hl-variable">$this</span>-&gt;<span class="hl-property">mail</span>-&gt;<span class="hl-property">send</span>(<span class="hl-variable">$input</span>[<span class="hl-value">'email'</span>], [<span class="hl-value">'data'</span> =&gt; <span class="hl-variable">$input</span>]);
</pre>
<p>That <code>$this-&gt;mail</code> is <code>Magento\Contact\Model\MailInterface</code>, the same mailer the form controller uses, sending the exact same configured contact email template. But the resolver has no reCAPTCHA check. GraphQL is also exempt from form key and CSRF validation, so there’s nothing to supply and nothing to forge.</p>
<p>A single request is all it takes:</p>
<pre data-lang="bash" class="notranslate">curl <span class="hl-generic">-s</span> <span class="hl-generic">-X</span> POST <span class="hl-value">&quot;https://www.example.com/graphql&quot;</span> \
  <span class="hl-generic">-H</span> <span class="hl-value">&quot;Content-Type: application/json&quot;</span> \
  <span class="hl-generic">--data</span> <span class="hl-value">'{&quot;query&quot;:&quot;mutation { contactUs(input: { name: \&quot;Test\&quot;, email: \&quot;test@example.com\&quot;, telephone: \&quot;0000\&quot;, comment: \&quot;Test\&quot; }) { status } }&quot;}'</span>
</pre>
<p>A successful call returns <code>{&quot;data&quot;:{&quot;contactUs&quot;:{&quot;status&quot;:true}}}</code> and fires the enquiry email. Loop that a couple of hundred times and you have the flood. Because GraphQL requests with no <code>Store</code> header resolve to the default store view, every email arrived branded as the default site regardless of which store the attacker referenced.</p>
<p>This is also why it’s so easy to miss when you go looking in the logs. The form POSTs show up clearly in the access log as <code>POST /contacts/index/post/</code>, and they were all being rejected. The requests that actually sent mail were <code>POST /graphql</code>, and nginx doesn’t log GraphQL request bodies, so there’s nothing in the access log that says <code>contactUs</code> at all.</p>
<h2><a id="content-confirming-recaptcha-really-was-working" href="#content-confirming-recaptcha-really-was-working" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Confirming reCAPTCHA really was working</h2>
<p>Before backporting anything, I proved the form-level reCAPTCHA wasn’t just broken. The same host that ran the enquiry flood also hammered <code>customer/account/createpost</code>, which is protected by reCAPTCHA in exactly the same way. If reCAPTCHA had been failing open, those would have created accounts. Checking the database for the window in question, zero accounts were created. The gate was up and doing its job. The GraphQL mutation walked around it.</p>
<h2><a id="content-the-fix-landed-in-magento-249" href="#content-the-fix-landed-in-magento-249" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The fix landed in Magento 2.4.9</h2>
<p>This is a genuine gap in core, and Adobe fixed it in Magento 2.4.9 (commit <code>LYNX-941</code>, “Implement ReCaptcha for missing GraphQl mutations”, shipped in <code>magento/security-package</code> 1.1.8). The <code>ContactUs</code> resolver itself is unchanged; the fix is on the reCAPTCHA side.</p>
<p>Server-side reCAPTCHA for web API and GraphQL endpoints is driven by <code>CompositeWebapiValidationConfigProvider</code>. Each reCAPTCHA module registers its endpoints into that provider via <code>etc/di.xml</code>. If you look at 2.4.8, the customer, checkout, newsletter, review, send-a-friend and PayPal modules all register. <code>Magento_ReCaptchaContact</code> ships no <code>etc/di.xml</code> at all, so <code>contact</code> is absent from the list. The 2.4.9 fix adds the missing registration and a small provider class that returns the existing <code>contact</code> validation config for the <code>ContactUs</code> endpoint.</p>
<h2><a id="content-backporting-the-fix-to-248" href="#content-backporting-the-fix-to-248" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Backporting the fix to 2.4.8</h2>
<p>If you can’t jump to 2.4.9 immediately, I’ve packaged the backport as a small module and released it for free under the MIT licence: <a href="https://github.com/DeployEcommerce/module-contact-graphql-recaptcha">DeployEcommerce/module-contact-graphql-recaptcha</a>. Drop it in, enable it, and the <code>contactUs</code> mutation is gated by your existing contact reCAPTCHA config.</p>
<p>If you’d rather understand what it’s doing or build your own, the fix backports cleanly because all the infrastructure it depends on already exists in 2.4.8. You need two files.</p>
<p>A provider that returns the <code>contact</code> validation config when it sees the <code>ContactUs</code> endpoint:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">&lt;?php</span>
<span class="hl-keyword">declare</span>(strict_types=1);

<span class="hl-keyword">namespace</span> <span class="hl-type">Vendor\ContactGraphQlReCaptcha\Model</span>;

<span class="hl-keyword">use</span> <span class="hl-type">Magento\ContactGraphQl\Model\Resolver\ContactUs</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Magento\ReCaptchaUi\Model\IsCaptchaEnabledInterface</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Magento\ReCaptchaUi\Model\ValidationConfigResolverInterface</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Magento\ReCaptchaValidationApi\Api\Data\ValidationConfigInterface</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Magento\ReCaptchaWebapiApi\Api\Data\EndpointInterface</span>;
<span class="hl-keyword">use</span> <span class="hl-type">Magento\ReCaptchaWebapiApi\Api\WebapiValidationConfigProviderInterface</span>;

<span class="hl-keyword">class</span> <span class="hl-type">WebapiConfigProvider</span> <span class="hl-keyword">implements</span><span class="hl-type"> WebapiValidationConfigProviderInterface
</span>{
    <span class="hl-keyword">private</span> <span class="hl-keyword">const</span> <span class="hl-property">CAPTCHA_ID</span> = <span class="hl-value">'contact'</span>;

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">__construct</span>(<span class="hl-injection">
        <span class="hl-keyword">private</span> <span class="hl-keyword">readonly</span> <span class="hl-type">IsCaptchaEnabledInterface</span> <span class="hl-property">$isEnabled</span>,
        <span class="hl-keyword">private</span> <span class="hl-keyword">readonly</span> <span class="hl-type">ValidationConfigResolverInterface</span> <span class="hl-property">$configResolver</span>
    </span>) {
    }

    <span class="hl-keyword">public</span> <span class="hl-keyword">function</span> <span class="hl-property">getConfigFor</span>(<span class="hl-injection"><span class="hl-type">EndpointInterface</span> $endpoint</span>): <span class="hl-type">?ValidationConfigInterface</span>
    {
        <span class="hl-keyword">if</span> (<span class="hl-variable">$endpoint</span>-&gt;<span class="hl-property">getServiceMethod</span>() === <span class="hl-value">'resolve'</span>
            <span class="hl-operator">&amp;&amp;</span> <span class="hl-variable">$endpoint</span>-&gt;<span class="hl-property">getServiceClass</span>() === <span class="hl-type">ContactUs</span>::<span class="hl-keyword">class</span>
            <span class="hl-operator">&amp;&amp;</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">isEnabled</span>-&gt;<span class="hl-property">isCaptchaEnabledFor</span>(<span class="hl-type">self</span>::<span class="hl-property">CAPTCHA_ID</span>)
        ) {
            <span class="hl-keyword">return</span> <span class="hl-variable">$this</span>-&gt;<span class="hl-property">configResolver</span>-&gt;<span class="hl-property">get</span>(<span class="hl-type">self</span>::<span class="hl-property">CAPTCHA_ID</span>);
        }

        <span class="hl-keyword">return</span> <span class="hl-keyword">null</span>;
    }
}
</pre>
<p>And an <code>etc/di.xml</code> that registers it into the composite provider:</p>
<pre data-lang="xml" class="notranslate">&lt;<span class="hl-keyword">config</span> <span class="hl-property">xmlns:xsi</span>=&quot;http://www.w3.org/2001/XMLSchema-instance&quot;
        <span class="hl-property">xsi:noNamespaceSchemaLocation</span>=&quot;urn:magento:framework:ObjectManager/etc/config.xsd&quot;&gt;
    &lt;<span class="hl-keyword">type</span> <span class="hl-property">name</span>=&quot;Magento\ReCaptchaWebapiApi\Model\CompositeWebapiValidationConfigProvider&quot;&gt;
        &lt;<span class="hl-keyword">arguments</span>&gt;
            &lt;<span class="hl-keyword">argument</span> <span class="hl-property">name</span>=&quot;providers&quot; <span class="hl-property">xsi:type</span>=&quot;array&quot;&gt;
                &lt;<span class="hl-keyword">item</span> <span class="hl-property">name</span>=&quot;vendor_recaptcha_on_contact_form&quot; <span class="hl-property">xsi:type</span>=&quot;object&quot;&gt;Vendor\ContactGraphQlReCaptcha\Model\WebapiConfigProvider&lt;/<span class="hl-keyword">item</span>&gt;
            &lt;/<span class="hl-keyword">argument</span>&gt;
        &lt;/<span class="hl-keyword">arguments</span>&gt;
    &lt;/<span class="hl-keyword">type</span>&gt;
&lt;/<span class="hl-keyword">config</span>&gt;
</pre>
<p>Add the usual <code>registration.php</code> and <code>etc/module.xml</code> (sequence it after <code>Magento_ReCaptchaContact</code>, <code>Magento_ReCaptchaWebapiApi</code> and <code>Magento_ContactGraphQl</code>), and enable it. I deliberately kept this under my own vendor namespace rather than overriding <code>Magento\ReCaptchaContact</code>, so it does not collide with core when the platform is eventually upgraded to 2.4.9, at which point the module can be removed.</p>
<p>Because it reuses the <code>contact</code> validation config, it’s gated by the same reCAPTCHA settings you’ve already configured. Legitimate token-bearing submissions are unaffected; only the tokenless GraphQL calls get blocked.</p>
<h2><a id="content-watch-out-for-the-di-cache" href="#content-watch-out-for-the-di-cache" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Watch out for the DI cache</h2>
<p>One thing that caught me out while testing: the <code>providers</code> argument on the composite provider is merged from <code>di.xml</code> and cached. Adding the module is not enough on its own: you have to compile DI and flush the cache, otherwise the merged config still reflects the old, ungated state and the mutation stays wide open with no obvious sign anything is wrong:</p>
<pre data-lang="bash" class="notranslate">bin/magento setup:upgrade
bin/magento setup:di:compile
bin/magento cache:flush
</pre>
<p>After that, re-run the <code>contactUs</code> curl from earlier. Instead of <code>{&quot;status&quot;:true}</code> you’ll get a reCAPTCHA validation error, and no email is sent.</p>
<h2><a id="content-checking-your-own-stores" href="#content-checking-your-own-stores" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Checking your own stores</h2>
<p>If you’re on 2.4.8 or earlier with <code>Magento_ContactGraphQl</code> enabled and you don’t have a headless or PWA storefront that actually needs it, the simplest fix is to disable the module:</p>
<pre data-lang="bash" class="notranslate">bin/magento module:disable Magento_ContactGraphQl
</pre>
<p>That closes the hole entirely with no custom code. If you are using GraphQL, install <a href="https://github.com/DeployEcommerce/module-contact-graphql-recaptcha">the extension</a>. Either way, rate-limit <code>/graphql</code> at the edge while you are in there. The contact form is the obvious thing to protect, but it’s rarely the only door into the same piece of functionality.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Magento 2 missing bin/magento, lib, dev and setup directories</title>
        <link>https://dor.ky/post/magento2-missing-bin-magento-lib-dev-setup-directories</link>
        <guid isPermaLink="true">https://dor.ky/post/magento2-missing-bin-magento-lib-dev-setup-directories</guid>
        <pubDate>Fri, 19 Jun 2026 00:00:00 +0000</pubDate>
        
        <description>When core directories deployed from magento/magento2-base go missing, a composer reinstall usually re-triggers them</description>
        <content:encoded><![CDATA[<p>Pull a Magento 2 project, find <code>bin/magento</code>, <code>lib/</code>, <code>dev/</code> or <code>setup/</code> missing, and nothing is wrong with the repo: those directories are deployed out of the <code>magento/magento2-base</code> package by Composer rather than committed. When that deploy step does not run, or runs partially, the files never land and the project looks half-installed.</p>
<h2><a id="content-the-fix" href="#content-the-fix" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The fix</h2>
<p>In most cases a reinstall of the base package re-triggers the deploy and the missing directories reappear:</p>
<pre data-lang="bash" class="notranslate">composer reinstall magento/magento2-base
</pre>
<p>That forces Composer to remove and re-add the package, which runs the <code>magento-composer-installer</code> deploy step again and copies the mapped files back into place. Worth running <code>bin/magento</code> afterwards to confirm the CLI is back.</p>
<h2><a id="content-why-it-happens" href="#content-why-it-happens" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why it happens</h2>
<p>The deploy is handled by <code>magento/magento-composer-installer</code>, and there is a bug in its copy strategy. The <code>Copy::createDelegate()</code> method uses <code>file_exists()</code>, which dereferences symlinks, so when a symlink with a missing target sits at one of the mapped destination paths, the installer reads it as absent, tries to <code>mkdir()</code> over the existing link node and hits a silent <code>File exists</code> error.</p>
<p>The error swallowing is worse. <code>DeployManager::doDeploy()</code> catches the <code>ErrorException</code> but only logs it under <code>-vvv</code> verbosity, so the remaining mappings get skipped with no warning. The install reports success while <code>setup/</code> and friends are partially or completely empty.</p>
<p>I’ve raised a <a href="https://github.com/magento/magento2/issues/40864">GitHub issue</a> with the full reproduction. There’s also a longstanding <a href="https://github.com/magento/magento-composer-installer/pull/27">PR against magento-composer-installer</a> that removes the exception suppression so <code>composer install</code> fails loud and early instead of leaving you with a broken but apparently fine install. It has been open for years awaiting review.</p>
<p>If the reinstall doesn’t stick, check for a dangling symlink at the target paths before assuming Composer is at fault.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Bypassing a Chrome Security Warning From the DevTools Console</title>
        <link>https://dor.ky/post/bypass-chrome-security-warning-devtools-console</link>
        <guid isPermaLink="true">https://dor.ky/post/bypass-chrome-security-warning-devtools-console</guid>
        <pubDate>Wed, 17 Jun 2026 00:00:00 +0000</pubDate>
        
        <description>When the &quot;Your connection is not private&quot; interstitial has no proceed link, you can fire the same command Chrome uses internally from the console</description>
        <content:encoded><![CDATA[<p>Local development over HTTPS eventually lands you on Chrome’s <code>NET::ERR_CERT_AUTHORITY_INVALID</code> interstitial, the full-page “Your connection is not private” warning. Usually there is an “Advanced” link that lets you proceed anyway. On certain certificate errors (HSTS, some <code>ERR_CERT_*</code> variants) that link is missing entirely and there is no obvious way through.</p>
<p>The well-known trick is to click anywhere on the page and type <code>thisisunsafe</code>. That still works, and you can also call the command the phrase triggers directly from the DevTools console.</p>
<h2><a id="content-the-command" href="#content-the-command" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The command</h2>
<p>Open DevTools on the interstitial page and run:</p>
<pre data-lang="js" class="notranslate"><span class="hl-property">sendCommand</span>(<span class="hl-type">SecurityInterstitialCommandId</span>.<span class="hl-property">CMD_PROCEED</span>)
</pre>
<p>Chrome reloads and takes you through to the site. The interstitial is an internal Chrome page that exposes a <code>sendCommand</code> function and a <code>SecurityInterstitialCommandId</code> enum, and <code>CMD_PROCEED</code> is the same code path the <code>thisisunsafe</code> shortcut fires.</p>
<p><code>CMD_PROCEED</code> resolves to <code>1</code>, so if the enum isn’t in scope you can fall back to:</p>
<pre data-lang="js" class="notranslate"><span class="hl-property">sendCommand</span>(1)
</pre>
<h2><a id="content-when-to-reach-for-it" href="#content-when-to-reach-for-it" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>When to reach for it</h2>
<p>This is for your own dev servers and self-signed certificates: the kind of cert warning you have already decided is safe to ignore. Handy when the <code>thisisunsafe</code> typing trick feels flaky, or when you want something scriptable against a known internal page.</p>
<p>Obvious caveat: never do this to get past a certificate warning on a site you do not control.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Fixing Klevu Stock Status with Magento MSI</title>
        <link>https://dor.ky/post/klevu-msi-stock-status-syncing</link>
        <guid isPermaLink="true">https://dor.ky/post/klevu-msi-stock-status-syncing</guid>
        <pubDate>Mon, 15 Jun 2026 00:00:00 +0000</pubDate>
        
        <description>Klevu&#039;s default stock status calculation reads the legacy inventory table, which MSI only syncs from the default source: one config setting switches it</description>
        <content:encoded><![CDATA[<p>Klevu and Magento’s Multi-Source Inventory (MSI) disagree about stock status more often than they should. The fix is one config setting.</p>
<h2><a id="content-the-problem" href="#content-the-problem" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The problem</h2>
<p>Products that were genuinely in stock were being indexed as out of stock (or the reverse) in Klevu. The Magento storefront showed the correct status, but Klevu disagreed. This only happened on a store using MSI with stock assigned to a non-default source.</p>
<h2><a id="content-why-it-happens" href="#content-why-it-happens" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why it happens</h2>
<p>Klevu’s product indexing module calculates stock status through <code>ProductStockStatusProvider</code>, and the method it uses is controlled by this config path:</p>
<pre data-lang="txt" class="notranslate">klevu/indexing/product_stock_status_calculation_method
</pre>
<p>There are four options:</p>
<ul>
<li><code>stock_item</code></li>
<li><code>stock_registry</code></li>
<li><code>is_available</code></li>
<li><code>is_salable</code></li>
</ul>
<p>The default is <code>stock_item</code>. With that setting, Klevu calls <code>getFromStockItem()</code>, which reads the <code>is_in_stock</code> flag off the product’s <code>StockItem</code> extension attribute:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">getFromStockItem</span>(<span class="hl-injection">
    <span class="hl-type">ProductInterface</span> $product,
    <span class="hl-type">?StoreInterface</span> $store,
</span>): <span class="hl-type">bool</span> {
    <span class="hl-variable">$extensionAttributes</span> = <span class="hl-variable">$product</span>-&gt;<span class="hl-property">getExtensionAttributes</span>();
    <span class="hl-variable">$stockItem</span> = <span class="hl-variable">$extensionAttributes</span>?-&gt;<span class="hl-property">getStockItem</span>();

    <span class="hl-comment">// Replicating original behaviour pre: 4.4.0</span>
    <span class="hl-keyword">return</span> <span class="hl-variable">$stockItem</span>
        <span class="hl-operator">?</span> (<span class="hl-type">bool</span>)<span class="hl-variable">$stockItem</span>-&gt;<span class="hl-property">getIsInStock</span>()
        : <span class="hl-variable">$this</span>-&gt;<span class="hl-property">getFromStockRegistry</span>(<span class="hl-variable">$product</span>, <span class="hl-variable">$store</span>);
}
</pre>
<p>That extension attribute is backed by the legacy <code>cataloginventory_stock_item</code> table. Under MSI, that legacy table is only kept in sync from the <strong>default source</strong>.</p>
<p>You can see this in Magento core, in <code>SetDataToLegacyCatalogInventoryAtSourceItemsSavePlugin::afterExecute()</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">foreach</span> (<span class="hl-variable">$sourceItems</span> <span class="hl-keyword">as</span> <span class="hl-variable">$sourceItem</span>) {
    <span class="hl-keyword">if</span> (<span class="hl-variable">$sourceItem</span>-&gt;<span class="hl-property">getSourceCode</span>() !== <span class="hl-variable">$this</span>-&gt;<span class="hl-property">defaultSourceProvider</span>-&gt;<span class="hl-property">getCode</span>()) {
        <span class="hl-keyword">continue</span>;
    }
    <span class="hl-comment">// ...</span>
    <span class="hl-variable">$sourceItemsForSynchronization</span>[] = <span class="hl-variable">$sourceItem</span>;
}

<span class="hl-variable">$this</span>-&gt;<span class="hl-property">setDataToLegacyCatalogInventory</span>-&gt;<span class="hl-property">execute</span>(<span class="hl-variable">$sourceItemsForSynchronization</span>);
</pre>
<p>Any source item that isn’t the default source is skipped. So if your sellable stock lives on a custom source, the legacy table never reflects it, and <code>getFromStockItem()</code> reads stale data. Klevu then indexes the wrong status.</p>
<h2><a id="content-the-fix" href="#content-the-fix" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The fix</h2>
<p>Switch the calculation method to <code>stock_registry</code>:</p>
<pre data-lang="txt" class="notranslate">klevu/indexing/product_stock_status_calculation_method = stock_registry
</pre>
<p>With that setting Klevu calls <code>getFromStockRegistry()</code> instead, which goes through <code>StockRegistryInterface::getStockStatus()</code>:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">function</span> <span class="hl-property">getFromStockRegistry</span>(<span class="hl-injection">
    <span class="hl-type">ProductInterface</span> $product,
    <span class="hl-type">?StoreInterface</span> $store,
</span>): <span class="hl-type">bool</span> {
    <span class="hl-comment">/**
     * MSI has a plugin on this method to get the correct stock.
     */</span>
    <span class="hl-variable">$stockStatus</span> = <span class="hl-variable">$this</span>-&gt;<span class="hl-property">stockRegistry</span>-&gt;<span class="hl-property">getStockStatus</span>(
        <span class="hl-property">productId</span>: <span class="hl-variable">$product</span>-&gt;<span class="hl-property">getId</span>(),
        <span class="hl-property">scopeId</span>: <span class="hl-variable">$store</span>
            <span class="hl-operator">?</span> (<span class="hl-type">int</span>)<span class="hl-variable">$store</span>-&gt;<span class="hl-property">getWebsiteId</span>()
            : <span class="hl-keyword">null</span>,
    );

    <span class="hl-keyword">return</span> (<span class="hl-type">bool</span>)<span class="hl-variable">$stockStatus</span>-&gt;<span class="hl-property">getStockStatus</span>();
}
</pre>
<p>As the comment notes, MSI plugs into this method and returns the aggregated stock across every source assigned to the website’s stock, not just the default one. That is what you want on multi-source.</p>
<p>The setting lives under <strong>Stores → Configuration → Klevu → Developer Settings → Stock Status Calculation Method</strong>, and it’s a global (default scope) setting. You can also set it directly:</p>
<pre data-lang="bash" class="notranslate">bin/magento config:<span class="hl-type">set</span> klevu/indexing/product_stock_status_calculation_method stock_registry
</pre>
<p>The field comment in the admin mentions something easy to skip: changing this setting requires a discovery or update of the product catalog before it takes effect on existing indexes. So after switching, requeue your products:</p>
<pre data-lang="bash" class="notranslate">bin/magento klevu:indexing:entity-update <span class="hl-generic">--entity-types</span> KLEVU_PRODUCT <span class="hl-generic">--entity-ids</span> all
</pre>
<h2><a id="content-which-option-to-pick" href="#content-which-option-to-pick" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Which option to pick</h2>
<p>On MSI, <code>stock_registry</code> is the right choice because it is MSI-aware. The <code>is_available</code> and <code>is_salable</code> options load the product per store and call <code>isAvailable()</code> / <code>isSalable()</code> respectively, which are heavier (they hit the product repository) but reflect salable quantity logic. The default <code>stock_item</code> is fine on single-source setups but, as above, will silently break once stock moves off the default source.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>How to Detect Service Versions on Adobe Commerce Cloud</title>
        <link>https://dor.ky/post/detect-service-versions-adobe-commerce-cloud</link>
        <guid isPermaLink="true">https://dor.ky/post/detect-service-versions-adobe-commerce-cloud</guid>
        <pubDate>Fri, 10 Apr 2026 00:00:00 +0000</pubDate>
        
        <description>Quick commands to check which versions of Valkey, MariaDB and RabbitMQ are running on your Adobe Commerce Cloud environment.</description>
        <content:encoded><![CDATA[<p>Adobe Commerce Cloud manages your infrastructure services for you, but knowing exactly which versions of Valkey, MariaDB and RabbitMQ are running on your environments isn’t always obvious. The <code>.magento/services.yaml</code> file defines what you’ve requested, but that doesn’t always reflect what’s actually deployed - especially after platform upgrades or environment redeployments.</p>
<p>Here’s how to check the actual running versions by SSH’ing into your environment.</p>
<h2><a id="content-valkey" href="#content-valkey" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Valkey</h2>
<pre data-lang="bash" class="notranslate">$ valkey-server <span class="hl-generic">-v</span>
Valkey server v=<span class="hl-number">8</span>.<span class="hl-number">0</span>.<span class="hl-number">1</span> sha=4fbab574:<span class="hl-number">0</span> malloc=jemalloc-<span class="hl-number">5</span>.<span class="hl-number">3</span>.<span class="hl-number">0</span> bits=<span class="hl-number">64</span> build=84a9272b225dd059
</pre>
<p>If your environment is still running Redis rather than Valkey, you can use <code>redis-server -v</code> instead.</p>
<h2><a id="content-mariadb" href="#content-mariadb" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>MariaDB</h2>
<pre data-lang="bash" class="notranslate">$ mariadb <span class="hl-generic">-V</span>
mariadb  Ver <span class="hl-number">15</span>.<span class="hl-number">1</span> Distrib <span class="hl-number">10</span>.<span class="hl-number">11</span>.<span class="hl-number">9</span>-MariaDB, <span class="hl-keyword">for</span> debian-linux-gnu (x86_64) using readline <span class="hl-number">5</span>.<span class="hl-number">2</span>
</pre>
<p>Note the uppercase <code>-V</code> flag here - lowercase <code>-v</code> won’t give you what you need.</p>
<h2><a id="content-rabbitmq" href="#content-rabbitmq" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>RabbitMQ</h2>
<pre data-lang="bash" class="notranslate">$ dpkg <span class="hl-generic">-s</span> rabbitmq-server <span class="hl-operator">|</span> grep Version
Version: <span class="hl-number">4</span>.<span class="hl-number">1</span>.<span class="hl-number">0</span>-<span class="hl-number">1</span>
</pre>
<p>RabbitMQ doesn’t have a straightforward version flag like the others, so querying the installed package via <code>dpkg</code> is the simplest approach.</p>
<h2><a id="content-why-this-matters" href="#content-why-this-matters" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why this matters</h2>
<p>Knowing your actual service versions is useful when debugging compatibility issues, preparing for upgrades or verifying that a platform change has actually taken effect. It’s also handy when raising support tickets with Adobe - having the exact version numbers saves a fair amount of back and forth.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Blocking SessionReaper and PolyShell File Access with Fastly on Magento/Adobe Commerce</title>
        <link>https://dor.ky/post/blocking-polyshell-sessionreaper-access-fastly-magento</link>
        <guid isPermaLink="true">https://dor.ky/post/blocking-polyshell-sessionreaper-access-fastly-magento</guid>
        <pubDate>Mon, 30 Mar 2026 00:00:00 +0000</pubDate>
        
        <description>How to use a Fastly VCL snippet to block access to malicious SessionReaper and PolyShell files uploaded to the media directory in Magento/Adobe Commerce.</description>
        <content:encoded><![CDATA[<p>If you’ve been keeping an eye on the Magento security space recently, you’ll have seen the research from Sansec around <a href="https://sansec.io/research/sessionreaper-exploitation">SessionReaper exploitation</a> and the more recent <a href="https://sansec.io/research/magento-polyshell">PolyShell</a> - both of which use the <code>media/custom_options/</code> directory as a vector for uploading and executing malicious PHP files.</p>
<p>The SessionReaper attack targets session data to extract customer and admin credentials, and the PolyShell technique builds on this by disguising executable PHP within files that appear to be valid image uploads.</p>
<h2><a id="content-blocking-access-at-the-edge-with-fastly" href="#content-blocking-access-at-the-edge-with-fastly" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Blocking access at the edge with Fastly</h2>
<p>If you’re running Adobe Commerce Cloud or any Magento instance fronted by Fastly, you can block access to these paths before requests ever reach your origin server. This is a fairly simple <a href="https://experienceleague.adobe.com/en/docs/commerce-on-cloud/user-guide/cdn/custom-vcl-snippets/fastly-vcl-custom-snippets">custom VCL snippet</a> that returns a <code>403</code> for any request targeting <code>media/custom_options/</code>:</p>
<pre data-lang="txt" class="notranslate">if (req.url ~ &quot;^/media/custom_options/&quot;) {
  error 403 &quot;Forbidden&quot;;
}
</pre>
<p>Add this as a custom VCL snippet in your Fastly configuration with the type set to <code>recv</code> and priority <code>1</code>. This will catch any direct access attempts to files within that directory - whether they’re SessionReaper scripts, PolyShell payloads or anything else that has no business being served publicly.</p>
<h2><a id="content-edge-level-blocking-over-application-level-blocking" href="#content-edge-level-blocking-over-application-level-blocking" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Edge-level blocking over application-level blocking</h2>
<p>Blocking at Fastly rather than at the application level means the request never reaches nginx, Varnish or PHP. This is important for two reasons - it reduces the attack surface significantly and it means even if an attacker has managed to upload a malicious file, they can’t execute it by requesting it directly through the public URL.</p>
<p>You should still be auditing your <code>media/custom_options/</code> directory for any suspicious files and ensuring your Magento instance is patched, but adding this edge-level block is a solid defensive layer that takes seconds to deploy.</p>
<h2><a id="content-important-note" href="#content-important-note" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Important Note</h2>
<p>If your store actively uses custom options with file upload fields on products, you’ll need to be more careful with this approach. Blanket blocking the entire <code>media/custom_options/</code> path could interfere with legitimate file uploads that customers need to access. In that case you may need to refine the VCL to target specific file extensions or patterns rather than the whole directory.</p>
<h2><a id="content-further-reading" href="#content-further-reading" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Further reading</h2>
<p>Sansec’s research on both of these attack vectors is well worth a read if you haven’t already:</p>
<ul>
<li><a href="https://sansec.io/research/sessionreaper-exploitation">SessionReaper Exploitation</a> - the session hijacking attack targeting customer and admin credentials</li>
<li><a href="https://sansec.io/research/magento-polyshell">Magento PolyShell</a> - the PolyShell research that builds on the SessionReaper technique</li>
</ul>
<p>With a bit of luck this saves you from a headache down the line. If you’re not already monitoring your media directories for unexpected PHP files, now is a good time to start.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Fixing Magento&#039;s Telephone Validation to Allow Dots and Slashes</title>
        <link>https://dor.ky/post/magento-telephone-validation-dots-slashes</link>
        <guid isPermaLink="true">https://dor.ky/post/magento-telephone-validation-dots-slashes</guid>
        <pubDate>Mon, 23 Mar 2026 00:00:00 +0000</pubDate>
        
        <description>Magento&#039;s customer telephone validator rejects valid international phone formats that use dots or slashes. Here&#039;s an m2-hotfixes patch to fix it.</description>
        <content:encoded><![CDATA[<p>Magento’s built-in telephone validation for customer accounts is fairly strict - it only allows digits, spaces, parentheses, plus signs and hyphens. That works for a lot of western formats but breaks for plenty of valid international phone numbers.</p>
<p>French phone numbers are commonly written with dots as separators (e.g. <code>06.76.40.32.22</code>), and Austrian numbers often use a forward slash (e.g. <code>+43680/2149568</code>). Both of these formats are rejected by Magento’s <code>Telephone</code> validator out of the box.</p>
<p>The <code>Telephone</code> model validator in <code>vendor/magento/module-customer/Model/Validator/Telephone.php</code> uses a regex pattern that doesn’t account for dots or slashes:</p>
<pre data-lang="php" class="notranslate"><span class="hl-keyword">private</span> <span class="hl-keyword">const</span> <span class="hl-property">PATTERN_TELEPHONE</span> = <span class="hl-value">'/(?:[\d\s\+\-\()]{1,20})/u'</span>;
</pre>
<p>This means any customer trying to save their phone number in a perfectly valid international format gets hit with “Invalid Phone Number” - not a great experience.</p>
<p>The issue is tracked internally as ACP2E-4554 and was discussed in <a href="https://github.com/magento/magento2/issues/40544">magento/magento2#40544</a> and <a href="https://github.com/magento/magento2/pull/40545">magento/magento2#40545</a>. The <a href="https://github.com/magento/magento2/commit/ca060df75cbb1350bbd1303cb8d0f0377d1d94f6">fix was committed</a> but if you’re on a version that doesn’t include it yet, here’s a patch.</p>
<p>Here’s an m2-hotfixes patch that adds support for dots and forward slashes in telephone validation:</p>
<pre data-lang="diff" class="notranslate"><span class="hl-type">--- a/vendor/magento/module-customer/Model/Validator/Telephone.php</span>
<span class="hl-type">+++ b/vendor/magento/module-customer/Model/Validator/Telephone.php</span>
<span class="hl-generic">@@ -21,9 +21,11 @@</span>
<span class="hl-diff-context">      * \() :Matches open and close parentheses</span>
<span class="hl-diff-context">      * \+: Matches the plus sign.</span>
<span class="hl-diff-context">      * \-: Matches the hyphen.</span>
<span class="hl-addition">+     * \.: Matches the dot character (e.g. 06.76.40.32.22)</span>
<span class="hl-addition">+     * \/: Matches the forward slash (e.g. +43680/2149568)</span>
<span class="hl-diff-context">      * \d: Digits (0-9).</span>
<span class="hl-diff-context">      */</span>
<span class="hl-deletion">-    private const PATTERN_TELEPHONE = '/(?:[\d\s\+\-\()]{1,20})/u';</span>
<span class="hl-addition">+    private const PATTERN_TELEPHONE = '/(?:[\d\s+().\/ -]{1,20})/u';</span>

<span class="hl-diff-context">     /**</span>
<span class="hl-diff-context">      * Validate telephone fields.</span>
<span class="hl-generic">@@ -35,7 +37,7 @@</span>
<span class="hl-diff-context">     {</span>
<span class="hl-diff-context">         if (!$this-&gt;isValidTelephone($customer-&gt;getTelephone())) {</span>
<span class="hl-diff-context">             parent::_addMessages([[</span>
<span class="hl-deletion">-                'telephone' =&gt; &quot;Invalid Phone Number. Please use 0-9, +, -, (, ) and space.&quot;</span>
<span class="hl-addition">+                'telephone' =&gt; &quot;Invalid Phone Number. Please use 0-9, +, -, (, ), ., / and space.&quot;</span>
<span class="hl-diff-context">             ]]);</span>
<span class="hl-diff-context">         }</span>
</pre>
<p>Save this as <code>m2-hotfixes/ac-13791__telephone-validation-dots-slashes.patch</code> (or whatever naming convention you use for your hotfixes directory).</p>
<p>The updated regex pattern <code>'/(?:[\d\s+().\/ -]{1,20})/u'</code> adds <code>.</code> and <code>/</code> to the allowed character set.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Creating URL Redirects When Updating Product URL Keys via the Magento API</title>
        <link>https://dor.ky/post/magento-api-url-key-create-redirect</link>
        <guid isPermaLink="true">https://dor.ky/post/magento-api-url-key-create-redirect</guid>
        <pubDate>Thu, 12 Mar 2026 00:00:00 +0000</pubDate>
        
        <description>How to use the url_key_create_redirect custom attribute in the Magento REST API to automatically create 301 redirects when changing product URL keys programmatically.</description>
        <content:encoded><![CDATA[<p>If you’ve ever updated a product’s URL key via the Magento 2 REST API and then wondered why the old URL is now returning a 404, you’ve probably missed the <code>url_key_create_redirect</code> custom attribute.</p>
<p>In the admin UI there’s a handy “Create Permanent Redirect for old URL” checkbox that appears when you change a product’s URL key. Behind the scenes, this sets <code>url_key_create_redirect</code> on the product - and you can pass exactly the same attribute in your API requests to get the same behaviour.</p>
<h2><a id="content-the-api-request" href="#content-the-api-request" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>The API request</h2>
<p>When updating a product’s URL key, include <code>url_key_create_redirect</code> alongside <code>url_key</code> in your <code>custom_attributes</code> array:</p>
<pre data-lang="bash" class="notranslate">PUT /rest/V1/products/{sku}
</pre>
<pre data-lang="json" class="notranslate"><span class="hl-property">{</span>
  <span class="hl-keyword">&quot;product&quot;</span>: <span class="hl-property">{</span>
    <span class="hl-keyword">&quot;custom_attributes&quot;</span>: <span class="hl-property">[</span>
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;attribute_code&quot;</span>: <span class="hl-value">&quot;url_key&quot;</span>,
        <span class="hl-keyword">&quot;value&quot;</span>: <span class="hl-value">&quot;new-product-url-key&quot;</span>
      <span class="hl-property">}</span>,
      <span class="hl-property">{</span>
        <span class="hl-keyword">&quot;attribute_code&quot;</span>: <span class="hl-value">&quot;url_key_create_redirect&quot;</span>,
        <span class="hl-keyword">&quot;value&quot;</span>: <span class="hl-value">&quot;1&quot;</span>
      <span class="hl-property">}</span>
    <span class="hl-property">]</span>
  <span class="hl-property">}</span>
<span class="hl-property">}</span>
</pre>
<p>Setting the value to <code>1</code> tells Magento to create a 301 redirect from the old URL to the new one. The redirect is written to the <code>url_rewrite</code> table with <code>redirect_type</code> set to <code>301</code>.</p>
<p>If you want to suppress redirect creation entirely - for example during a bulk initial import where no old URLs exist yet - set the value to <code>0</code> or omit the attribute.</p>
<h2><a id="content-why-this-matters-for-bulk-operations" href="#content-why-this-matters-for-bulk-operations" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Why this matters for bulk operations</h2>
<p>If you’re running a bulk URL key update across hundreds or thousands of products, you’ll want to be deliberate about this. Passing <code>url_key_create_redirect: 1</code> on every product creates a redirect entry for each one, which can pile up in <code>url_rewrite</code> over time - especially if products have had multiple URL key changes.</p>
<p>It’s worth cleaning out stale redirects periodically, or being selective about which updates warrant a redirect versus which are clean-slate changes (new products, dev/staging refreshes, etc.).</p>
<h2><a id="content-verifying-the-redirect-was-created" href="#content-verifying-the-redirect-was-created" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Verifying the redirect was created</h2>
<p>You can check the <code>url_rewrite</code> table directly after the API call:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> *
<span class="hl-keyword">FROM</span> <span class="hl-type">url_rewrite</span>
<span class="hl-keyword">WHERE</span> entity_type = '<span class="hl-value">product</span>'
  <span class="hl-keyword">AND</span> redirect_type = 301
  <span class="hl-keyword">AND</span> target_path <span class="hl-keyword">LIKE</span> '<span class="hl-value">%new-product-url-key%</span>'
<span class="hl-keyword">ORDER BY</span> url_rewrite_id <span class="hl-keyword">DESC</span>
<span class="hl-keyword">LIMIT</span> 10;
</pre>
<p>Or use the REST API to inspect the product’s URL rewrites:</p>
<pre data-lang="bash" class="notranslate">GET /rest/V1/url-rewrites/products/{product_id}
</pre>
<p>With a bit of luck this saves you the debugging time of wondering why your URL key updates are silently dropping old links.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Fixing Broken Product Relationships in Adobe Commerce/Magento 2</title>
        <link>https://dor.ky/post/fix-broken-product-relationships-magento-adobe-commerce</link>
        <guid isPermaLink="true">https://dor.ky/post/fix-broken-product-relationships-magento-adobe-commerce</guid>
        <pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate>
        
        <description>SQL queries to identify and clean up invalid parent-child product relationships where simple products incorrectly have children.</description>
        <content:encoded><![CDATA[<p>I recently ran into a fairly puzzling issue on an Adobe Commerce site where some products were behaving oddly on the frontend. After some digging, it turned out the <code>catalog_product_relation</code> and <code>catalog_product_super_link</code> tables had invalid entries - simple products were listed as parents with child relationships, which should never happen.</p>
<p>In Magento/Adobe Commerce, only configurable, bundle and grouped products should have child relationships. When simple products end up with children in these tables, it’s usually the result of a botched import, a failed product type conversion or some other data corruption.</p>
<h2><a id="content-finding-the-problem" href="#content-finding-the-problem" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Finding the problem</h2>
<p>First, identify any invalid entries in <code>catalog_product_relation</code> where the parent is a simple product:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span>
    <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span>,
    <span class="hl-type">cpr</span>.<span class="hl-property">child_id</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">entity_id</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_entity_id</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">sku</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_sku</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_type</span>,
    <span class="hl-type">child</span>.<span class="hl-property">entity_id</span> <span class="hl-keyword">as</span> <span class="hl-type">child_entity_id</span>,
    <span class="hl-type">child</span>.<span class="hl-property">sku</span> <span class="hl-keyword">as</span> <span class="hl-type">child_sku</span>,
    <span class="hl-type">child</span>.<span class="hl-property">type_id</span> <span class="hl-keyword">as</span> <span class="hl-type">child_type</span>
<span class="hl-keyword">FROM</span>
    catalog_product_relation cpr
<span class="hl-keyword">INNER JOIN</span>
    catalog_product_entity parent <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">INNER JOIN</span>
    catalog_product_entity child <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">child_id</span> = <span class="hl-type">child</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span>
    <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> = '<span class="hl-value">simple</span>'
<span class="hl-keyword">ORDER BY</span>
    <span class="hl-type">parent</span>.<span class="hl-property">entity_id</span>, <span class="hl-type">child</span>.<span class="hl-property">entity_id</span>;
</pre>
<p>Similarly, check <code>catalog_product_super_link</code> for non-configurable parents (this table should only contain configurable product relationships):</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span>
    <span class="hl-type">cpsl</span>.<span class="hl-property">link_id</span>,
    <span class="hl-type">cpsl</span>.<span class="hl-property">product_id</span>,
    <span class="hl-type">cpsl</span>.<span class="hl-property">parent_id</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">entity_id</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_entity_id</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">sku</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_sku</span>,
    <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> <span class="hl-keyword">as</span> <span class="hl-type">parent_type</span>,
    <span class="hl-type">child</span>.<span class="hl-property">entity_id</span> <span class="hl-keyword">as</span> <span class="hl-type">child_entity_id</span>,
    <span class="hl-type">child</span>.<span class="hl-property">sku</span> <span class="hl-keyword">as</span> <span class="hl-type">child_sku</span>,
    <span class="hl-type">child</span>.<span class="hl-property">type_id</span> <span class="hl-keyword">as</span> <span class="hl-type">child_type</span>
<span class="hl-keyword">FROM</span>
    catalog_product_super_link cpsl
<span class="hl-keyword">INNER JOIN</span>
    catalog_product_entity parent <span class="hl-keyword">ON</span> <span class="hl-type">cpsl</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">INNER JOIN</span>
    catalog_product_entity child <span class="hl-keyword">ON</span> <span class="hl-type">cpsl</span>.<span class="hl-property">product_id</span> = <span class="hl-type">child</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span>
    <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> != '<span class="hl-value">configurable</span>'
<span class="hl-keyword">ORDER BY</span>
    <span class="hl-type">parent</span>.<span class="hl-property">entity_id</span>, <span class="hl-type">child</span>.<span class="hl-property">entity_id</span>;
</pre>
<p>You can also get a quick count of how many invalid entries exist:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-keyword">SELECT</span> '<span class="hl-value">catalog_product_relation</span>' <span class="hl-keyword">as</span> <span class="hl-type">table_name</span>,
       <span class="hl-property">COUNT</span>(*) <span class="hl-keyword">as</span> <span class="hl-type">invalid_count</span>,
       '<span class="hl-value">Parents with type_id=simple</span>' <span class="hl-keyword">as</span> <span class="hl-type">issue</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_relation</span> cpr
<span class="hl-keyword">INNER JOIN</span> <span class="hl-type">catalog_product_entity</span> parent <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> = '<span class="hl-value">simple</span>'

<span class="hl-keyword">UNION ALL</span>

<span class="hl-keyword">SELECT</span> '<span class="hl-value">catalog_product_super_link</span>' <span class="hl-keyword">as</span> <span class="hl-type">table_name</span>,
       <span class="hl-property">COUNT</span>(*) <span class="hl-keyword">as</span> <span class="hl-type">invalid_count</span>,
       '<span class="hl-value">Parents with type_id!=configurable</span>' <span class="hl-keyword">as</span> <span class="hl-type">issue</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_super_link</span> cpsl
<span class="hl-keyword">INNER JOIN</span> <span class="hl-type">catalog_product_entity</span> parent <span class="hl-keyword">ON</span> <span class="hl-type">cpsl</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> != '<span class="hl-value">configurable</span>';
</pre>
<h2><a id="content-checking-for-orphans" href="#content-checking-for-orphans" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Checking for orphans</h2>
<p>While you’re at it, check for orphaned references where the parent or child product no longer exists:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-comment">-- Missing parents in catalog_product_relation</span>
<span class="hl-keyword">SELECT</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span>, <span class="hl-type">cpr</span>.<span class="hl-property">child_id</span>, '<span class="hl-value">Missing parent</span>' <span class="hl-keyword">as</span> <span class="hl-type">issue</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_relation</span> cpr
<span class="hl-keyword">LEFT JOIN</span> <span class="hl-type">catalog_product_entity</span> parent <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">parent</span>.<span class="hl-property">row_id</span> <span class="hl-keyword">IS NULL</span>;

<span class="hl-comment">-- Missing children in catalog_product_relation</span>
<span class="hl-keyword">SELECT</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span>, <span class="hl-type">cpr</span>.<span class="hl-property">child_id</span>, '<span class="hl-value">Missing child</span>' <span class="hl-keyword">as</span> <span class="hl-type">issue</span>
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_relation</span> cpr
<span class="hl-keyword">LEFT JOIN</span> <span class="hl-type">catalog_product_entity</span> child <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">child_id</span> = <span class="hl-type">child</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">child</span>.<span class="hl-property">row_id</span> <span class="hl-keyword">IS NULL</span>;
</pre>
<h2><a id="content-cleaning-up" href="#content-cleaning-up" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Cleaning up</h2>
<p>Back up your database first. Then remove the invalid entries:</p>
<pre data-lang="sql" class="notranslate"><span class="hl-comment">-- Remove invalid entries from catalog_product_relation</span>
<span class="hl-keyword">DELETE</span> cpr
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_relation</span> cpr
<span class="hl-keyword">INNER JOIN</span> <span class="hl-type">catalog_product_entity</span> parent <span class="hl-keyword">ON</span> <span class="hl-type">cpr</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> = '<span class="hl-value">simple</span>';

<span class="hl-comment">-- Remove invalid entries from catalog_product_super_link</span>
<span class="hl-keyword">DELETE</span> cpsl
<span class="hl-keyword">FROM</span> <span class="hl-type">catalog_product_super_link</span> cpsl
<span class="hl-keyword">INNER JOIN</span> <span class="hl-type">catalog_product_entity</span> parent <span class="hl-keyword">ON</span> <span class="hl-type">cpsl</span>.<span class="hl-property">parent_id</span> = <span class="hl-type">parent</span>.<span class="hl-property">row_id</span>
<span class="hl-keyword">WHERE</span> <span class="hl-type">parent</span>.<span class="hl-property">type_id</span> != '<span class="hl-value">configurable</span>';
</pre>
<p>After cleanup, reindex and clear cache:</p>
<pre data-lang="bash" class="notranslate">bin/magento indexer:reindex
bin/magento cache:clean
</pre>
<p>One thing to consider - if those simple products <em>should</em> actually be configurable products, you’ll need to convert them rather than just deleting the relationships. The queries above assume the relationships themselves are the problem, not the product types.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Disable Chrome AI Mode in the Omnibox</title>
        <link>https://dor.ky/post/disable-chrome-ai-mode-omnibox</link>
        <guid isPermaLink="true">https://dor.ky/post/disable-chrome-ai-mode-omnibox</guid>
        <pubDate>Mon, 09 Feb 2026 00:00:00 +0000</pubDate>
        
        <description>How to disable the AI mode entry point in Chrome&#039;s address bar using a flag.</description>
        <content:encoded><![CDATA[<p>If you want to disable the AI mode button that appears in Chrome’s omnibox/address bar then you can do so via Chrome flags.</p>
<p>Navigate to:</p>
<pre data-lang="txt" class="notranslate">chrome://flags/#ai-mode-omnibox-entry-point
</pre>
<p>Set the flag to <strong>Disabled</strong> and relaunch Chrome.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Claude Code - How to Revert Back to Opus 4.5 from Opus 4.6</title>
        <link>https://dor.ky/post/claude-code-how-to-revert-back-to-opus-4-5-from-opus-4-6</link>
        <guid isPermaLink="true">https://dor.ky/post/claude-code-how-to-revert-back-to-opus-4-5-from-opus-4-6</guid>
        <pubDate>Mon, 02 Feb 2026 00:00:00 +0000</pubDate>
        
        <description>Claude Code - How to Revert Back to Opus 4.5 from Opus 4.6</description>
        <content:encoded><![CDATA[<p>If you want to go back to Opus 4.5, start hashtag#claude with:</p>
<pre data-lang="bash" class="notranslate">claude <span class="hl-generic">--model</span> claude-opus-<span class="hl-number">4</span>-<span class="hl-number">5</span>-<span class="hl-number">20251101</span>
</pre>
<p>You can also edit your config file to set this as the default if you want to use it by default rather than 4.6.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Signing Git Commits with SSH</title>
        <link>https://dor.ky/post/signing-git-commits-with-ssh</link>
        <guid isPermaLink="true">https://dor.ky/post/signing-git-commits-with-ssh</guid>
        <pubDate>Mon, 26 Jan 2026 00:00:00 +0000</pubDate>
        
        <description>How to sign your git commits using SSH keys instead of GPG</description>
        <content:encoded><![CDATA[<p>I recently ran into a “No signature” error when trying to get my git commits verified and it sent me down a bit of a rabbit hole. If you’ve ever tried to set up GPG signing for git commits, you’ll know it can be a fairly painful experience.</p>
<p>As you can now sign git commits with SSH keys rather than GPG and given that pretty much everyone already has an SSH key (and pretty much no one has a GPG key properly configured) - this is a much more practical approach.</p>
<p>Caleb Hearth has written an <a href="https://calebhearth.com/sign-git-with-ssh">excellent post on how to set this up</a>.</p>
<p>If you’re using GitHub, you’ll need to upload your SSH key separately as a signing key (distinct from your authentication key) - but once that’s done you’ll get the verified badge on your commits.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe Commerce Isolated Security Patches Changelog</title>
        <link>https://dor.ky/post/adobe-commerce-isolated-security-patches-changelog</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-commerce-isolated-security-patches-changelog</guid>
        <pubDate>Mon, 12 Jan 2026 00:00:00 +0000</pubDate>
        
        <description>Adobe Commerce Isolated Security Patches Changelog</description>
        <content:encoded><![CDATA[<p>Adobe have changed the way they usually release security patch updates for<br />
2026, but sadly their communcations around the release leaves quite a bit<br />
to be desired.</p>
<p>I’ll collate the release information here through the year and document<br />
what has/hasn’t been released as per the <a href="https://experienceleague.adobe.com/en/docs/commerce-operations/release/planning/schedule">official release schedule</a>.</p>
<p>13/01/2026: No patches released, confirmed by <a href="https://magentocommeng.slack.com/archives/CANPJTBC5/p1768322442816999">Nathan Smith via Slack</a>.</p>
<p>10/02/2026: There was a release planned but then was revoked. Currently no release planned.</p>
<p>10/03/2026: Pending</p>
<p>14/04/2026: Pending</p>
<p>12/05/2026: Pending, should be full semver release (well, Adobe’s attempt at it).</p>
<p>09/06/2026: Pending</p>
<p>14/07/2026: Pending</p>
<p>11/08/2026: Pending</p>
<p>08/09/2026: Pending</p>
<p>13/10/2026: Pending</p>
<p>10/11/2026: Pending</p>
<p>08/12/2026: Pending</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe Commerce Deployments Failing - Composer block-insecure in Effect for 2.7/2.8</title>
        <link>https://dor.ky/post/adobe-commerce-deployments-failing-composer-block-insecure-in-effect-for-27-28</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-commerce-deployments-failing-composer-block-insecure-in-effect-for-27-28</guid>
        <pubDate>Fri, 02 Jan 2026 00:00:00 +0000</pubDate>
        
        <description>Adobe Commerce Deployments Failing - Composer block-insecure in Effect for 2.7/2.8</description>
        <content:encoded><![CDATA[<p>If you’re running into this issue with Adobe Commerce deployments, it’s due to the older versions of <code>composer</code> now being marked as having a few security issues (“PKSA-1gck-s111-yq7g”, “PKSA-b8f7-zn44-r4gz”, “PKSA-s25b-vbmp-jvhh”):</p>
<pre data-lang="bash" class="notranslate">Installing build dependencies...
  Installing php build dependencies: composer/composer
  W: Changed current directory to /app/.global/composer/composer
  W: No composer.lock file present. Updating dependencies to latest instead of installing from lock file. See https://getcomposer.org/install <span class="hl-keyword">for</span> more information.
  W: Loading composer repositories with package information
  W: Updating dependencies
  W: Your requirements could not be resolved to an installable <span class="hl-type">set</span> of packages.
  W:
  W:   Problem <span class="hl-number">1</span>
  W:     - Root composer.json requires composer/composer <span class="hl-number">2</span>.<span class="hl-number">7</span>.<span class="hl-number">0</span> (exact version match: <span class="hl-number">2</span>.<span class="hl-number">7</span>.<span class="hl-number">0</span> or <span class="hl-number">2</span>.<span class="hl-number">7</span>.<span class="hl-number">0</span>.<span class="hl-number">0</span>), found composer/composer[<span class="hl-number">2</span>.<span class="hl-number">7</span>.<span class="hl-number">0</span>] but these were not loaded, because they are affected by security advisories. To ignore the advisories, add (<span class="hl-value">&quot;PKSA-1gck-s111-yq7g&quot;</span>, <span class="hl-value">&quot;PKSA-b8f7-zn44-r4gz&quot;</span>, <span class="hl-value">&quot;PKSA-s25b-vbmp-jvhh&quot;</span>) to the audit <span class="hl-value">&quot;ignore&quot;</span> config. To turn the feature off entirely, you can <span class="hl-type">set</span> <span class="hl-value">&quot;block-insecure&quot;</span> to false <span class="hl-keyword">in</span> your <span class="hl-value">&quot;audit&quot;</span> config.
  W:

E: Error building project: `composer` could not be run.

E: Error: Unable to build application, aborting.
</pre>
<p>To fix the issue, update the version of <code>composer</code> used to build your project in the <code>.magento.app.yaml</code> file:</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">dependencies</span><span class="hl-property">:</span>
    <span class="hl-keyword">php</span><span class="hl-property">:</span>
        composer/<span class="hl-keyword">composer</span>: '<span class="hl-value">^2.9</span>'
</pre>
<p>For Magento 2.4.7 and up, use <code>^2.9</code>, for anything else use <code>^2.2</code> which should give you <code>2.2.26</code>.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Nginx client_header_buffer_size - Debugging Cloudflare-related 400 Errors for Magento/Adobe Commerce HTTP Requests</title>
        <link>https://dor.ky/post/nginx-400-error-webhook-response-debugging-for-adobe-commerce-cloudflare</link>
        <guid isPermaLink="true">https://dor.ky/post/nginx-400-error-webhook-response-debugging-for-adobe-commerce-cloudflare</guid>
        <pubDate>Wed, 24 Dec 2025 00:00:00 +0000</pubDate>
        
        <description>How to debug and fix nginx 400 errors caused by headers exceeding nginx&#039;s client_header_buffer_size limits in Magento/Adobe Commerce webhook requests.</description>
        <content:encoded><![CDATA[<p>One of the more interesting things I’ve had to debug this year was why Magento<br />
was apparently returning <code>400</code> responses to <a href="https://builder.io">builder.io</a> for certain webhook payloads.</p>
<p>A few of the team had looked into this but drawn a bit of a blank so I offered to take a look and it ended up being a fairly puzzling one to debug.</p>
<p>The same payload <code>POST</code> direct to a local running version of the application would give the expected <code>200</code> response, but when running through it’s production stack of Cloudflare and into the excellent <a href="https://www.corefinity.com/">Corefinity</a> - we suddently started getting <code>400</code> response codes which was fairly odd as the custom  controller that was receiving the payload had no code in it to return a <code>400</code> response.</p>
<p>My first thought was that Cloudflare was stopping the requests going due to either the body content or an OWASP ruleset. Running the requests through in no-proxy mode discounted this as being the issue.</p>
<p>A fairly important piece of debugging spotted that the <code>nginx</code> log presented the <code>400</code> error as well, so that ruled out Cloudflare being the issue.</p>
<p>I added some ancillary logging to fire whenever the controller Magento side fired to determine whether or not the request was even getting that far and fairly quickly realised that they weren’t even hitting Magento to return a <code>400</code> response.</p>
<p>My thoughts then shifted to either PHP’s <code>post_max_size</code>, or perhaps<br />
<code>max_input_vars</code> set to low and the payload couldn’t be parsed. The additional logging never fired when the <code>400</code> was being returned so that pointed to <code>nginx</code> or <code>varnish</code> being the culprit.</p>
<p>At this point, I trapped a few of the outbound <code>POST</code> requests again and spotted that once they’d been through Cloudflare there was quite a number of headers and suspected that the issue we were actually running into was that nginx’s two configuration values were too low for:</p>
<p><code>client_header_buffer_size</code><br />
<code>large_client_header_buffers</code></p>
<p>This turned out to be the case, once these were increased the <code>400</code> errors soon<br />
disappeared. We did also debug whether varnish was involved but fairly quickly<br />
ruled out that being part of the issue. It was solely nginx.</p>
<p>If you ever encounter add odd <code>400</code> response for a webhook on any host, I can highly recommend that you check those two configuration values first and with a bit of luck I’ve saved you some debugging.</p>
<p>The best piece of advice I can give you is to isolate the point inside the stack you’re getting to when the <code>400</code> is thrown, that will help you narrow down what the cause is quickly.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe Commerce/Magento 2 Concurrent Import Process Interference Issue</title>
        <link>https://dor.ky/post/adobe-commerce-magento2-issue-running-concurrent-imports</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-commerce-magento2-issue-running-concurrent-imports</guid>
        <pubDate>Thu, 04 Dec 2025 00:00:00 +0000</pubDate>
        
        <description>Understanding and avoiding data corruption when running multiple import processes simultaneously in Magento 2.4.6+</description>
        <content:encoded><![CDATA[<p>If you’re running multiple import processes simultaneously in Adobe Commerce or Magento 2, you might encounter a frustrating bug where the imports interfere with each other, causing data corruption or import failures. This issue, documented in <a href="https://github.com/magento/magento2/issues/37593">GitHub issue #37593</a>, has been confirmed in Magento 2.4.6 but is present in other versions too namely 2.4.7.</p>
<p>If two import processes run concurrently in Magento 2.4.6, they can interfere with each other in unexpected ways. For example, if you’re simultaneously importing customers and products, product data might erroneously appear in the customer import process, causing failures. I discovered this while debugging a customer importer that ended up being fed stock source import data.</p>
<p>The issue stems from how Magento handles large import files using the <code>importexport_importdata</code> table. The default batch size limit is set to 100 records in <code>vendor/magento/module-import-export/etc/config.xml</code>. When an import exceeds this threshold, Magento splits the data across multiple rows in the database table and this is fed into the import<br />
engine.</p>
<p>The problem occurs when concurrent imports add their validated data simultaneously to this shared table. Processes can then retrieve mixed or incorrect data from other running imports, leading to validation errors and data corruption.</p>
<h2><a id="content-reproduction" href="#content-reproduction" class="heading-permalink" aria-hidden="true" tabindex="-1" title="Permalink">¶</a>Reproduction</h2>
<pre><code>- Open Magento admin in two separate browser sessions or systems
- Navigate to System &gt; Data Transfer &gt; Import in both
- In the first session: Select &quot;Customers and Addresses&quot; with 500+ records
- In the second session: Select &quot;Catalog Product Import&quot; with 50-60 records
- Validate both imports
- Click Import on the first system, then immediately click Import on the second
</code></pre>
<p>It’s fixed in <a href="https://github.com/magento/magento2/commit/c4a868c0098379ab260bbc54f9a54267830dd0aa">this commit</a> which was pushed out in 2.4.8. For 2.4.7, Adobe have provided this <code>ACSD-63793_2.4.7-p3_v2.patch</code> patch:</p>
<pre data-lang="diff" class="notranslate"><span class="hl-comment">diff --git a/vendor/magento/module-import-export/Block/Adminhtml/Import/Edit/Form.php b/vendor/magento/module-import-export/Block/Adminhtml/Import/Edit/Form.php</span>
<span class="hl-comment">index bf394c9ed0c42..11eaaa1a4a202 100644</span>
<span class="hl-type">--- a/vendor/magento/module-import-export/Block/Adminhtml/Import/Edit/Form.php</span>
<span class="hl-type">+++ b/vendor/magento/module-import-export/Block/Adminhtml/Import/Edit/Form.php</span>
<span class="hl-generic">@@ -1,7 +1,7 @@</span>
<span class="hl-diff-context"> &lt;?php</span>
<span class="hl-diff-context"> /**</span>
<span class="hl-deletion">- * Copyright © Magento, Inc. All rights reserved.</span>
<span class="hl-deletion">- * See COPYING.txt for license details.</span>
<span class="hl-addition">+ * Copyright 2013 Adobe</span>
<span class="hl-addition">+ * All Rights Reserved.</span>
<span class="hl-diff-context">  */</span>
<span class="hl-diff-context"> namespace Magento\ImportExport\Block\Adminhtml\Import\Edit;</span>
<span class="hl-diff-context"> </span>
<span class="hl-generic">@@ -264,6 +264,16 @@ protected function _prepareForm()</span>
<span class="hl-diff-context">                 'value' =&gt; '',</span>
<span class="hl-diff-context">             ]</span>
<span class="hl-diff-context">         );</span>
<span class="hl-addition">+        $fieldset-&gt;addField(</span>
<span class="hl-addition">+            '_import_history_id',</span>
<span class="hl-addition">+            'hidden',</span>
<span class="hl-addition">+            [</span>
<span class="hl-addition">+                'name' =&gt; '_import_history_id',</span>
<span class="hl-addition">+                'label' =&gt; __('Import History id'),</span>
<span class="hl-addition">+                'title' =&gt; __('Import History id'),</span>
<span class="hl-addition">+                'value' =&gt; '',</span>
<span class="hl-addition">+            ]</span>
<span class="hl-addition">+        );</span>
<span class="hl-diff-context">         $fieldsets['upload'] = $fieldset;</span>
<span class="hl-diff-context">         $form-&gt;setUseContainer(true);</span>
<span class="hl-diff-context">         $this-&gt;setForm($form);</span>
<span class="hl-comment">diff --git a/vendor/magento/module-import-export/Controller/Adminhtml/Import/Validate.php b/vendor/magento/module-import-export/Controller/Adminhtml/Import/Validate.php</span>
<span class="hl-comment">index c388851edcbe4..bf0758bb2105c 100644</span>
<span class="hl-type">--- a/vendor/magento/module-import-export/Controller/Adminhtml/Import/Validate.php</span>
<span class="hl-type">+++ b/vendor/magento/module-import-export/Controller/Adminhtml/Import/Validate.php</span>
<span class="hl-generic">@@ -1,7 +1,7 @@</span>
<span class="hl-diff-context"> &lt;?php</span>
<span class="hl-diff-context"> /**</span>
<span class="hl-deletion">- * Copyright © Magento, Inc. All rights reserved.</span>
<span class="hl-deletion">- * See COPYING.txt for license details.</span>
<span class="hl-addition">+ * Copyright 2011 Adobe</span>
<span class="hl-addition">+ * All Rights Reserved.</span>
<span class="hl-diff-context">  */</span>
<span class="hl-diff-context"> declare(strict_types=1);</span>
<span class="hl-diff-context"> </span>
<span class="hl-generic">@@ -50,6 +50,11 @@ public function execute()</span>
<span class="hl-diff-context">                 $ids = $import-&gt;getValidatedIds();</span>
<span class="hl-diff-context">                 if (count($ids) &gt; 0) {</span>
<span class="hl-diff-context">                     $resultBlock-&gt;addAction('value', Import::FIELD_IMPORT_IDS, $ids);</span>
<span class="hl-addition">+                    $resultBlock-&gt;addAction(</span>
<span class="hl-addition">+                        'value',</span>
<span class="hl-addition">+                        '_import_history_id',</span>
<span class="hl-addition">+                        $this-&gt;historyModel-&gt;getId()</span>
<span class="hl-addition">+                    );</span>
<span class="hl-diff-context">                 }</span>
<span class="hl-diff-context">             } catch (\Magento\Framework\Exception\LocalizedException $e) {</span>
<span class="hl-diff-context">                 $resultBlock-&gt;addError($e-&gt;getMessage());</span>
<span class="hl-comment">diff --git a/vendor/magento/module-import-export/Model/History.php b/vendor/magento/module-import-export/Model/History.php</span>
<span class="hl-comment">index 9a97367ba8453..ad0be7fbb8bc1 100644</span>
<span class="hl-type">--- a/vendor/magento/module-import-export/Model/History.php</span>
<span class="hl-type">+++ b/vendor/magento/module-import-export/Model/History.php</span>
<span class="hl-generic">@@ -1,7 +1,7 @@</span>
<span class="hl-diff-context"> &lt;?php</span>
<span class="hl-diff-context"> /**</span>
<span class="hl-deletion">- * Copyright © Magento, Inc. All rights reserved.</span>
<span class="hl-deletion">- * See COPYING.txt for license details.</span>
<span class="hl-addition">+ * Copyright 2015 Adobe</span>
<span class="hl-addition">+ * All Rights Reserved.</span>
<span class="hl-diff-context">  */</span>
<span class="hl-diff-context"> namespace Magento\ImportExport\Model;</span>
<span class="hl-diff-context"> </span>
<span class="hl-generic">@@ -9,33 +9,34 @@</span>
<span class="hl-diff-context">  * Import history model</span>
<span class="hl-diff-context">  *</span>
<span class="hl-diff-context">  * @api</span>
<span class="hl-addition">+ * @SuppressWarnings(PHPMD.CookieAndSessionMisuse)</span>
<span class="hl-diff-context">  * @SuppressWarnings(PHPMD.CouplingBetweenObjects)</span>
<span class="hl-diff-context">  * @SuppressWarnings(PHPMD.LongVariable)</span>
<span class="hl-diff-context">  * @since 100.0.2</span>
<span class="hl-diff-context">  */</span>
<span class="hl-diff-context"> class History extends \Magento\Framework\Model\AbstractModel</span>
<span class="hl-diff-context"> {</span>
<span class="hl-deletion">-    const HISTORY_ID = 'history_id';</span>
<span class="hl-addition">+    public const HISTORY_ID = 'history_id';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const STARTED_AT = 'started_at';</span>
<span class="hl-addition">+    public const STARTED_AT = 'started_at';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const USER_ID = 'user_id';</span>
<span class="hl-addition">+    public const USER_ID = 'user_id';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const IMPORTED_FILE = 'imported_file';</span>
<span class="hl-addition">+    public const IMPORTED_FILE = 'imported_file';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const ERROR_FILE = 'error_file';</span>
<span class="hl-addition">+    public const ERROR_FILE = 'error_file';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const EXECUTION_TIME = 'execution_time';</span>
<span class="hl-addition">+    public const EXECUTION_TIME = 'execution_time';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const SUMMARY = 'summary';</span>
<span class="hl-addition">+    public const SUMMARY = 'summary';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const IMPORT_IN_PROCESS = 'In Progress';</span>
<span class="hl-addition">+    public const IMPORT_IN_PROCESS = 'In Progress';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const IMPORT_VALIDATION = 'Validation';</span>
<span class="hl-addition">+    public const IMPORT_VALIDATION = 'Validation';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const IMPORT_FAILED = 'Failed';</span>
<span class="hl-addition">+    public const IMPORT_FAILED = 'Failed';</span>
<span class="hl-diff-context"> </span>
<span class="hl-deletion">-    const IMPORT_SCHEDULED_USER = 0;</span>
<span class="hl-addition">+    public const IMPORT_SCHEDULED_USER = 0;</span>
<span class="hl-diff-context"> </span>
<span class="hl-diff-context">     /**</span>
<span class="hl-diff-context">      * @var \Magento\ImportExport\Helper\Report</span>
<span class="hl-generic">@@ -122,7 +123,7 @@ public function addErrorReportFile($filename)</span>
<span class="hl-diff-context">     public function updateReport(Import $import, $updateSummary = false)</span>
<span class="hl-diff-context">     {</span>
<span class="hl-diff-context">         if ($import-&gt;isReportEntityType()) {</span>
<span class="hl-deletion">-            $this-&gt;load($this-&gt;getLastItemId());</span>
<span class="hl-addition">+            $this-&gt;load($import-&gt;getData('_import_history_id') ?? $this-&gt;getLastItemId());</span>
<span class="hl-diff-context">             $executionResult = self::IMPORT_IN_PROCESS;</span>
<span class="hl-diff-context">             if ($updateSummary) {</span>
<span class="hl-diff-context">                 $executionResult = $this-&gt;reportHelper-&gt;getExecutionTime($this-&gt;getStartedAt());</span>
<span class="hl-comment">diff --git a/vendor/magento/module-import-export/i18n/en_US.csv b/vendor/magento/module-import-export/i18n/en_US.csv</span>
<span class="hl-comment">index cc1098841bab8..378daac3afa25 100644</span>
<span class="hl-type">--- a/vendor/magento/module-import-export/i18n/en_US.csv</span>
<span class="hl-type">+++ b/vendor/magento/module-import-export/i18n/en_US.csv</span>
<span class="hl-generic">@@ -29,6 +29,7 @@ Import,Import</span>
<span class="hl-diff-context"> &quot;File to Import&quot;,&quot;File to Import&quot;</span>
<span class="hl-diff-context"> &quot;Select File to Import&quot;,&quot;Select File to Import&quot;</span>
<span class="hl-diff-context"> &quot;Images File Directory&quot;,&quot;Images File Directory&quot;</span>
<span class="hl-addition">+&quot;Import History id&quot;,&quot;Import History id&quot;</span>
<span class="hl-diff-context"> &quot;For Type &quot;&quot;Local Server&quot;&quot; use relative path to &amp;lt;Magento root directory&amp;gt;/var/import/images, e.g. &lt;i&gt;product_images&lt;/i&gt;, &lt;i&gt;import_images/batch1&lt;/i&gt;.&lt;br&gt;&lt;br&gt;For example, in case &lt;i&gt;product_images&lt;/i&gt;, files should be placed into &lt;i&gt;&amp;lt;Magento root directory&amp;gt;/var/import/images/product_images&lt;/i&gt; folder.&quot;,&quot;For Type &quot;&quot;Local Server&quot;&quot; use relative path to &amp;lt;Magento root directory&amp;gt;/var/import/images, e.g. &lt;i&gt;product_images&lt;/i&gt;, &lt;i&gt;import_images/batch1&lt;/i&gt;.&lt;br&gt;&lt;br&gt;For example, in case &lt;i&gt;product_images&lt;/i&gt;, files should be placed into &lt;i&gt;&amp;lt;Magento root directory&amp;gt;/var/import/images/product_images&lt;/i&gt; folder.&quot;</span>
<span class="hl-diff-context"> &quot;Download Sample File&quot;,&quot;Download Sample File&quot;</span>
<span class="hl-diff-context"> &quot;Please correct the data sent value.&quot;,&quot;Please correct the data sent value.&quot;</span>
</pre>
<p>As noted earlier, there’s more discussion and info on <a href="https://github.com/magento/magento2/issues/37593">GitHub Issue #37593</a>.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>How to List/Change/Activate Different Service Versions on Fastly for Adobe Commerce</title>
        <link>https://dor.ky/post/adobe-commerce-fastly-change-or-activate-different-service-version</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-commerce-fastly-change-or-activate-different-service-version</guid>
        <pubDate>Sun, 23 Nov 2025 00:00:00 +0000</pubDate>
        
        <description>How to List/Change/Activate Different Service Versions on Fastly for Adobe Commerce</description>
        <content:encoded><![CDATA[<p>There are times when you need to either list or activate an old version of your<br />
Fastly configuration but you may not be able to access your Magento/Adobe Commerce<br />
control panel to do so.</p>
<p>If you need to list versions of your service, you can do so using the Fastly CLI<br />
tool using:</p>
<pre data-lang="bash" class="notranslate">fastly service-version list <span class="hl-generic">-t</span> API_KEY <span class="hl-generic">-s</span> SERVICE_ID
</pre>
<p>This will return you a list of service versions and their associated dates:</p>
<pre data-lang="txt" class="notranslate">NUMBER  ACTIVE  STAGED  LAST EDITED (UTC)
1        false    false    2020-06-09 17:25
2        false    false    2020-06-09 21:01
3        false    false    2020-06-10 00:08
4        false    false    2020-06-10 02:27
5        false    false    2020-06-10 06:40
6        false    false    2020-06-10 09:02
7        false    false    2020-06-11 16:07
8        false    false    2020-06-25 20:02
9        false    false    2020-06-25 20:02
10       false    false    2020-07-13 08:42
11       false    false    2020-07-13 08:43
12       false    false    2020-07-22 10:09
13       false    false    2020-07-28 20:07
14       false    false    2020-07-28 19:51
15       false    false    2020-08-03 12:04
16       false    false    2020-09-29 20:04
17       false    false    2020-10-02 12:03
18       false    false    2020-10-29 21:32
19       false    false    2020-12-01 12:03
20       false    false    2021-01-07 12:57
21       false    false    2021-01-07 12:58
22       false    false    2021-01-07 12:59
23       false    false    2021-01-07 13:01
24       false    false    2021-01-30 08:05
25       false    false    2021-02-16 10:41
26       false    false    2021-02-17 09:17
27       false    false    2021-03-18 14:29
28       false    false    2021-03-31 08:04
29       false    false    2021-05-06 10:14
30       false    false    2021-05-10 10:08
31       false    false    2021-05-10 10:09
32       false    false    2021-05-12 14:01
33       false    false    2021-05-30 08:05
34       false    false    2021-06-07 21:07
35       false    false    2021-06-07 21:19
36       false    false    2021-07-16 10:08
37       false    false    2021-07-16 11:11
38       false    false    2021-07-29 08:04
39       false    false    2021-09-27 08:05
40       false    false    2021-11-10 16:15
41       false    false    2021-11-11 17:39
42       false    false    2021-11-15 11:52
43       false    false    2021-11-26 08:04
44       false    false    2021-12-09 11:28
45       false    false    2022-01-25 08:04
46       false    false    2022-01-28 09:34
47       false    false    2022-02-08 16:30
48       false    false    2022-03-08 10:46
49       false    false    2022-03-26 08:04
50       false    false    2022-03-29 01:53
51       false    false    2022-04-19 12:27
52       false    false    2022-05-18 08:30
53       false    false    2022-05-25 08:04
54       false    false    2022-07-24 08:03
55       false    false    2022-07-24 08:03
56       false    false    2022-08-17 13:23
57       false    false    2022-09-20 08:21
58       false    false    2022-09-22 08:04
59       false    false    2022-09-27 13:47
60       false    false    2022-09-27 13:56
61       false    false    2022-09-28 09:11
62       false    false    2022-10-03 09:02
63       false    false    2022-10-19 13:56
64       false    false    2022-11-21 08:02
65       false    false    2022-12-06 05:43
66       false    false    2022-12-08 15:14
67       false    false    2022-12-13 09:31
68       false    false    2022-12-13 09:34
69       false    false    2022-12-22 16:00
70       false    false    2022-12-22 16:04
71       false    false    2023-01-20 08:03
72       false    false    2023-01-27 12:45
73       false    false    2023-02-23 16:15
74       false    false    2023-03-21 08:02
75       false    false    2023-04-21 16:07
76       false    false    2023-04-21 16:34
77       false    false    2023-04-21 16:46
78       false    false    2023-04-21 18:25
79       false    false    2023-04-21 20:16
80       false    false    2023-04-22 11:53
81       false    false    2023-04-22 12:03
82       false    false    2023-04-24 09:39
83       false    false    2023-04-24 09:55
84       false    false    2023-04-24 09:56
85       false    false    2023-04-24 10:00
86       false    false    2023-04-24 10:07
87       false    false    2023-04-27 13:05
88       false    false    2023-04-27 13:07
89       false    false    2023-05-02 08:35
90       false    false    2023-05-02 10:22
91       false    false    2023-05-20 08:04
92       false    false    2023-05-24 17:20
93       false    false    2023-05-25 09:28
94       false    false    2023-05-31 18:13
95       false    false    2023-07-06 12:34
96       false    false    2023-07-19 08:05
97       false    false    2023-08-15 09:41
98       false    false    2023-08-15 09:41
99       false    false    2023-08-15 10:51
100      false    false    2023-08-15 10:52
101      false    false    2023-08-15 14:31
102      false    false    2023-08-22 07:08
103      false    false    2023-08-22 13:45
104      false    false    2023-08-22 15:21
105      false    false    2023-08-22 15:26
106      false    false    2023-08-22 15:25
107      false    false    2023-08-22 15:25
108      false    false    2023-09-06 21:37
109      false    false    2023-08-22 15:26
110      false    false    2023-09-15 13:28
111      false    false    2023-09-15 13:38
112      false    false    2023-09-17 08:04
113      false    false    2023-09-23 05:36
114      false    false    2023-09-23 05:49
115      false    false    2023-09-23 06:12
116      false    false    2023-09-25 06:09
117      false    false    2023-10-05 05:58
118      false    false    2023-10-05 06:35
119      false    false    2023-10-05 09:06
120      false    false    2023-10-05 09:08
121      false    false    2023-10-05 09:09
122      false    false    2023-10-05 09:29
123      false    false    2023-10-27 07:20
124      false    false    2023-11-07 14:24
125      false    false    2023-11-16 08:04
126      false    false    2023-11-20 12:45
127      false    false    2023-12-12 08:05
128      false    false    2023-12-13 17:57
129      false    false    2024-01-15 08:04
130      false    false    2024-03-01 10:44
131      false    false    2024-03-15 08:05
132      false    false    2024-04-10 14:16
133      false    false    2024-04-10 14:20
134      false    false    2024-05-02 18:39
135      false    false    2024-05-14 08:05
136      false    false    2024-05-17 10:23
137      false    false    2024-05-17 10:26
138      false    false    2024-05-17 10:31
139      false    false    2024-05-17 10:34
140      false    false    2024-05-17 12:24
141      false    false    2024-05-17 12:25
142      false    false    2024-05-17 12:28
143      false    false    2024-05-21 09:11
144      false    false    2024-05-21 09:17
145      false    false    2024-05-21 09:30
146      false    false    2024-05-21 09:30
147      false    false    2024-05-21 12:22
148      false    false    2024-06-03 10:03
149      false    false    2024-06-19 06:07
150      false    false    2024-06-28 06:55
151      false    false    2024-07-05 12:05
152      false    false    2024-07-08 07:14
153      false    false    2024-07-08 11:52
154      false    false    2024-07-09 07:25
155      false    false    2024-07-13 08:03
156      false    false    2024-07-19 06:28
157      false    false    2024-07-19 06:57
158      false    false    2024-07-22 11:32
159      false    false    2024-08-16 12:39
160      false    false    2024-09-11 08:04
161      false    false    2024-09-25 17:58
162      false    false    2024-10-14 21:41
163      false    false    2024-10-22 07:29
164      false    false    2024-10-22 22:09
165      false    false    2024-11-10 08:04
166      false    false    2024-11-13 14:30
167      false    false    2024-11-22 08:03
168      false    false    2024-12-17 08:42
169      false    false    2024-12-22 03:13
170      false    false    2024-12-22 03:58
171      false    false    2025-01-09 08:04
172      false    false    2024-12-22 03:53
173      false    false    2025-01-29 21:54
174      false    false    2025-01-29 21:54
175      false    false    2025-02-13 14:20
176      false    false    2025-03-10 08:05
177      false    false    2025-03-21 08:44
178      false    false    2025-03-21 12:03
179      false    false    2025-03-21 15:12
180      false    false    2025-03-21 15:13
181      false    false    2025-03-25 23:02
182      false    false    2025-05-08 16:36
183      false    false    2025-05-09 08:03
184      false    false    2025-05-15 16:18
185      false    false    2025-05-21 11:16
186      false    false    2025-05-24 13:15
187      false    false    2025-06-11 13:24
188      false    false    2025-07-08 08:03
189      false    false    2025-07-29 16:18
190      false    false    2025-08-06 16:26
191      false    false    2025-09-05 14:48
192      false    false    2025-09-06 08:04
193      false    false    2025-09-08 17:38
194      false    false    2025-09-11 22:18
195      false    false    2025-09-22 07:00
196      false    false    2025-09-22 23:59
197      false    false    2025-10-14 16:04
198      false    false    2025-10-20 12:13
199      false    false    2025-10-20 13:25
200      false    false    2025-10-21 12:16
201      false    false    2025-11-05 08:04
202      false    false    2025-11-20 10:58
203      false    false    2025-11-20 10:46
204      false    false    2025-11-20 11:30
205      false    false    2025-11-20 11:53
206      true     false    2025-11-20 11:53
</pre>
<p>From here, you can activate one of those versions by running this command:</p>
<pre data-lang="bash" class="notranslate">fastly service-version activate <span class="hl-generic">--version</span>=<span class="hl-number">204</span> <span class="hl-generic">-t</span> API_KEY <span class="hl-generic">-s</span> SERVICE_ID
</pre>
<p>You can fetch both your API_KEY key and SERVICE_ID from the file mounted at:</p>
<pre data-lang="bash" class="notranslate">/mnt/shared/fastly_tokens.txt
</pre>
<p>in your Commerce Cloud cluster if you don’t have the tokens to hand.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Flush Fastly CDN via CLI/Command Line for Adobe Commerce Projects</title>
        <link>https://dor.ky/post/flush-adobe-commerce-fastly-cdn-via-curl</link>
        <guid isPermaLink="true">https://dor.ky/post/flush-adobe-commerce-fastly-cdn-via-curl</guid>
        <pubDate>Sun, 16 Nov 2025 00:00:00 +0000</pubDate>
        
        <description>Flush Fastly CDN via CLI/Command Line for Adobe Commerce Projects</description>
        <content:encoded><![CDATA[<p>If you ever need the ability to flush Fastly but can’t acess an Adobe Commerce<br />
admin panel to do so, you can run the following curl request:</p>
<pre data-lang="bash" class="notranslate">curl <span class="hl-generic">-H</span> <span class="hl-value">&quot;Fastly-Key: API_KEY” -X POST &quot;</span>https://api.fastly.com/service/SERVICE_ID/purge_all&quot;
</pre>
<p>You can fetch both your API_KEY key and SERVICE_ID from the file mounted at:</p>
<pre data-lang="bash" class="notranslate">/mnt/shared/fastly_tokens.txt
</pre>
<p>in your Commerce Cloud cluster.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe Commerce Deployment issue &#039;does not support Valkey backend model&#039;</title>
        <link>https://dor.ky/post/valkey-error-adobe-commerce-deployments</link>
        <guid isPermaLink="true">https://dor.ky/post/valkey-error-adobe-commerce-deployments</guid>
        <pubDate>Sun, 13 Apr 2025 00:00:00 +0000</pubDate>
        
        <description>Adobe Commerce Deployment issue &#039;does not support Valkey backend model&#039;</description>
        <content:encoded><![CDATA[<p>If you’ve recently deployed one of the latest release lines of Magento on Commerce Cloud and have:</p>
<pre data-lang="yaml" class="notranslate"><span class="hl-keyword">REDIS_BACKEND</span><span class="hl-property">:</span> '<span class="hl-value">\Magento\Framework\Cache\Backend\RemoteSynchronizedCache</span>'
</pre>
<pre data-lang="bash" class="notranslate">[<span class="hl-number">2025</span>-<span class="hl-number">04</span>-08T10:<span class="hl-number">29</span>:<span class="hl-number">29</span>.<span class="hl-number">486342</span>+<span class="hl-number">00</span>:<span class="hl-number">00</span>] 
ERROR: Magento version <span class="hl-value">'2.4.7.0-patch5'</span> does not support Valkey backend 
model <span class="hl-value">'\Magento\Framework\Cache\Backend\RemoteSynchronizedCache'</span>
</pre>
<p>There is a <a href="https://github.com/magento/ece-tools/issues/824">Github issue for this problem</a>. That<br />
latest release of <code>ece-tools</code> does have a fix for the issue albeit not a great fix.</p>
]]></content:encoded>
    </item>
    
    <item>
        <title>Adobe Commerce B2B upgrade issue &#039;REGEXP_LIKE does not exist&#039;</title>
        <link>https://dor.ky/post/adobe-commerce-b2b-upgrade-error</link>
        <guid isPermaLink="true">https://dor.ky/post/adobe-commerce-b2b-upgrade-error</guid>
        <pubDate>Sun, 13 Apr 2025 00:00:00 +0000</pubDate>
        
        <description>Adobe Commerce B2B upgrade issue &#039;REGEXP_LIKE does not exist&#039;</description>
        <content:encoded><![CDATA[<p>If you’re using Adobe’s B2B extension and recently tried upgrading then you likely hit this issue:</p>
<pre data-lang="bash" class="notranslate">W: Unable to apply data patch Magento\Company\Setup\Patch\Data\SetComp
W: anyForStructure <span class="hl-keyword">for</span> module Magento_Company. Original exception message: SQL
W: STATE[<span class="hl-number">42000</span>]: Syntax error or access violation: <span class="hl-number">1305</span> FUNCTION abcdefghijk 
W: .REGEXP_LIKE does not exist, query was: UPDATE `company_structure` SET `com
W: pany_id` = ? WHERE (REGEXP_LIKE(path, <span class="hl-value">'^7(/.+)?$'</span>))
</pre>
<p>I sent this in to Adobe’s Cloud support and got this Absolute gem of a response from support:</p>
<hr />
<blockquote>
<p>We investigated this further and please find more details regarding this error below:</p>
</blockquote>
<blockquote>
<p>W: Unable to apply data patch Magento\Company\Setup\Patch\Data\SetComp<br />
W: anyForStructure for module Magento_Company. Original exception message: SQL<br />
W: STATE[42000]: Syntax error or access violation: 1305 FUNCTION abcdefghijk<br />
W: .REGEXP_LIKE does not exist, query was: UPDATE <code>company_structure</code> SET <code>com W: pany_id</code> = ? WHERE (REGEXP_LIKE(path, ‘^7(/.+)?$’))</p>
</blockquote>
<blockquote>
<p>The Adobe Commerce version 2.4.6-p10 uses the function “REGEXP_LIKE” in the file \Magento\Company\Setup\Patch\Data\SetCompanyForStructure.</p>
</blockquote>
<blockquote>
<p>The function “REGEXP_LIKE” is not supported in hashtag MariaDB which is the default Database used by Adobe Commerce.</p>
</blockquote>
<p>No idea how this one got through QA.</p>
<p>If you pin the version of the B2B extension to <code>1.4.2-p3</code> it’ll work or you could patch out that function.</p>
]]></content:encoded>
    </item>
    
</channel>
</rss>
