File Upload and Download

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) running on Apache Tomcat 11.0.26 and Java 21, the combination on which every example was built, deployed and requested with curl on 2026-10-08 and again on 2026-10-10. The facts were checked against the struts-default.xml, default.properties and struts-messages.properties files inside the 7.4.0 struts2-core jar, the Javadoc of the classes involved and the Core Developer Guide pages on https://struts.apache.org/core-developers/. File upload is the feature of Struts with the worst security history: the bulletins S2-066 and S2-067 (CVE-2023-50164 and CVE-2024-53677), both remote code execution through path traversal in the upload parameters, are the reason the old fileUpload interceptor was replaced in 6.4 by actionFileUpload and removed from the distribution in 7.0. The running scenario is the Bookshelf, a catalogue of books exposed as Struts actions. This page lets a librarian upload a cover image for a book (uploadCover), limits what may be uploaded, stores the bytes outside the web application under a generated name, serves the cover back with the stream result (cover) and exports the catalogue as a CSV file (export). The neighbouring pages are the value stack, parameter injection, validation and localization.

Upload and Download in One Page

Uploading and downloading are the two directions of the same problem: bytes that do not fit in a text parameter cross the boundary between the browser and the action. They use different parts of the framework.

Table 1. The pieces of upload and download in Struts 7.4.0
Direction Piece Role

Upload

HTML form with enctype="multipart/form-data" and <input type="file"> (the s:file tag)

Makes the browser send a multipart/form-data request instead of an URL encoded one

Upload

The multipart parser (struts.multipart.parser, jakarta by default)

Parses the request body with Commons FileUpload 2, enforces the request level limits and keeps small parts in memory, large parts in a temporary file

Upload

MultiPartRequestWrapper

Wraps the servlet request so that normal fields appear as parameters and file parts are kept separately

Upload

The actionFileUpload interceptor

Applies the per-action rules (size, content type, extension), turns problems into field errors and hands the accepted files to the action

Upload

UploadedFilesAware and UploadedFile

The callback withUploadedFiles(List<UploadedFile>) and the object that describes each upload

Download

An InputStream getter on the action

The bytes to send, produced per request

Download

The stream result (StreamResult)

Copies the stream to the response and sets the content type, length, disposition and cache headers

Download

The httpheader result

Answers a status code (404 for an unknown cover) without a body

The two diagrams below show the same flow. The first follows one upload from the browser to the action, with every point at which the request can be refused; the second follows the order of the work in time.

flowchart TD B[Browser sends POST multipart/form-data] --> F[StrutsPrepareAndExecuteFilter] F --> V{Content type matches validationRegex and method is POST} V -- no --> N[Ordinary request: no files] V -- yes --> P[Multipart parser: jakarta] P --> L1{maxSize, maxFiles, maxParameterCount, maxStringLength, maxFileSize} L1 -- exceeded --> E1[Fail closed: no parameters and no files, upload error recorded] L1 -- within limits --> S[Parts kept in memory or in temp files] S --> I[actionFileUpload interceptor] I --> C{maximumSize, allowedTypes, allowedExtensions} C -- rejected --> E2[Field error added, file not passed to the action] C -- accepted --> A[withUploadedFiles on the action] A --> X[Action reads getInputStream and stores the bytes] E1 --> W[workflow interceptor returns input result] E2 --> W X --> R[success result]
sequenceDiagram participant Br as Browser participant Fi as Filter and parser participant In as actionFileUpload participant Ac as CoverUploadAction participant St as CoverStore on disk Br->>Fi: POST /uploadCover (multipart: bookId and cover) Fi->>Fi: enforce request limits, build UploadedFile list Fi->>In: invocation with the wrapped request In->>In: check size, content type and extension In->>Ac: withUploadedFiles(list) Ac->>Ac: check magic bytes of the content Ac->>St: save under a generated name outside the webapp Ac-->>Br: success result with the saved cover Br->>Ac: GET /cover?id=... (stream result) Ac-->>Br: bytes with Content-Disposition and Content-Type

The Bookshelf Cover Upload

The scenario needs one Maven dependency, because the multipart parser and Commons FileUpload 2 are part of struts2-core; the struts2-bom supplies the version, so no version is written next to the artifact.

<dependencyManagement>
    <dependencies>
        <dependency>
            <groupId>org.apache.struts</groupId>
            <artifactId>struts2-bom</artifactId>
            <version>7.4.0</version>
            <type>pom</type>
            <scope>import</scope>
        </dependency>
    </dependencies>
</dependencyManagement>

<dependencies>
    <dependency>
        <groupId>org.apache.struts</groupId>
        <artifactId>struts2-core</artifactId>
    </dependency>
</dependencies>

The form is a normal Struts form with two rules: the method is post and the encoding is multipart/form-data. The s:file tag renders the <input type="file"> and its accept attribute is passed through to the browser as a hint (the server does not trust it, see Allowed Types and Extensions).

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html><body>
<h1>Bookshelf covers</h1>
<s:actionerror/><s:fielderror/>
<s:form action="uploadCover" method="post" enctype="multipart/form-data">
  <s:textfield name="bookId" label="Book id"/>
  <s:file name="cover" label="Cover (PNG or JPEG)" accept="image/png,image/jpeg"/>
  <s:submit value="Upload"/>
</s:form>
</body></html>

The rendered form (Struts adds the .action extension and the session id to the target) shows the two attributes that matter:

<form id="uploadCover" name="uploadCover" action="/g9upload/uploadCover.action;jsessionid=A4B0..." method="post" enctype="multipart/form-data">
    <td class="tdInput"><input type="file" name="cover" accept="image/png,image/jpeg" id="uploadCover_cover"/></td>

The mapping uses the actionFileUpload interceptor with three parameters and the standard stacks around it. The interceptor is placed first and basicStack follows, the arrangement of the official examples, and validation and workflow come after so that a field error produced by the upload turns into the input result.

<struts>
    <constant name="struts.allowlist.packageNames" value="org.example.bookshelf"/>
    <constant name="struts.multipart.maxSize" value="1048576"/>
    <constant name="struts.multipart.maxFileSize" value="524288"/>
    <constant name="struts.multipart.maxFiles" value="5"/>
    <constant name="struts.multipart.maxParameterCount" value="20"/>
    <constant name="struts.multipart.maxStringLength" value="256"/>

    <package name="default" extends="struts-default" namespace="/">
        <global-allowed-methods>execute,form</global-allowed-methods>

        <action name="covers" class="org.example.bookshelf.CoverUploadAction" method="form">
            <result name="input">/WEB-INF/views/covers.jsp</result>
        </action>

        <action name="uploadCover" class="org.example.bookshelf.CoverUploadAction">
            <interceptor-ref name="actionFileUpload">
                <param name="maximumSize">200000</param>
                <param name="allowedTypes">image/png,image/jpeg</param>
                <param name="allowedExtensions">.png,.jpg,.jpeg</param>
            </interceptor-ref>
            <interceptor-ref name="basicStack"/>
            <interceptor-ref name="validation"/>
            <interceptor-ref name="workflow"/>
            <result name="success">/WEB-INF/views/covers.jsp</result>
            <result name="input">/WEB-INF/views/covers.jsp</result>
        </action>
    </package>
</struts>

Two lines of the file are not about upload. struts.allowlist.packageNames is the Struts 7 OGNL allowlist: the JSP reads saved.id from a record in the org.example.bookshelf package, and without the constant the view prints nothing for objects of a package that is not allowlisted. global-allowed-methods lists form, the second entry point of the action, because Struts 7 only allows the methods that are listed (execute is allowed by default).

The action implements UploadedFilesAware, the interface through which the interceptor hands the files over. The action reads the first file, checks the first bytes of the content (a PNG starts with the eight bytes 89 50 4E 47 0D 0A 1A 0A, a JPEG with FF D8 FF), and only then stores it.

package org.example.bookshelf;

import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Path;
import java.util.List;
import org.apache.struts2.ActionSupport;
import org.apache.struts2.action.UploadedFilesAware;
import org.apache.struts2.dispatcher.multipart.UploadedFile;
import org.apache.struts2.inject.Inject;
import org.apache.struts2.interceptor.parameter.StrutsParameter;

public class CoverUploadAction extends ActionSupport implements UploadedFilesAware {
    private String dir = System.getProperty("java.io.tmpdir") + "/g9upload-covers";
    private long bookId = 1;
    private List<UploadedFile> files = List.of();
    private CoverStore.Cover saved;

    @Inject(value = "bookshelf.covers.dir", required = false)
    public void setDir(String dir) { this.dir = dir; }

    @StrutsParameter
    public void setBookId(long bookId) { this.bookId = bookId; }

    public long getBookId() { return bookId; }

    public CoverStore.Cover getSaved() { return saved; }

    public List<CoverStore.Cover> getCovers() { return CoverStore.all(); }

    @Override
    public void withUploadedFiles(List<UploadedFile> uploadedFiles) {
        this.files = uploadedFiles;
    }

    public String form() { return INPUT; }

    @Override
    public String execute() throws IOException {
        if (files.isEmpty()) {
            addActionError("Choose a cover image first.");
            return INPUT;
        }
        UploadedFile f = files.get(0);
        try (InputStream in = f.getInputStream()) {
            byte[] head = in.readNBytes(8);
            if (!looksLikeImage(head)) {
                addActionError("The content of " + f.getInputName() + " is not a PNG or JPEG image.");
                return INPUT;
            }
        }
        String ext = f.getContentType().equals("image/png") ? ".png" : ".jpg";
        try (InputStream in = f.getInputStream()) {
            saved = CoverStore.save(Path.of(dir), bookId, f.getOriginalName(), f.getContentType(), in, ext);
        }
        return SUCCESS;
    }

    private static boolean looksLikeImage(byte[] h) {
        boolean png = h.length >= 8 && (h[0] & 0xFF) == 0x89 && h[1] == 'P' && h[2] == 'N' && h[3] == 'G';
        boolean jpg = h.length >= 3 && (h[0] & 0xFF) == 0xFF && (h[1] & 0xFF) == 0xD8 && (h[2] & 0xFF) == 0xFF;
        return png || jpg;
    }
}

A real upload with curl (-F builds a multipart/form-data request) and what the page reports:

curl -s -F bookId=7 -F "cover=@dune.png;type=image/png" http://localhost:18080/g9upload/uploadCover | grep saved
<p id="saved">Saved 8ca50ba2-e1fc-4b87-acef-dc2422012d0c (69 bytes, image/png) as dune.png</p>

The stored file is 8ca50ba2-e1fc-4b87-acef-dc2422012d0c.png in the cover directory: the name was generated by the server, the client’s name dune.png survives only as text in the page.

bookId is a long, not a String, because it ends up in the Content-Disposition header of the download (see Downloading with the stream Result). Type conversion is the validation: a value that is not a number never reaches the action, the conversionError interceptor of basicStack adds a field error and workflow answers the input result, so nothing is stored. With a String property, the value 7";filename*=UTF-8''evil.html;x=" was stored as it was and the download answered Content-Disposition: attachment;filename="cover-7";filename*=UTF-8''evil.html;x=".png", a header whose file name the uploader chose (observed before the change). After the change the values 7";x="y, 7;filename=evil.html and abc were all refused, and the list of covers still held only the valid upload:

for v in '7";x="y' '7;filename=evil.html' abc; do
  curl -s --form-string "bookId=$v" -F "cover=@dune.png;type=image/png" \
    http://localhost:18080/g9upload/uploadCover | grep -m1 -o 'Invalid field value[^<]*'
done
Invalid field value for field &quot;bookId&quot;.
Invalid field value for field &quot;bookId&quot;.
Invalid field value for field &quot;bookId&quot;.

What the Form and the Request Must Look Like

The parser acts only on a request that is a POST and whose Content-Type matches struts.multipart.validationRegex, by default ^multipart/form-data(?:\s*;\s*boundary=...)?... (a multipart/form-data media type with an optional boundary and charset). Anything else is an ordinary request and the file parts never arrive. The classic mistake is a form without enctype: the browser then sends only the file name as a text field.

curl -s -d "bookId=1&cover=dune.png" http://localhost:18080/g9upload/uploadCover | grep -A1 errorMessage
<ul class="errorMessage">
  <li><span>Choose a cover image first.</span></li></ul>

The action sees no file at all (an empty list, not an error from Struts), so the application has to handle the empty case; the action above does it with an action error. Struts disables the whole mechanism with <constant name="struts.multipart.enabled" value="false"/>, after which it ignores the Content-Type header and treats every request as ordinary (documented, not run here).

UploadedFilesAware and UploadedFile

org.apache.struts2.action.UploadedFilesAware is the Struts 6.4 and later way to receive uploads. It has one method, withUploadedFiles(List<UploadedFile>), called by the interceptor once per request with every accepted file part. The interceptor sets the files through this callback, not through setters named after the form field, which is the design change that removed the attack surface of S2-066 and S2-067 (see The Security History). The list contains one entry for each file part, whatever its field name, so an action that expects one file reads get(0) after checking isEmpty().

Table 2. The methods of org.apache.struts2.dispatcher.multipart.UploadedFile (read with javap from struts2-core 7.4.0)
Method Meaning

Long length()

Size of the content in bytes

String getName()

Name of the file on the server (the temporary file, not what the user chose)

String getOriginalName()

The file name sent by the browser; user controlled text, never use it as a path

String getContentType()

The Content-Type of the part as declared by the client; also user controlled

String getInputName()

The name of the form field (cover above)

boolean isFile()

Whether the content is a file on disk

Object getContent()

The java.io.File of the temporary file; materialises it if the content is still in memory

String getAbsolutePath()

Path of the temporary file; materialises it as well

boolean delete()

Deletes the temporary file

InputStream getInputStream()

Default method, since 7.3.0: reads the content without forcing it to disk; the preferred way

boolean isMissing()

Default method, since 7.3.0: true for a failed upload with no content

A tiny action that reports every file it receives shows the values for two uploads in one request (the line format is field|original name|content type|length|missing):

public class MultiUploadAction extends ActionSupport implements UploadedFilesAware {
    private final List<String> report = new ArrayList<>();

    @Override
    public void withUploadedFiles(List<UploadedFile> uploadedFiles) {
        for (UploadedFile f : uploadedFiles) {
            report.add(f.getInputName() + "|" + f.getOriginalName() + "|" + f.getContentType() + "|" + f.length() + "|" + f.isMissing());
        }
    }

    public List<String> getReport() { return report; }

    @Override
    public String execute() { return SUCCESS; }
}
curl -s -F "u=@dune.png;type=image/png" -F "u=@cover.jpg;type=image/jpeg" http://localhost:18080/g9upload/uploadMany
[u|dune.png|image/png|69|false]
[u|cover.jpg|image/jpeg|104|false]
done

Reading the content: stream first, file only when needed

Since 7.3.0 a small upload (below the parser’s disk-spill threshold, around 8 KB according to the official page) is kept in memory and its temporary file is written lazily, only when something asks for a java.io.File. Uploads that the interceptor rejects by size, type or extension therefore never touch the file system. The consequence for code is to read with getInputStream() and to avoid getContent() and getAbsolutePath() unless a library really needs a file:

public void withUploadedFiles(List<UploadedFile> uploadedFiles) {
    for (UploadedFile file : uploadedFiles) {
        try (InputStream in = file.getInputStream()) {
            // process the bytes; no temporary file is created for small uploads
        } catch (IOException e) {
            // handle
        }
    }
}

Several files: one name or several names

Several s:file fields with the same name (upload) produce several entries in the one list, which is what the official multiple-upload example uses. Fields with different names also end in the same list, and getInputName() tells them apart. In the old fileUpload style the two choices needed three setters per file or three arrays; with the new callback there is no such multiplication.

<s:form action="multipleFileUploadUsingList" method="POST" enctype="multipart/form-data">
    <s:file label="File (1)" name="upload"/>
    <s:file label="File (2)" name="upload"/>
    <s:file label="File (3)" name="upload"/>
    <s:submit/>
</s:form>

The actionFileUpload Interceptor

org.apache.struts2.interceptor.ActionFileUploadInterceptor is registered under the name actionFileUpload and is available since Struts 6.4.0 as the replacement for fileUpload. It is based on MultiPartRequestWrapper, which the framework applies to every request that carries a file, and, for an action that implements UploadedFilesAware, it passes the files through the callback. It adds field errors when the action implements ValidationAware (every ActionSupport does).

Where it sits in the stacks

struts-default.xml of 7.4.0 registers the interceptor and puts it in three stacks. In defaultStack it is the 11th of 24 interceptors, before params (17th) and validation; in paramsPrepareParamsStack it sits after modelDriven and before staticParams and the second params; and actionFileUploadStack is a two-entry stack, the interceptor followed by basicStack.

Table 3. Where actionFileUpload is used by the stacks of struts-default.xml 7.4.0
Stack Position of actionFileUpload

defaultStack

After modelDriven and before checkbox, datetime, multiselect, staticParams, actionMappingParams and params

paramsPrepareParamsStack

After modelDriven, before staticParams and the second params

actionFileUploadStack

First, followed by basicStack

any other stack (basicStack, emptyStack, validationWorkflowStack)

Not present: an action mapped with only basicStack receives no files

An action that uses the default stack therefore needs no interceptor-ref at all to receive uploads: it implements UploadedFilesAware and the default limits apply. The uploadMany mapping of the scratch application is exactly that (no interceptor-ref), and it received the two files above. Mappings that need their own limits do one of three things: list actionFileUpload with param children (as uploadCover does), override one parameter of the default stack, or use a custom stack.

Parameters

Table 4. The parameters of actionFileUpload
Parameter Default Meaning

maximumSize

about 2 MB

Largest size in bytes of one file that the interceptor lets reach the action; it is not related to the struts.multipart.* constants

allowedTypes

all types

Comma separated list of content types (image/png,image/jpeg)

allowedExtensions

all extensions

Comma separated list of extensions (.png,.jpg)

disabled

false

Switches the interceptor off for the mapping (the parameter that every interceptor with lazy parameters understands)

Overriding one parameter of the default stack without repeating the stack uses the stackName.interceptorName.param form, the same one that any stack parameter uses. The uploadTuned mapping accepts only PNG although the stack is the default one; a JPEG is refused:

<action name="uploadTuned" class="org.example.bookshelf.MultiUploadAction">
    <interceptor-ref name="defaultStack">
        <param name="actionFileUpload.allowedTypes">image/png</param>
    </interceptor-ref>
    <result>/WEB-INF/views/many.jsp</result>
    <result name="input">/WEB-INF/views/many.jsp</result>
</action>
curl -s -F "u=@cover.jpg;type=image/jpeg" http://localhost:18080/g9upload/uploadTuned
Content-Type not allowed: u "cover.jpg" "upload_29c37f2a_cab4_46d0_a92e_3cb049b2af7d.tmp" image/jpeg

Per-request rules with expressions

Since 7.2.0 the three parameters allowedTypes, allowedExtensions and maximumSize accept ${...} expressions that are evaluated against the value stack at the time of the upload (the interceptor implements WithLazyParams). The policy can therefore depend on the action: here ebooks may be PDFs up to 400000 bytes and every other upload is a PNG of at most 100 bytes.

/** Upload policy chosen per request: ebooks may be PDF up to 400 000 bytes, everything else PNG up to 100 bytes. */
public class PolicyUploadAction extends MultiUploadAction {
    private String kind;

    @StrutsParameter
    public void setKind(String kind) { this.kind = kind; }

    public String getAllowedContentTypes() { return "ebook".equals(kind) ? "application/pdf" : "image/png"; }

    public Long getMaxFileSize() { return "ebook".equals(kind) ? 400000L : 100L; }

    public String getKind() { return kind; }
}
<action name="uploadPolicy" class="org.example.bookshelf.PolicyUploadAction">
    <interceptor-ref name="basicStack"/>
    <interceptor-ref name="actionFileUpload">
        <param name="allowedTypes">${allowedContentTypes}</param>
        <param name="maximumSize">${maxFileSize}</param>
    </interceptor-ref>
    <result>/WEB-INF/views/many.jsp</result>
    <result name="input">/WEB-INF/views/many.jsp</result>
</action>

The order of basicStack and actionFileUpload matters, and the official page does not say so. The kind parameter is copied to the action by params, which is inside basicStack. With the stack first (above) the expression sees kind=ebook; with the upload interceptor first (the position it has in defaultStack) kind is still empty when the policy is evaluated. The same PDF, with and without the stack in front:

curl -s -F "u=@x.pdf;type=application/pdf" "http://localhost:18080/g9upload/uploadPolicy?kind=ebook"
curl -s -F "u=@x.pdf;type=application/pdf" "http://localhost:18080/g9upload/uploadPolicyEarly?kind=ebook"
[u|x.pdf|application/pdf|6|false]
done

Content-Type not allowed: u "x.pdf" "upload_9d375dd0_2d4c_43b4_a84e_3c74d64edc10.tmp" application/pdf

The first request is accepted (the stack ran first), the second rejected (uploadPolicyEarly lists the upload interceptor first). The practical rule: when an expression depends on a request parameter, put basicStack (or params) before actionFileUpload; when the policy depends only on session or application data, any order works.

Since 7.3.0 the resolved values live in a per-invocation org.apache.struts2.interceptor.UploadPolicy instead of on the shared interceptor instance, so two concurrent requests can no longer be validated against each other’s policy (WW-5659). The same release made an expression that cannot be resolved fail closed: the upload is rejected with struts.messages.error.upload.policy.unresolved rather than validated against a partial policy. A misspelled property name in a ${...} parameter is therefore a visible error and not a silent hole:

<interceptor-ref name="actionFileUpload">
    <param name="allowedTypes">${nope}</param>
</interceptor-ref>
The upload validation policy could not be resolved, rejecting the file: u "dune.png"; unresolved parameters: allowedTypes

Extending the interceptor

To change what counts as an acceptable file, subclass the interceptor and override acceptFile. Since 7.3.0 the method takes the per-invocation policy as its first argument; the old five-argument version is gone and the compiler points out the subclasses that still override it:

protected boolean acceptFile(UploadPolicy policy, Object action, UploadedFile file,
                             String originalFilename, String contentType, String inputName)

Read policy.getMaximumSize(), policy.getAllowedTypes() and policy.getAllowedExtensions() rather than the former interceptor fields. A third-party interceptor that implements WithLazyParams must supply a holder class that extends InterceptorParams (or DisableParams when it supports disabled) and move the resolved values into it.

Limits: Two Layers

A request is limited twice. The first layer belongs to the parser and applies before any interceptor runs, which makes it the defence against the requests that would exhaust the memory or the disk. The second layer belongs to the interceptor and expresses what one action is willing to accept. The page of the documentation says it plainly: the request level settings take precedence over the interceptor’s maximumSize.

Table 5. The struts.multipart constants (defaults from default.properties of 7.4.0)
Constant Default Limits

struts.multipart.parser

jakarta

Which parser reads the body (see Parsers and Other Constants)

struts.multipart.saveDir

empty

Where temporary files are written; empty means the servlet container’s temporary directory

struts.multipart.maxSize

2097152

Combined size of the whole request in bytes (about 2 MB); the sum of all files, not each file

struts.multipart.maxFileSize

not set

Size of each file in bytes; not enforced unless set

struts.multipart.maxFiles

256

Number of uploaded files per request (since 6.1.2; counts only files since 7.3.0)

struts.multipart.maxParameterCount

256

Number of normal (non-file) fields per request (since 7.3.0)

struts.multipart.maxStringLength

4096

Length of one normal string field in a multipart request (since 6.1.2.1)

Setting a limit to -1 makes it unlimited, which the official page warns against; always set a finite value for maxFiles. The values used by the scratch application are small on purpose, so that every limit can be reached with a tiny test file:

<constant name="struts.multipart.maxSize" value="1048576"/>
<constant name="struts.multipart.maxFileSize" value="524288"/>
<constant name="struts.multipart.maxFiles" value="5"/>
<constant name="struts.multipart.maxParameterCount" value="20"/>
<constant name="struts.multipart.maxStringLength" value="256"/>

Every limit, forced in turn with curl (all the responses have HTTP status 200 because the action’s input result is rendered):

Table 6. Observed behaviour at each limit on Struts 7.4.0 (the scratch limits above)
Request Limit reached Message shown

Two small files, one field

none

both files reach withUploadedFiles

One file of 600083 bytes

maxFileSize 524288

File big600k.png assigned to u exceeded allowed size limit! Max size allowed is: 524,288 but file was: 524,289!

Four files of 300083 bytes each

maxSize 1048576

Request exceeded allowed size limit! Max size allowed is: 1,048,576!

Six small files

maxFiles 5

Request exceeded allowed number of files! Permitted number of files is: 5!

21 normal fields and one file

maxParameterCount 20

Request exceeded allowed number of parameters! Permitted number of parameters is: 20!; the file does not arrive either

20 normal fields and one file

none (exactly at the limit)

accepted

One field of 300 characters

maxStringLength 256

The request parameter "p" was too long. Max length allowed is 256, but found 300!; the file part is still delivered

One file of 300083 bytes to uploadCover

maximumSize 200000 of the interceptor

The file is too large to be uploaded: cover "big300k.png" "upload_....tmp" has size 300083 and allowed mx size is 200,000

Three details of this table are worth knowing. The per-file message reports the limit plus one byte (524,289), not the real size, because the parser stops reading as soon as the limit is crossed. The size and count limits are fail closed: the official page states that on exceeding either of the two counts, parsing is aborted, an upload error is recorded and the action receives no parameters and no files, never a partial request (in the run above the file of the 21-field request did not arrive). And the message of the interceptor limit contains the temporary file name and the sentence "allowed mx size", a typo in struts-messages.properties 7.4.0 that is repeated on the page the user sees.

What the limits cannot do

The limits are about bytes and counts, not about the server in front of Struts. The servlet container has its own limits (Tomcat’s maxPostSize and maxParameterCount of the connector, maxSwallowSize for aborted uploads, and the @MultipartConfig limits of servlets that are not Struts), and a reverse proxy has its own body size limit (for example client_max_body_size of nginx). A limit that the proxy imposes shows up as a proxy error page, not as a Struts message. The Struts limits remain necessary because they are the last line of defence when the front layers are misconfigured or bypassed.

Error Messages and How to Customise Them

Rejections become field errors on the file field (and, in the Bookshelf page, the s:actionerror and s:fielderror tags print them). The text of each message is a key in the default struts-messages.properties and can be overridden by the application’s global resource bundle (struts.custom.i18n.resources) or the action’s own package.properties.

Table 7. The message keys of file upload (Struts 7.x; Struts 6.x uses the Commons FileUpload 1.x exception names)
Key When

struts.messages.error.uploading

General error: the file could not be uploaded

struts.messages.error.file.too.large

The file is larger than maximumSize of the interceptor

struts.messages.error.content.type.not.allowed

The content type is not in allowedTypes

struts.messages.error.file.extension.not.allowed

The extension is not in allowedExtensions

struts.messages.error.upload.policy.unresolved

A ${...} parameter could not be resolved (since 7.3.0)

struts.messages.upload.error.FileUploadSizeException

The whole request exceeds struts.multipart.maxSize

struts.messages.upload.error.FileUploadByteCountLimitException

A file exceeds struts.multipart.maxFileSize

struts.messages.upload.error.FileUploadFileCountLimitException

More files than struts.multipart.maxFiles

struts.messages.upload.error.FileUploadParameterCountLimitException

More normal fields than struts.multipart.maxParameterCount (since 7.3.0)

struts.messages.upload.error.parameter.too.long

A normal field longer than struts.multipart.maxStringLength

struts.messages.upload.error. followed by the simple name of any other exception

Any other exception while parsing, for example IllegalArgumentException for an empty file

An empty file input (a user presses Upload with no file chosen, or the file has 0 bytes) is rejected by the parser with File empty.png for field cover is empty (0 bytes). Empty file uploads are not allowed. (observed; the message is the IllegalArgumentException entry). An absent file part (the field is not sent at all) is not an error: the list is empty, which is why the action tests files.isEmpty().

For the Bookshelf the messages are better in the librarian’s language and without the temporary file name. The bundle below (documented placeholders; not run in the scratch application) replaces three of them; the placeholders are {0} the field name, {1} the original file name, {2} the temporary file name, {3} the actual size and {4} the allowed size (the numbering is documented in the comments of struts-messages.properties).

struts.messages.error.file.too.large=The cover {1} is too large ({3} bytes); the maximum is {4} bytes.
struts.messages.error.content.type.not.allowed=The cover {1} must be a PNG or a JPEG image.
struts.messages.error.file.extension.not.allowed=The cover {1} must end in .png, .jpg or .jpeg.

Allowed Types and Extensions

Struts offers two declarative filters (allowedTypes and allowedExtensions) and a programmatic one (the action inspects getContentType() and the content). They answer different questions and none of them, alone, is enough.

Table 8. What each check looks at, and what it can be fooled by
Check Looks at Weakness

allowedTypes

The Content-Type header of the part, written by the client

A client can send image/png for any bytes: fake.png (21 bytes of ASCII) with type=image/png passes this check

allowedExtensions

The suffix of the original file name, written by the client

A file named shell.jsp.png has the extension .png; a file named notes.txt is refused even with type=image/png

Content inspection in the action

The first bytes of the file (magic numbers), or a real decoder such as ImageIO

The strongest check, still only a heuristic: a valid image can carry a payload after the image data

The four test files behave like this against the uploadCover mapping (types and extensions listed, plus the magic byte check of the action):

File and declared type Interceptor Action

dune.png, image/png (a real 1 by 1 PNG)

accepted

accepted and stored as <uuid>.png

x.pdf, application/pdf

two errors: File extension not allowed and Content-Type not allowed

not reached

notes.txt, image/png

File extension not allowed

not reached

fake.png, image/png (text bytes)

accepted: the name and the type are fine

rejected: The content of cover is not a PNG or JPEG image.

shell.jsp.png, image/png (a real PNG with that name)

accepted: the extension is .png

stored as <uuid>.png; the name shell.jsp.png is never used for the file

DUNE.PNG (upper case extension)

accepted: the extension check ignores case

accepted

The lesson of the table is the layering: the interceptor refuses what is obviously outside the policy cheaply and early, the action verifies the content, and the store never uses the client’s name. The user can still upload a valid image that carries something else after the image data; images that are served to other users should be re-encoded (for example with ImageIO) when the application cannot tolerate that.

Storing Uploads Safely

An uploaded file is first written by the parser to a temporary location (the parts below the disk-spill threshold stay in memory since 7.3.0). The official page asks the action to process or move the file, because the temporary file does not outlive the request, and warns that a server security policy may forbid writing outside the temporary directory and the directories of the web application. The advice of this page for where to move it is the opposite of the habit of older tutorials, which saved into a folder of the web application:

  • Save outside the web application root (and outside any directory that the container serves). A file stored under the web root can be requested directly, and a file whose name ends in .jsp or .jspx will be compiled and run by Jasper. This is the mechanism behind the upload-to-RCE incidents, and the Tomcat bulletins CVE-2024-50379 and CVE-2024-56337 (a time-of-check time-of-use race in JSP compilation, exploitable when an uploaded file is treated as a JSP on a case-insensitive file system) show that even an extension filter on the Tomcat side is not a substitute for storing the bytes elsewhere.

  • Generate the stored name (a UUID plus an extension picked from a fixed table), and never use getOriginalName() or getContentType() as part of a path. Keep the original name as data, for the download header or a label, after sanitising it.

  • Resolve the target and check that it is below the intended directory (normalize() then startsWith()), which protects against a name that contains .. or an absolute path.

  • Authorise the upload and the download: who may attach a cover to which book is application logic that the file interceptors know nothing about.

  • Limit the total storage per user and the number of files, and delete the temporary file when the work is finished (UploadedFile.delete()).

  • Serve user uploads through an action (the stream result below) rather than by mapping a folder to a URL, so that the content type and the disposition are chosen by the application, not guessed from the file name.

The store of the Bookshelf follows these rules. It keeps the covers in a directory that is not part of the WAR, saves under a generated name, and refuses a target outside the directory:

public final class CoverStore {
    public record Cover(String id, long bookId, String originalName, String contentType, long size, Path path) {
        public String getId() { return id; }
        public long getBookId() { return bookId; }
        public String getOriginalName() { return originalName; }
        public String getContentType() { return contentType; }
        public long getSize() { return size; }
    }

    private static final Map<String, Cover> COVERS = new ConcurrentHashMap<>();

    public static Cover save(Path dir, long bookId, String originalName, String contentType, InputStream in, String ext) throws IOException {
        Files.createDirectories(dir);
        String id = UUID.randomUUID().toString();
        Path target = dir.resolve(id + ext).normalize();
        if (!target.startsWith(dir.normalize())) {
            throw new IOException("Refusing to write outside " + dir);
        }
        Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
        Cover cover = new Cover(id, bookId, originalName, contentType, Files.size(target), target);
        COVERS.put(id, cover);
        return cover;
    }

    public static Cover find(String id) { return id == null ? null : COVERS.get(id); }

    public static List<Cover> all() { return new ArrayList<>(COVERS.values()); }
}

The in-memory map stands in for a database table in the scratch application; a real application keeps the metadata in the database and the path or object key next to it. The file shell.jsp.png of the test run was stored as 00689104-d983-418a-a3fe-e96b9a7621f0.png, in a directory that Tomcat does not serve.

The temporary directory is struts.multipart.saveDir; when it is empty the container’s temporary directory is used. The official page adds that on some operating systems, Solaris is the one it names, /tmp is memory based, so a large upload would consume about the same amount of RAM; point saveDir at a disk directory with enough room when files are large.

The Security History: S2-066 and S2-067

Two critical bulletins explain the shape of the current feature, and each is one sentence in the official bulletin.

Table 9. The upload bulletins, as written on the Struts Security Bulletins pages
Bulletin CVE Affected Summary and fix

S2-066

CVE-2023-50164

Struts 2.0.0 to 2.3.37 (EOL), 2.5.0 to 2.5.32, 6.0.0 to 6.3.0

File upload logic is flawed and allows an attacker to enable paths with traversals; with some conditions, a malicious file can lead to remote code execution. Fixed in 2.5.33 and 6.3.0.2

S2-067

CVE-2024-53677

Struts 2.0.0 to 2.3.37 (EOL), 2.5.0 to 2.5.33 (EOL), 6.0.0 to 6.3.0.2

Similar problem to S2-066, relating to mixing setters for uploaded files with setters for normal fields. Applications that do not use FileUploadInterceptor are safe. The solution is to upgrade to 6.4.0 or later and migrate to the Action File Upload Interceptor, which is not backward compatible; keeping the old mechanism keeps the application vulnerable

Both CVE ids were confirmed on cve.org on 2026-10-08. Both bulletins say that an attacker can manipulate the file upload parameters to enable path traversal. The old design is the setting for that: the file, its name and its content type were ordinary properties of the action (upload, uploadFileName, uploadContentType) set by name from the same request that carried the data, next to the setters for normal fields, and S2-067 names the mixing of the two kinds of setters as the flaw. The new design removes those properties: the names and the content arrive through the callback, as UploadedFile objects created by the framework, and there is no setter named after the field for a request parameter to target. This page does not reproduce the attacks; the explanation of the mechanism is the reading of the bulletins, not a claim from the framework’s source.

The bulletins are the reason for the rules of this page, not only history: a Struts 6 application that still runs the old interceptor on 6.3.0.2 or earlier is vulnerable, and the migration to the callback is the supported fix. Other bulletins of the same period (S2-068 onwards) concern other areas and are listed on the security bulletins page of this section.

Source: CVE-2023-50164

Source: CVE-2024-53677

The Removed fileUpload Interceptor and How to Migrate

Before 6.4 the upload was received through three setters per file: setUpload(File), setUploadContentType(String) and setUploadFileName(String), populated by the fileUpload interceptor of defaultStack. Version 6.4.0 introduced the new interceptor and deprecated the old one, and in 7.0 it was removed together with the com.opensymphony.xwork2 package names. Struts 7.4.0 registers no fileUpload interceptor, so a mapping that names it makes the whole application fail at startup, as any other unknown interceptor does (the same behaviour that the interceptors page of this section documents for timer). The Bookshelf contains the old-style action only to show what happens if it is left in place:

/** The Struts 2.x style: File, xxxContentType and xxxFileName setters. */
public class LegacyUploadAction extends ActionSupport {
    private File upload;
    private String uploadContentType;
    private String uploadFileName;

    @StrutsParameter public void setUpload(File upload) { this.upload = upload; }
    @StrutsParameter public void setUploadContentType(String t) { this.uploadContentType = t; }
    @StrutsParameter public void setUploadFileName(String n) { this.uploadFileName = n; }

    public String getResult() {
        return "file=" + (upload == null ? "null" : upload.getName()) + " type=" + uploadContentType + " name=" + uploadFileName;
    }

    @Override
    public String execute() { return SUCCESS; }
}
curl -s -F "upload=@dune.png;type=image/png" http://localhost:18080/g9upload/legacy
file=null type=null name=null

The request succeeds (HTTP 200) and every property is null: there is no error and no file. This is the migration risk that matters, because an application that was upgraded without reading the migration notes simply stops receiving uploads, and the symptom is a form that "does nothing". The migration is mechanical:

Table 10. Migrating an upload action from the old to the new mechanism
Before (Struts 2.x to 6.3) After (Struts 6.4 and later)

setUpload(File), setUploadContentType(String), setUploadFileName(String) and their arrays or lists

A single withUploadedFiles(List<UploadedFile>) that implements UploadedFilesAware

<interceptor-ref name="fileUpload"> in a stack

<interceptor-ref name="actionFileUpload">, or just defaultStack

allowedTypes, allowedExtensions, maximumSize parameters of fileUpload

The same names on actionFileUpload

@StrutsParameter annotations on the upload setters (needed since 6.4 for parameter access)

None: files are not parameters

file.getName() for the temporary name, uploadFileName for the original name

getName() for the temporary name, getOriginalName() for the original name

com.opensymphony.xwork2.ActionSupport

org.apache.struts2.ActionSupport

Error keys named after the Commons FileUpload 1.x exceptions (SizeLimitExceededException)

Commons FileUpload 2 names since 7.0 (FileUploadSizeException)

Parsers and Other Constants

The parser is chosen with struts.multipart.parser. The default is jakarta; jakarta-stream (since 2.3.18) is, according to the official page, the implementation to use for handling large files. Struts 7.3.0 enforces the same limits in both. The cos parser was removed for licensing incompatibilities, and pell, which was a plugin, is not published for 7.x (the 7.4.0 struts2-bom does not list the plugin).

<constant name="struts.multipart.parser" value="jakarta-stream"/>
Table 11. The remaining multipart constants
Constant Purpose

struts.multipart.parser

Parser class or alias (jakarta, jakarta-stream)

struts.multipart.saveDir

Directory for temporary files

struts.multipart.validationRegex

Regular expression that the request’s content type must match to be treated as multipart (since 2.3.11)

struts.multipart.enabled

false switches the whole upload support off (since 2.3.11)

Downloading with the stream Result

The stream result (org.apache.struts2.result.StreamResult) sends raw data from an InputStream to the response. The action exposes the stream through a getter; the result is configured with the content type, the length and the disposition. It is the right tool for files that are not pages: a stored cover, a PDF, a generated archive or a CSV.

Table 12. The parameters of the stream result (from the Core Developer Guide and the 7.4.0 Javadoc)
Parameter Default Meaning

contentType

text/plain

The MIME type sent to the browser

contentLength

not set

Length of the stream in bytes; the browser shows a progress bar

contentDisposition

inline

The Content-Disposition header value; typically attachment;filename="document.pdf" to force a download

inputName

inputStream

Name of the InputStream property on the action

bufferSize

1024

Size of the buffer used to copy the stream to the response

allowCaching

true

When false it sets Pragma and Cache-Control to no-cache

contentCharSet

not set

When set, ;charset=value is added to the Content-Type header; may be an expression

The parameters can come from getters of the action, but since Struts 2.5.2 each one has to be written explicitly as an expression (${coverType}); before 2.5.2 the framework searched the value stack on its own, even for inputName. The official page states this change and it is the reason every parameter of the examples below is an expression when it comes from the action.

A download of one cover

The action finds the cover in the store and exposes the stream and the three values. A missing cover answers a result code, notFound, that the mapping turns into a bare HTTP 404 with the httpheader result.

public class CoverDownloadAction extends ActionSupport {
    private String id;
    private CoverStore.Cover cover;

    @StrutsParameter
    public void setId(String id) { this.id = id; }

    @Override
    public String execute() {
        cover = CoverStore.find(id);
        return cover == null ? NOT_FOUND : SUCCESS;
    }

    public InputStream getCoverStream() throws IOException { return Files.newInputStream(cover.path()); }

    public String getCoverType() { return cover.contentType(); }

    public long getCoverLength() { return cover.size(); }

    public String getDownloadName() {
        String ext = cover.contentType().equals("image/png") ? "png" : "jpg";
        return "cover-" + cover.bookId() + "." + ext;
    }

    public static final String NOT_FOUND = "notFound";
}
<action name="cover" class="org.example.bookshelf.CoverDownloadAction">
    <result type="stream">
        <param name="contentType">${coverType}</param>
        <param name="inputName">coverStream</param>
        <param name="contentLength">${coverLength}</param>
        <param name="contentDisposition">attachment;filename="${downloadName}"</param>
        <param name="allowCaching">false</param>
    </result>
    <result name="notFound" type="httpheader">
        <param name="status">404</param>
    </result>
</action>

The response headers and the first bytes of the body (a PNG signature) for a stored cover, and the answer for an unknown identifier:

curl -s -D - -o cover.bin "http://localhost:18080/g9upload/cover?id=8ca50ba2-e1fc-4b87-acef-dc2422012d0c"
curl -s -o /dev/null -w "%{http_code}\n" "http://localhost:18080/g9upload/cover?id=nope"
HTTP/1.1 200
Content-Disposition: attachment;filename="cover-7.png"
Pragma: no-cache
Cache-Control: no-cache
Content-Type: image/png
Content-Length: 69
404

Three things in the mapping are deliberate. The disposition attachment makes the browser save the file instead of rendering it, which is the safe default for user uploads (an uploaded HTML or SVG file shown inline runs in the application’s origin). The name in the header comes from the server (cover-7.png, built from the numeric bookId), not from the client’s file name or from any other free text. And the identifier is looked up in a map, so a request such as cover?id=../../etc/passwd has nothing to resolve and also answers 404 (observed); an application that maps the identifier to a path by concatenation would not be safe.

Struts reads inputName as the name of a property of the action, so the getter must return a stream that has not been read yet, a fresh one for every request, and never a shared instance. When the stream comes from an object that is not a file, such as an in-memory byte array, the result works in the same way; the CSV export below does that.

Inline or attachment

The value of contentDisposition decides what the browser does. The default is inline (display when the browser can); attachment;filename="..." forces the save dialog and names the file. For a cover that is shown in the page the mapping would use inline with the real content type (a different mapping from the download). The list of covers of the Bookshelf builds the link of each cover with the s:url tag and its s:param:

<ul>
  <s:iterator value="covers">
    <li><a href="<s:url action='cover'><s:param name='id' value='id'/></s:url>"><s:property value="originalName"/></a></li>
  </s:iterator>
</ul>

The originalName is printed through s:property, which escapes HTML by default; the client’s file name is untrusted text, so it is never written to a page unescaped.

The same result with the Convention plugin

With the Convention plugin the result is declared on the class with the @Result annotation, using an array of name, value pairs for the parameters:

@Result(
    name = "success",
    type = "stream",
    params = {
        "contentType", "${type}",
        "inputName", "stream",
        "bufferSize", "1024",
        "contentDisposition", "attachment;filename=\"${filename}\""
    }
)
public class FileDisplay extends ActionSupport { ... }

The page of the official documentation shows this form with the expression ${stream} for the input name and with private getters; the example above uses the plain property name and the getters must be public for the value stack to see them. The Convention plugin and its annotations are covered on their own page, Convention Plugin & Annotations.

The CSV Export

The second download of the Bookshelf is the catalogue as a CSV file, the case where the stream does not come from a file at all. The action builds the bytes in memory and exposes them as a ByteArrayInputStream; because the length is known, the browser gets a Content-Length and a progress bar.

public class ExportAction extends ActionSupport {
    private record Book(int id, String title, String author) { }

    private static final List<Book> BOOKS = List.of(
            new Book(1, "Dune", "Frank Herbert"),
            new Book(2, "Neuromancer", "William Gibson"),
            new Book(3, "=HYPERLINK(\"http://example.org\")", "Dan Simmons"),
            new Book(4, "Les Misérables, vol. 1", "Victor Hugo"));

    private String author = "";
    private byte[] csv;

    @StrutsParameter
    public void setAuthor(String author) { this.author = author; }

    @Override
    public String execute() {
        StringBuilder sb = new StringBuilder("id,title,author\r\n");
        for (Book b : BOOKS) {
            if (author.isEmpty() || b.author().contains(author)) {
                sb.append(b.id()).append(',').append(cell(b.title())).append(',').append(cell(b.author())).append("\r\n");
            }
        }
        csv = sb.toString().getBytes(StandardCharsets.UTF_8);
        return SUCCESS;
    }

    private static String cell(String v) {
        String safe = v.matches("(?s)^[=+\\-@\\t\\r].*") ? "'" + v : v;
        return '"' + safe.replace("\"", "\"\"") + '"';
    }

    public InputStream getCsvStream() { return new ByteArrayInputStream(csv); }

    public long getCsvLength() { return csv.length; }

    public String getFileName() { return "bookshelf-books.csv"; }
}
<action name="export" class="org.example.bookshelf.ExportAction">
    <result type="stream">
        <param name="contentType">text/csv</param>
        <param name="contentCharSet">UTF-8</param>
        <param name="inputName">csvStream</param>
        <param name="contentLength">${csvLength}</param>
        <param name="contentDisposition">attachment;filename="${fileName}"</param>
        <param name="allowCaching">false</param>
    </result>
</action>

The request, with the byte order mark shown as M-oM-;M-? by cat -v and the carriage returns as ^M:

curl -s -D - http://localhost:18080/g9upload/export | cat -v
Content-Disposition: attachment;filename="bookshelf-books.csv"^M
Pragma: no-cache^M
Cache-Control: no-cache^M
Content-Type: text/csv;charset=UTF-8^M
Content-Length: 178^M
^M
M-oM-;M-?id,title,author^M
1,"Dune","Frank Herbert"^M
2,"Neuromancer","William Gibson"^M
3,"'=HYPERLINK(""http://example.org"")","Dan Simmons"^M
4,"Les MisM-CM-)rables, vol. 1","Victor Hugo"^M

The same action with a filter, export?author=Gibson, returns only the header line and the Neuromancer row (observed). The points worth reading in this output:

  • contentCharSet adds ;charset=UTF-8 to the content type, and the bytes really are UTF-8 (getBytes(UTF_8)); the length is the number of bytes, not of characters (178 bytes for 4 rows, because the accented letter takes two).

  • The leading U+FEFF is a byte order mark (the three bytes EF BB BF). Excel needs it to recognise UTF-8 in a CSV file; other readers show it as a stray character, so add it only when the consumers are spreadsheet users.

  • Every cell is quoted and an embedded double quote is doubled (""), which also protects the comma in Les Misérables, vol. 1; the line separator is the CRLF that RFC 4180 specifies.

  • A cell that begins with =, +, -, @, a tab or a carriage return is prefixed with a single quote. Without that, a book titled =HYPERLINK("http://example.org") becomes a formula when the file is opened in a spreadsheet (CSV injection or formula injection). Escaping for CSV does not prevent it, the prefix does.

Pitfalls

Table 13. Upload and download pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Struts 7.4.0 and Tomcat 11.0.26)
Symptom or error Cause Fix

The action receives no file and no error (observed)

The form has no enctype="multipart/form-data" or the method is get; the browser sends only the file name as text

Use method="post" and the multipart encoding, and test the empty list in the action

An upgraded application stops receiving uploads, with HTTP 200 and null properties (observed)

The action still uses File, xxxContentType and xxxFileName setters of the removed fileUpload interceptor

Implement UploadedFilesAware and read the files in withUploadedFiles

The application fails at startup naming fileUpload

A mapping or a custom stack refers to an interceptor that does not exist in 7.x

Replace it with actionFileUpload

The download header carries parameters that the uploader chose (observed)

A free-text value (here a String bookId) with " or ; is placed in contentDisposition

Make the value numeric, validate it against a strict pattern, or build the name only from server data

An action mapped with only basicStack receives nothing

basicStack does not contain actionFileUpload

Add <interceptor-ref name="actionFileUpload"/> before it, or use defaultStack or actionFileUploadStack

A ${...} rule that depends on a request parameter is ignored (observed)

actionFileUpload runs before params, which has not yet set the value

List basicStack or params before the upload interceptor in the mapping

The upload is rejected with "upload validation policy could not be resolved" (observed)

A ${...} parameter names a property that the action does not have

Fix the name; this is fail closed by design since 7.3.0

A large upload fails with a message about the request size, whatever maximumSize says (observed)

struts.multipart.maxSize (default about 2 MB) applies first and to the sum of all the files

Raise struts.multipart.maxSize and maxFileSize to the real need, then set maximumSize per action

A request with many form fields loses its file (observed)

More than struts.multipart.maxParameterCount normal fields (default 256): the request fails closed and nothing arrives

Raise maxParameterCount, not maxFiles

Before 7.3.0: a form with many fields is rejected

struts.multipart.maxFiles counted the normal fields too

Upgrade, or raise the limit; lower it again after 7.3.0

The file is accepted although it is not an image (observed)

allowedTypes and allowedExtensions trust the client’s header and name

Check the content (magic bytes or a decoder) in the action

The file is written under the client’s name

The action concatenates getOriginalName() into a path

Generate the name and verify that the resolved path is below the target directory

A file under the web root is executed or served

The upload directory is part of the web application

Store outside the web root and serve through an action

The page shows temporary file names and "allowed mx size" (observed)

The default error messages include {2} and a typo in the text

Override the keys in the message bundle

The download shows the page source or garbage

The mapping has no contentType, so the default text/plain is used, or the stream property name is not inputStream and inputName is missing

Set contentType and inputName

A stream parameter from the action is ignored

Since 2.5.2 each parameter must be written as ${name}; it is not resolved automatically

Write the expression

The browser shows the file instead of saving it

contentDisposition is inline (the default)

Use attachment;filename="..."

The CSV opens with a formula executing in the spreadsheet

A cell starting with =, +, - or @ is a formula

Prefix such cells with ' (as cell() does) or reject the values

The CSV shows  at the start in a non-Excel reader

The byte order mark is read as characters

Drop the BOM when the consumers are not spreadsheets

The Bookshelf End to End: a Cover for a Book

The pieces above combine into one flow of the Bookshelf. A librarian opens covers, uploads dune.png for book 7, and the page lists it; a reader follows the link to cover?id=...; the librarian exports the catalogue.

# 1. the form
curl -s -o /dev/null -w "%{http_code}\n" http://localhost:18080/g9upload/covers
# 2. a valid upload
curl -s -F bookId=7 -F "cover=@dune.png;type=image/png" http://localhost:18080/g9upload/uploadCover | grep saved
# 3. a rejected upload (a PDF)
curl -s -F "cover=@x.pdf;type=application/pdf" http://localhost:18080/g9upload/uploadCover | grep -c errorMessage
# 4. the download
curl -s -D - -o /dev/null "http://localhost:18080/g9upload/cover?id=06607f3c-4cbf-41b4-a318-7c1632a07a75" | grep -i disposition
# 5. the catalogue as CSV
curl -s http://localhost:18080/g9upload/export

The five steps answered 200 with the form, the line Saved 06607f3c-... (69 bytes, image/png) as dune.png, the two field errors for the PDF (extension and content type), the header Content-Disposition: attachment;filename="cover-7.png" and the CSV shown above. The matrix of what the interceptors decide for each input is the table of Allowed Types and Extensions; the limits are the table of Limits: Two Layers.

In a real application the same flow has three more parts. The identifier comes from a database and the download action checks that the current user may read the cover; the upload action is reachable only after authentication and, because it changes state, is protected by the token interceptor (the topic of the double-submit page of this section); and the front proxy and the container have body limits equal to or slightly above the Struts ones.

What Changed Recently

Table 14. Changes to upload and download across the Struts versions covered by this section
Version Change

2.3.11

struts.multipart.validationRegex and struts.multipart.enabled were added

2.3.18

The jakarta-stream parser was added for large uploads

2.5.2

stream result parameters from the action have to be written as ${...} expressions

6.1.2

struts.multipart.maxFiles limits the number of files per request

6.1.2.1

struts.multipart.maxStringLength limits normal string fields in a multipart request

6.3.0.2 and 2.5.33

Fix for S2-066 (CVE-2023-50164)

6.4.0

The actionFileUpload interceptor and UploadedFilesAware are introduced; fileUpload is deprecated; S2-067 (CVE-2024-53677) recommends migrating

7.0.0

fileUpload removed; the package names move to org.apache.struts2.*; the Commons FileUpload 2 exception names are used in the message keys

7.2.0

allowedTypes, allowedExtensions and maximumSize accept ${...} expressions (lazy parameters)

7.3.0

maxFiles counts only files and maxParameterCount counts normal fields (WW-5474); small uploads stay in memory (WW-5413, getInputStream() and isMissing()); the per-invocation UploadPolicy (WW-5659); an unresolved expression rejects the upload; acceptFile takes the policy as first argument

7.4.0

Current release (2026-10-02); the behaviour in this page was observed on it

Where the Books Are Outdated

The printed Struts 2 books describe the framework as it was in the 2.0 and 2.1 era. On file upload and download the differences are large, and they are security relevant:

Table 15. Where older Struts books and tutorials no longer apply to 7.4.0
What a Struts 2.x book teaches What is true in Struts 7.4.0

Receive an upload with setUpload(File), setUploadContentType(String) and setUploadFileName(String) on the action, through the fileUpload interceptor of defaultStack

That interceptor does not exist; use UploadedFilesAware and actionFileUpload. The old code compiles and silently receives nothing. S2-066 and S2-067 are the reason

Save the file by copying getUpload() into a folder of the web application

Store outside the web root under a generated name and serve through an action

Trust allowedTypes as a security control

It checks a client supplied header; inspect the content as well

Limits are the single constant struts.multipart.maxSize of 2 MB

There are five request-level constants plus the interceptor parameters, all fail closed; maxFiles and maxParameterCount are separate since 7.3.0

The stream result finds inputName, contentType and the other values on the value stack by itself

Since 2.5.2 each must be an explicit ${...} expression

The package names com.opensymphony.xwork2.ActionSupport and com.opensymphony.xwork2.Action in the action and result examples

org.apache.struts2.ActionSupport and org.apache.struts2.action.Action since 7.0

Download through a servlet that writes to HttpServletResponse

Still possible, but the stream result does the same with the headers configured declaratively

Return a spreadsheet as application/x-msexcel

CSV (text/csv) or XLSX with the proper content type and Content-Disposition; guard against formula injection

Upload with an AJAX Dojo widget or an applet

Plain multipart forms, or fetch with a FormData body against the same action

Notes on the Official Documentation

While this page was written the official pages were found to disagree with Struts 7.4.0 in several places; each is recorded so that the reader is not surprised.

  • The Action File Upload page still says com.opensymphony.xwork2.ValidationAware and javax.servlet.context.tempdir in prose that was written before the 7.0 repackaging and the Jakarta namespace move; the classes are in org.apache.struts2 and the attribute is jakarta.servlet.context.tempdir.

  • The legacy File Upload page still shows @StrutsParameter setters for the old File properties, although there is no fileUpload interceptor in 7.4.0; the code compiles and does nothing.

  • The Stream Result page shows com.opensymphony.xwork2.Action and private getters, and says that allowCaching sets no-cahce headers (observed: no-cache).

  • The text of struts.messages.error.file.too.large contains "allowed mx size", and the whole-request message does not report the request’s size although the properties text promises it (observed).

  • The Security Guide has no section on file uploads; the guidance on this page (store outside the web root, generated names, content checks) comes from the Core Developer Guide, the bulletins and the observations above.

References