Skip to content

WW-5474 Count files only for maxFiles, add maxParameterCount#1806

Open
lukaszlenart wants to merge 9 commits into
mainfrom
WW-5474-multipart-maxfiles-semantics
Open

WW-5474 Count files only for maxFiles, add maxParameterCount#1806
lukaszlenart wants to merge 9 commits into
mainfrom
WW-5474-multipart-maxfiles-semantics

Conversation

@lukaszlenart

Copy link
Copy Markdown
Member

Fixes WW-5474

Problem

struts.multipart.maxFiles (default 256) is documented as a cap on the number of uploaded files, but it did not behave that way, and the two Jakarta parsers disagreed:

  • jakarta parser (default) passed the value to commons-fileupload2 setMaxFileCount, whose parseRequest counts every part — form fields and files. So maxFiles actually limited the total number of parameters, firing spuriously on forms with many normal fields and few (or zero) files. This is the reported bug.
  • jakarta-stream parser never received commons enforcement (its streaming iterator ignores maxFileCount) and counted distinct field names via its own exceedsMaxFiles, so multiple files under one field name collapsed to one.

Change

  • struts.multipart.maxFiles now counts file parts only, identically in both parsers.
  • New struts.multipart.maxParameterCount (default 256) caps non-file form fields, restoring the DoS guard the old accidental total-part cap incidentally provided. The two limits are orthogonal.
  • Breaching either limit is fail-closed: the request is rejected with a recorded upload error and the action receives no partial parameters or files.
  • A negative value (-1) or unset limit means unlimited, following the commons-fileupload2 convention.
  • The jakarta parser keeps a coarse total-parts backstop (maxFiles + maxParameterCount, only when both are finite) so gross floods abort early inside commons.

Design: docs/superpowers/specs/2026-07-22-WW-5474-multipart-maxfiles-semantics-design.md

Behavior changes to be aware of (please review)

  1. jakarta-stream gains a parameter-count cap it never had. A jakarta-stream app legitimately posting more than 256 form fields will now fail at field 257 unless it raises struts.multipart.maxParameterCount. The default jakarta parser is strictly more permissive than before (previously fields+files shared one 256 budget; now 256 each).
  2. Fail-closed now discards partial data on maxSize/maxFileSize breaches too (not only the new limits). Previously the stream parser could expose parts collected before a size breach; now a rejected request exposes nothing. This is intentional hardening.
  3. Gross-flood message differs by parser (spec §6, accepted tradeoff): an all-fields flood exceeding the combined 512 total on the jakarta parser surfaces the generic FileUploadFileCountLimitException via the commons backstop, while the stream parser reports the precise FileUploadParameterCountLimitException. Both fail closed.

Tests

New/updated unit tests in JakartaMultiPartRequestTest, JakartaStreamMultiPartRequestTest, AbstractMultiPartRequestTest cover: many fields + few files accepted (the reported regression), over-maxFiles and over-maxParameterCount fail-closed, multiple files under one field name counted individually, and maxFiles=-1 not clamped by the backstop. Full core module suite green (3003 tests); ActionFileUploadInterceptorTest regression green.

🤖 Generated with Claude Code

lukaszlenart and others added 6 commits July 22, 2026 14:56
…rCount

Spec for correcting struts.multipart.maxFiles to count file parts only
(consistently across the jakarta and jakarta-stream parsers) and adding
struts.multipart.maxParameterCount to cap non-file form fields, restoring
the DoS guard the old accidental total-part cap provided. Fail-closed on
breach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…rCount

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…erCount (jakarta)

The jakarta parser passed maxFiles to commons-fileupload2 setMaxFileCount,
which counts every part (fields + files), so maxFiles wrongly limited total
parameters. Enforce a files-only count and non-file field count in Struts,
failing closed on breach; keep a total-parts commons backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ckstop

prepareServletFileUpload applied the total-parts backstop whenever both
maxFiles and maxParameterCount were non-null, without checking for the
-1 "unlimited" sentinel already honored by enforceMaxFiles/enforceMaxParameterCount.
With maxFiles=-1 and maxParameterCount=256, maxParts computed to 255 and
was passed to commons-fileupload2's setMaxFileCount (which counts ALL
parts), wrongly rejecting large file-only uploads. Only apply the
backstop when both limits are finite (non-null and >= 0).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… to stream parser

Replace the field-name-based exceedsMaxFiles with the shared files-only
enforcement and add parameter-count enforcement, matching the jakarta parser
and failing closed on breach.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…k on fail-closed breach

servletFileUpload.parseRequest() fully materializes every part - spilling
large ones to disk - before processUpload() iterates over the result. The
loop only added each DiskFileItem to diskFileItems as it was reached, so
when enforceMaxFiles/enforceMaxParameterCount threw mid-loop on a breach,
every item positioned after the breaching one was never registered for
cleanup. With no FileCleaningTracker on the factory, cleanUp() had no way
to reclaim those temp files, leaking disk space on the hardening path.

Materialize the parsed list once and register all items for cleanup
before processing so cleanUp() reclaims every temp file regardless of
where enforcement aborts.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… S2629)

Wrap the LOG.debug calls in enforceMaxFiles/enforceMaxParameterCount with
isDebugEnabled() so normalizeSpace() is not evaluated when debug is disabled,
matching the exceedsMaxStringLength pattern in the same class.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

This PR fixes long-standing inconsistencies in multipart upload limiting for the Jakarta multipart parsers by making struts.multipart.maxFiles count file parts only, adding a new non-file field cap (struts.multipart.maxParameterCount), and enforcing fail-closed behavior with updated message wiring and tests.

Changes:

  • Align maxFiles semantics across jakarta and jakarta-stream parsers to count only actual uploaded files, not form fields.
  • Introduce struts.multipart.maxParameterCount (default 256) to cap non-file form fields and preserve DoS protection.
  • Harden failure handling to clear collected parameters/files on upload-limit exceptions, and expand unit test coverage for the new semantics.

Reviewed changes

Copilot reviewed 12 out of 12 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
docs/superpowers/specs/2026-07-22-WW-5474-multipart-maxfiles-semantics-design.md New design spec documenting the corrected semantics and fail-closed behavior.
docs/superpowers/plans/2026-07-22-WW-5474-multipart-maxfiles-semantics.md New implementation plan describing tasks, wiring, and test expectations.
core/src/main/java/org/apache/struts2/StrutsConstants.java Adds the new STRUTS_MULTIPART_MAX_PARAMETER_COUNT constant.
core/src/main/resources/org/apache/struts2/default.properties Updates maxFiles comment and adds default maxParameterCount=256.
core/src/main/resources/org/apache/struts2/struts-messages.properties Adds localized message key for FileUploadParameterCountLimitException.
core/src/main/java/org/apache/struts2/dispatcher/multipart/FileUploadParameterCountLimitException.java New exception type for parameter-count limit breaches.
core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java Adds maxParameterCount injection, shared enforcement helpers, commons backstop logic, and fail-closed clearing.
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequest.java Reworks counting to separate file vs parameter parts and ensures disk items are registered for cleanup up-front.
core/src/main/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequest.java Removes field-name-based counting and replaces it with true per-part counters plus shared enforcement calls.
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaMultiPartRequestTest.java Adds regression and limit tests, plus cleanup/backstop coverage.
core/src/test/java/org/apache/struts2/dispatcher/multipart/JakartaStreamMultiPartRequestTest.java Updates fail-closed expectations and adds tests for the new counting rules and parameter cap.
core/src/test/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequestTest.java Adds a unit test verifying the new setter stores the configured value.
Comments suppressed due to low confidence (1)

core/src/main/java/org/apache/struts2/dispatcher/multipart/AbstractMultiPartRequest.java:387

  • clearCollectedData() is called for FileUploadException, but not for the generic IOException path. If an IOException occurs after some fields/files were already collected (e.g., read error mid-request), the action could still see partial data even though an upload error was recorded. If the intent is broadly fail-closed on parse aborts, clear collected data in the IOException catch as well.
        } catch (IOException e) {
            LOG.warn("Unable to parse request", e);
            LocalizedMessage errorMessage = buildErrorMessage(e.getClass(), e.getMessage(), new Object[]{});
            addErrorIfAbsent(errorMessage);
        }

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

…flow guard

- JakartaMultiPartRequest: only count/enforce a file part toward maxFiles when it
  has a non-null field name, matching JakartaStreamMultiPartRequest's accept criteria
  (defensive: commons-fileupload2 already drops parts without a name attribute before
  parseRequest returns, so the two parsers stay consistent regardless).
- AbstractMultiPartRequest: compute the total-parts backstop with Math.addExact and
  clamp to Long.MAX_VALUE on overflow, so extremely large configured limits cannot
  wrap negative and silently disable the commons backstop.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Copilot reviewed 12 out of 12 changed files in this pull request and generated 3 comments.

…ields

Move the debug log into the isFormField branch so file parts are not
mislabelled; the file branch already logs "Processing a file".

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sonarqubecloud

Copy link
Copy Markdown

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