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.
| Direction | Piece | Role |
|---|---|---|
Upload |
HTML form with |
Makes the browser send a |
Upload |
The multipart parser ( |
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 |
|
Wraps the servlet request so that normal fields appear as parameters and file parts are kept separately |
Upload |
The |
Applies the per-action rules (size, content type, extension), turns problems into field errors and hands the accepted files to the action |
Upload |
|
The callback |
Download |
An |
The bytes to send, produced per request |
Download |
The |
Copies the stream to the response and sets the content type, length, disposition and cache headers |
Download |
The |
Answers a status code (404 for an unknown cover) without a body |
Source: Apache Struts — Stream Result
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.
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 "bookId".
Invalid field value for field "bookId".
Invalid field value for field "bookId".
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().
| Method | Meaning |
|---|---|
|
Size of the content in bytes |
|
Name of the file on the server (the temporary file, not what the user chose) |
|
The file name sent by the browser; user controlled text, never use it as a path |
|
The |
|
The name of the form field ( |
|
Whether the content is a file on disk |
|
The |
|
Path of the temporary file; materialises it as well |
|
Deletes the temporary file |
|
Default method, since 7.3.0: reads the content without forcing it to disk; the preferred way |
|
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.
| Stack | Position of actionFileUpload |
|---|---|
|
After |
|
After |
|
First, followed by |
any other stack ( |
Not present: an action mapped with only |
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
| Parameter | Default | Meaning |
|---|---|---|
|
about 2 MB |
Largest size in bytes of one file that the interceptor lets reach the action; it is not related to the |
|
all types |
Comma separated list of content types ( |
|
all extensions |
Comma separated list of extensions ( |
|
|
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.
| Constant | Default | Limits |
|---|---|---|
|
|
Which parser reads the body (see Parsers and Other Constants) |
|
empty |
Where temporary files are written; empty means the servlet container’s temporary directory |
|
|
Combined size of the whole request in bytes (about 2 MB); the sum of all files, not each file |
|
not set |
Size of each file in bytes; not enforced unless set |
|
|
Number of uploaded files per request (since 6.1.2; counts only files since 7.3.0) |
|
|
Number of normal (non-file) fields per request (since 7.3.0) |
|
|
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):
| Request | Limit reached | Message shown |
|---|---|---|
Two small files, one field |
none |
both files reach |
One file of 600083 bytes |
|
|
Four files of 300083 bytes each |
|
|
Six small files |
|
|
21 normal fields and one file |
|
|
20 normal fields and one file |
none (exactly at the limit) |
accepted |
One field of 300 characters |
|
|
One file of 300083 bytes to |
|
|
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.
| Key | When |
|---|---|
|
General error: the file could not be uploaded |
|
The file is larger than |
|
The content type is not in |
|
The extension is not in |
|
A |
|
The whole request exceeds |
|
A file exceeds |
|
More files than |
|
More normal fields than |
|
A normal field longer than |
|
Any other exception while parsing, for example |
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.
| Check | Looks at | Weakness |
|---|---|---|
|
The |
A client can send |
|
The suffix of the original file name, written by the client |
A file named |
Content inspection in the action |
The first bytes of the file (magic numbers), or a real decoder such as |
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 |
|---|---|---|
|
accepted |
accepted and stored as |
|
two errors: |
not reached |
|
|
not reached |
|
accepted: the name and the type are fine |
rejected: |
|
accepted: the extension is |
stored as |
|
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
.jspor.jspxwill 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
UUIDplus an extension picked from a fixed table), and never usegetOriginalName()orgetContentType()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()thenstartsWith()), 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.
| 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 |
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:
| Before (Struts 2.x to 6.3) | After (Struts 6.4 and later) |
|---|---|
|
A single |
|
|
|
The same names on |
|
None: files are not parameters |
|
|
|
|
Error keys named after the Commons FileUpload 1.x exceptions ( |
Commons FileUpload 2 names since 7.0 ( |
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"/>
| Constant | Purpose |
|---|---|
|
Parser class or alias ( |
|
Directory for temporary files |
|
Regular expression that the request’s content type must match to be treated as multipart (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.
| Parameter | Default | Meaning |
|---|---|---|
|
|
The MIME type sent to the browser |
|
not set |
Length of the stream in bytes; the browser shows a progress bar |
|
|
The |
|
|
Name of the |
|
|
Size of the buffer used to copy the stream to the response |
|
|
When |
|
not set |
When set, |
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:
-
contentCharSetadds;charset=UTF-8to 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+FEFFis a byte order mark (the three bytesEF 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 inLes Misérables, vol. 1; the line separator is theCRLFthat 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
| Symptom or error | Cause | Fix |
|---|---|---|
The action receives no file and no error (observed) |
The form has no |
Use |
An upgraded application stops receiving uploads, with HTTP 200 and |
The action still uses |
Implement |
The application fails at startup naming |
A mapping or a custom stack refers to an interceptor that does not exist in 7.x |
Replace it with |
The download header carries parameters that the uploader chose (observed) |
A free-text value (here a |
Make the value numeric, validate it against a strict pattern, or build the name only from server data |
An action mapped with only |
|
Add |
A |
|
List |
The upload is rejected with "upload validation policy could not be resolved" (observed) |
A |
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 |
|
Raise |
A request with many form fields loses its file (observed) |
More than |
Raise |
Before 7.3.0: a form with many fields is rejected |
|
Upgrade, or raise the limit; lower it again after 7.3.0 |
The file is accepted although it is not an image (observed) |
|
Check the content (magic bytes or a decoder) in the action |
The file is written under the client’s name |
The action concatenates |
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 |
Override the keys in the message bundle |
The download shows the page source or garbage |
The mapping has no |
Set |
A stream parameter from the action is ignored |
Since 2.5.2 each parameter must be written as |
Write the expression |
The browser shows the file instead of saving it |
|
Use |
The CSV opens with a formula executing in the spreadsheet |
A cell starting with |
Prefix such cells with |
The CSV shows |
The byte order mark is read as characters |
Drop the BOM when the consumers are not spreadsheets |
Source: Apache Struts — Stream Result
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.
Source: Apache Struts — Stream Result
What Changed Recently
| Version | Change |
|---|---|
2.3.11 |
|
2.3.18 |
The |
2.5.2 |
|
6.1.2 |
|
6.1.2.1 |
|
6.3.0.2 and 2.5.33 |
Fix for S2-066 (CVE-2023-50164) |
6.4.0 |
The |
7.0.0 |
|
7.2.0 |
|
7.3.0 |
|
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:
| What a Struts 2.x book teaches | What is true in Struts 7.4.0 |
|---|---|
Receive an upload with |
That interceptor does not exist; use |
Save the file by copying |
Store outside the web root under a generated name and serve through an action |
Trust |
It checks a client supplied header; inspect the content as well |
Limits are the single constant |
There are five request-level constants plus the interceptor parameters, all fail closed; |
The |
Since 2.5.2 each must be an explicit |
The package names |
|
Download through a servlet that writes to |
Still possible, but the |
Return a spreadsheet as |
CSV ( |
Upload with an AJAX Dojo widget or an applet |
Plain multipart forms, or |
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.ValidationAwareandjavax.servlet.context.tempdirin prose that was written before the 7.0 repackaging and the Jakarta namespace move; the classes are inorg.apache.struts2and the attribute isjakarta.servlet.context.tempdir. -
The legacy File Upload page still shows
@StrutsParametersetters for the oldFileproperties, although there is nofileUploadinterceptor in 7.4.0; the code compiles and does nothing. -
The Stream Result page shows
com.opensymphony.xwork2.Actionand private getters, and says thatallowCachingsetsno-cahceheaders (observed:no-cache). -
The text of
struts.messages.error.file.too.largecontains "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.
Source: Apache Struts — Stream Result
References
-
Apache Struts — Action File Upload, Uploading Multiple Files
-
Apache Struts — Action File Upload, Disabling file upload support
-
Apache Struts — Action File Upload Interceptor, Dynamic Parameter Evaluation
-
Apache Struts — Action File Upload Interceptor, Extending the Interceptor
-
Apache Struts — Stream Result, Annotation based Configuration
-
Apache Struts — Type Conversion, Type Conversion Error Handling
-
Apache Struts 7.4.0 Javadoc — UploadedFilesAware.withUploadedFiles