feat: SurfaceGuard — env.php kill switches for the unauthenticated upload entry points - #1
Conversation
SurfaceGuard lets an operator switch off the public, unauthenticated file-upload
entry points a store does not use. When a switch is off, that endpoint's upload
path is denied outright and nothing reaches disk, while ordinary traffic on the
same route keeps working.
Configuration is a single `harden` key in app/etc/env.php, read through
DeploymentConfig only. There is no admin UI and no database config, so nothing a
compromised admin session can reach will flip a switch. Only a strict boolean
false denies; an absent key, a typo, or an unreadable env.php all resolve to core
behaviour, so the module cannot brick a storefront by omission.
Enforcement is six around-plugins:
- Checkout\Controller\Cart\Add storefront custom-option uploads
- Quote\Api\CartItemRepositoryInterface REST file options (guest + carts/mine)
- Customer\Controller\Address\File\Upload guest-reachable address file upload
- Customer\Model\FileUploaderFactory customer/address upload backstop
- GraphQl\Controller\GraphQl whole-endpoint kill switch
- Catalog ValidatorFile + ValidatorInfo custom-option backstop
The two catalog validators are the guarantee behind "off means neutered": every
custom-option file upload crosses one of them before the file is moved into
pub/media, whichever controller it arrived through.
Deviations from the spec, all documented in the README and the PR:
- The quickorder switch is dropped from v1. Its controller exists only on B2B
installs and a plugin naming a missing class fails di:compile everywhere
else. Shipping the key without enforcement would be dead config.
- Customer uploads are guarded at FileUploaderFactory rather than
FileProcessor. FileProcessor keeps its entity type in a private property with
no getter, whereas the factory receives it as a plugin argument — which also
covers the Commerce custom-attribute controllers without this module ever
naming a Commerce-only class.
- A dedicated controller guard was added for customer/address_file/upload so
that endpoint returns a flat 403 rather than the controller's JSON error
envelope.
- The REST guard is scoped to the webapi_rest area so a storefront add-to-cart
is answered by its own guard instead of this one.
Verified against the real EE 2.4.8-p5 tree: every plugin target class, method and
signature confirmed present; 28 unit tests green; all five XML files validate
against the framework XSDs.
Three review findings, all reproduced against the real EE 2.4.8-p5 tree before being changed. Optional file options no longer block the purchase. Core calls ValidatorFile::validate() for an optional file option even when the customer uploaded nothing (`$runValidation = $option->getIsRequire() || $upload->isUploaded()`) and throws Validator\Exception to say so. File.php catches that type and lets the purchase continue with a null value, but Validator\Exception extends InputException extends LocalizedException and its catch block sits *before* the LocalizedException catch — so our LocalizedException landed in the later block, which rethrows. The result was a failed add-to-cart for every customer of a product that merely offers an optional file option. The backstop now checks whether a file is genuinely present, using core's own file key, and defers to core when it is not. ValidatorInfo is untouched: File.php only reaches it when it already holds file info. The GraphQL kill switch now runs ahead of the cache. Magento_GraphQlCache declares its plugins on FrontControllerInterface with no sortOrder attribute, which the object manager config mapper reads as 0, and BuiltinPlugin returns the cached response without calling proceed on a hit. Our guard at sortOrder 10 therefore never ran for a cached query: the endpoint stayed served and no denial was logged. The guard moves to the same interface at sortOrder -100, outside the cache lookup. A response already cached at the CDN still never reaches PHP; that limit is inherent and is now documented. REST file detection reads structure instead of substrings. A text option whose value merely mentioned quote_path, order_path or secret_key — "Please print secret_key on the label" — was treated as a file upload and refused with a 403. Detection now requires those markers to be keys of a map, either an array or the JSON encoding of one. Only JSON is decoded; attacker-controlled input is never unserialized. Unit suite covers each regression: optional option with no upload proceeds, an actual upload is still denied, the re-materialize path is still denied unconditionally, and six innocent text values are no longer mistaken for uploads. 36 tests, 59 assertions, all green against the 2.4.8-p5 tree. All five XML files still validate against the framework XSDs.
|
Thanks — all three reproduce on 2.4.8-p5, so all three are fixed in e7c73b5. Since the findings were validated against a 2.4.6 tree, I re-derived each one from the 2.4.8-p5 source before touching anything; the evidence is below in case it is useful for the next review. 1. Optional file options blocking purchases — confirmed, fixedReproduced exactly as described, and the mechanism is worth spelling out because it is subtler than "wrong exception type".
$runValidation = $option->getIsRequire() || $upload->isUploaded($file);
if (!$runValidation) {
throw new \Magento\Framework\Validator\Exception(...);
}So core itself throws for an optional option with nothing uploaded. The caller in } catch (ProductException $e) { ...
} catch (Exception $e) { // Magento\Framework\Validator\Exception, imported line 27
$this->setUserValue(null); // swallowed, purchase continues
} catch (LocalizedException $e) {
$this->setIsValid(false);
throw new LocalizedException(...); // rethrown, add-to-cart fails
}
Fixed by checking whether a file is genuinely present before denying, using core's own file key (
Undeterminable cases (unexpected argument shape, or the upload adapter throwing) resolve to "no upload" and defer to core. Blocking a legitimate purchase is the worse failure, and a real upload is still refused by the controller guards on the way in and by 2. Cached GraphQL bypassing the kill switch — confirmed, fixedConfirmed, and the sortOrder detail is the opposite of what the plugin list's own sort function suggests, so here is how I pinned it down.
<type name="Magento\Framework\App\FrontControllerInterface">
<plugin name="graphql-dispatch-plugin" type="Magento\GraphQlCache\Controller\Plugin\GraphQl"/>
<plugin name="front-controller-builtin-cache" type="Magento\PageCache\Model\App\FrontController\BuiltinPlugin"/>
<plugin name="front-controller-varnish-cache" type="Magento\PageCache\Model\App\FrontController\VarnishPlugin"/>
</type>
$result = $this->kernel->load();
if ($result === false) {
$result = $proceed($request);
...
} else {
$this->addDebugHeader($result, 'X-Magento-Cache-Debug', 'HIT', true);
}
return $result;On the ordering: The guard now sits on One limit I could not engineer away, now documented in the README: a response already cached at Varnish or Fastly is served before PHP is reached, so no PHP-layer guard can refuse it. Switching GraphQL off on a CDN-fronted site needs a CDN purge too. 3. REST detection rejecting ordinary text options — confirmed, fixedConfirmed by inspection — this one was my bug, plainly. Detection now requires the markers to be keys of a map: an array, or the JSON encoding of one. A text option's value decodes to I stayed with structured metadata rather than looking up the actual option type, to avoid a product-option load on every cart write; Verification
|
|
Heads-up: this PR merged (db15182, 11:01:45Z) carrying up to e7c73b5, while a fourth finding was being fixed. That fix was pushed to What [P1] With This does not affect any site today, because every switch defaults to allow and the module installs inert. It would bite the first site that switches #2 is a single cherry-picked commit on My apologies for the mis-sequencing here — I read the frozen head SHA on this PR as GitHub lag and spent a couple of minutes polling it before checking the PR state and finding it had been merged. |
Implements the SurfaceGuard build spec. An
env.php-only hardening module that lets an operator switch off the public, unauthenticated file-upload entry points a store does not use.Why
The StyleSmuggler campaign (CVE-2026-75650, 835 deduplicated attempts across the fleet, first probe ~12 days before public disclosure) staged a GIF+PHP polyglot through the guest custom-option upload path and then named that file as the source for an arbitrary-instantiation gadget. The upload half of that chain does not depend on the CVE — it is ordinary Magento behaviour on an endpoint most of our stores never use.
The root cause of the upload is a blacklist: when a custom option declares no
file_extension,module-catalog/etc/config.xmlfalls back to forbiddingphp,exeonly, so.phtml,.phar,.phtand.shtmlpass. This module does not try to validate its way out of that — a tightened whitelist is still a guess. Each entry point gets an explicit on/off instead, and off means refused.Configuration contract
One top-level
hardenkey inapp/etc/env.php, read throughDeploymentConfigonly. No admin UI, no database, so nothing a compromised admin session can reach will flip a switch.Fail-safe: only a strict boolean
falsedenies. An absent key,null,true,0,'0','false',''and an unreadableenv.phpall resolve to core behaviour. The module installs inert and can be deployed fleet-wide before any per-site decision is made.Enforcement
Checkout\Controller\Cart\Add::executeQuote\Api\CartItemRepositoryInterface::saveWebapi\Exception403, only when the item carries a file optionCustomer\Controller\Address\File\Upload::executeCustomer\Model\FileUploaderFactory::createLocalizedException, entity type picks the switchGraphQl\Controller\GraphQl::dispatchValidatorFile::validate+ValidatorInfo::validateLocalizedExceptionbeforeUploader::saveThe two catalog validators are the guarantee behind "off means neutered". Every custom-option file upload crosses one of them before the file is moved into
pub/media, so a future resolver or an entry point nobody has enumerated still dies there.Denials write one line to a dedicated
var/log/surfaceguard.log— endpoint, switch, client IP. No filename, no request body, no headers: a log carrying attacker-supplied content is its own liability.Deviations from the spec
quickorder_filedropped from v1. Its controller exists only on B2B installs, and a plugin naming a missing class failssetup:di:compileon every non-B2B site. Shipping the key without enforcement would be dead config. If wanted, it belongs in a companion module that hard-depends onMagento_QuickOrder.FileUploaderFactory, notFileProcessor. The spec assumedFileProcessorexposes its entity type; it does not —$entityTypeCodeis private with no getter.FileUploaderFactory::create()receives it as a plugin argument, and all four upload controllers construct through it, including the CommerceAbstractUploadFile. So one open-source seam covers the Commerce routes without this module ever naming a Commerce-only class.FileUploaderis built only to upload, never to read, so this does not disturb rendering of files already on disk.customer/address_file/upload. The factory guard throws aLocalizedException, which those controllers catch and turn into a JSON error envelope at HTTP 200. A controller guard was added so the endpoint named in the surface audit returns a true flat 403. The Commerce custom-attribute routes still answer with their JSON error envelope rather than a bare 403 — noted here and in the README.webapi_rest. So a storefront add-to-cart is answered by its own guard rather than this one.quote_path/order_path/secret_key, the keysValidatorInfoitself looks for) rather than loading product option metadata on every cart write. TheValidatorInfobackstop is what guarantees the denial if a payload shape ever slips past the markers.Documented consequence
The catalog backstop is layer-agnostic, so with either custom-option switch off the admin-side custom-option file flow is denied too. Intentional — a store that has switched the feature off is not selling file-option products — but stated in the README so nobody debugs it as a bug.
graphql.enabled = falseis blunt and kills every headless/PWA call. Core GraphQL cart mutations take string-only option inputs and carry no file-write sink, so there is no upload variant to deny selectively.Verification
module.xsd,ObjectManager/config.xsd, resolved through an XML catalog).php -lclean on every file; no double hyphens in XML comments.phpcs --standard=Magento2was not run — not installed in the available vendor tree.setup:di:compilehas not been run against a live install; worth doing on staging before merge, particularly for the plugin on the generatedFileUploaderFactory.Not in scope
Not a whitelist, not incident response, not a substitute for the VULN-39341 hotfix, and no detection — that lives in the mjolnir IOC sweep (DeployEcommerce/mjolnir#125).