Skip to content

fix: treat empty file inputs as no upload on add-to-cart - #2

Merged
ssx merged 1 commit into
mainfrom
hotfix/empty-file-input-denial
Sep 11, 2026
Merged

ssx merged 1 commit into
mainfrom
hotfix/empty-file-input-denial

Conversation

@ssx

@ssx ssx commented Sep 11, 2026

Copy link
Copy Markdown
Member

Follow-up to #1, which merged before this fix was pushed. The branch commit landed on feature/surfaceguard-v1 after the merge, so this fix is not on mainmain currently carries the bug described below.

The bug

[P1] With cart_add_file switched off, an ordinary purchase returns 403 whenever the product has an optional file option the customer left empty.

The controller guard counted entries in the request's file collection and read a non-empty collection as proof of an upload. It is not:

  • PHP puts every file input on a submitted multipart form into $_FILES, including ones left empty, with error = UPLOAD_ERR_NO_FILE and size = 0.

  • Laminas' mapPhpFiles() (laminas-http/src/PhpEnvironment/Request.php:408) copies every $_FILES entry through to the request's file parameters. Nothing filters the empty ones — I grepped the framework and module-catalog for UPLOAD_ERR_NO_FILE and there is no such filtering anywhere.

  • The product view form sets enctype="multipart/form-data" whenever the product has any option, not only a file one:

    <form ... <?php if ($_product->getOptions()) :?> enctype="multipart/form-data"<?php endif; ?>>

    (module-catalog/view/frontend/templates/product/view/form.phtml:21)

So the 403 fired on a normal add-to-cart, and it fired at the controller — before the backstop's no-upload handling from #1 could run. This is the same bug class as finding 1 on that PR, surviving one layer up.

The fix

The guard now walks the mapped entries and counts one only when it carries a real upload: an entry whose error is anything other than UPLOAD_ERR_NO_FILE. A file PHP rejected for exceeding the size limit still counts, because a file was still sent and refusing it is the intended behaviour. The walk recurses, because Laminas nests entries when an input name carries brackets (mapPhpFileParam).

Audit of the other guards

Checked each one for the same assumption:

Guard Verdict
DenyCartAddFile The bug. Fixed here.
DenyCustomerFileUpload Unaffected. Both the open-source Address\File\Upload and the Commerce AbstractUploadFile check empty($requestedFiles) before constructing the uploader, and neither sits on a purchase path.
DenyCartItemFileOption Unaffected. Reads option structure, never a files collection.
DenyFileOptionBackstop Already correct. Uses core's isUploaded(), which returns false when empty($file['name']) — exactly the empty-input case.

Verification

  • 43 tests, 73 assertions, all green against the real EE 2.4.8-p5 tree (was 36/59).
  • New coverage: four shapes that must not count as uploads — a single empty optional option, two empty options, a nested bracketed input, and a deeply nested one — and four that must: a flat upload, a nested upload, a mixed submission where one option is empty and another carries a file, and a file rejected by PHP for size.
  • All five XML files validate against the framework XSDs; php -l clean.
  • Unchanged from feat: SurfaceGuard — env.php kill switches for the unauthenticated upload entry points #1: phpcs --standard=Magento2 not run (not installed in the available vendor tree), and setup:di:compile still not run against a live install.

The controller guard counted entries in the request's file collection and read a
non-empty collection as proof that something had been uploaded. It is not. PHP puts
every file input on a submitted multipart form into $_FILES, including ones the
customer left empty, with error UPLOAD_ERR_NO_FILE and a zero size, and Laminas'
mapPhpFiles keeps all of them when it converts the superglobal — nothing filters
them out.

The product view form carries enctype="multipart/form-data" whenever the product has
any option at all, not only a file one (module-catalog form.phtml). So with
cart_add_file off, any product offering an optional file option returned 403 on an
ordinary purchase where the customer simply left the upload empty. The 403 was
returned before the backstop could apply its own no-upload handling, so the earlier
fix one layer down did not help here.

The guard now walks the mapped entries and counts one only when it carries a real
upload: an entry whose error is anything other than UPLOAD_ERR_NO_FILE. A file PHP
rejected for exceeding the size limit still counts, because a file was still sent.
The walk recurses, since Laminas nests entries when an input name carries brackets.

Audited the other guards for the same assumption. The customer upload guard is
unaffected: both the open-source and Commerce controllers check for an empty file
collection before constructing the uploader, and neither sits on a purchase path.
The REST guard reads option structure rather than files. The backstop uses core's
own isUploaded(), which rejects an entry with an empty name, so it was already
correct.

43 tests, 73 assertions, all green against the 2.4.8-p5 tree. New coverage: four
shapes that must not be treated as uploads, including nested and multi-option
submissions, and four that must, including a mixed submission and a file rejected
for size.

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@ssx
ssx merged commit c1ec403 into main Sep 11, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants