Security
|
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 Pages 4.0, Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 (API
jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api 3.0.2, implementation org.glassfish.web:jakarta.servlet.jsp.jstl
3.0.1) and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21, with the OWASP Java Encoder
org.owasp.encoder:encoder-jakarta-jsp 1.5.0. Everything was verified on 2026-10-08: the CVE records against the Tomcat 9, 10
and 11 security pages and the CVE Program, the rules against the specifications and the OWASP cheat sheets, and every example
by building it into a Maven WAR (context path /g7security) and running it on Tomcat 11.0.26 with curl; the outputs shown are
the ones that run produced, and the few configuration snippets that cannot be applied to a shared server (server.xml) are
marked as checked against the documentation only. Security in a JSP application is mostly one habit and a handful of
settings. The habit is to treat every value that came from outside as text that must be encoded for the place where it is
written. The settings keep the pages out of reach of direct requests, remove the dangerous features of the container and make
the session and the cookie harder to steal or abuse. The running scenario is the Bookshelf, the small catalogue of books of
this section: a search page, a review form, a cover upload and an admin area. The page ends with the CVE table, a pitfalls
table, what changed recently and where the books in the section bibliography are out of date. Output escaping is introduced on
the JSTL core page and the JSTL functions
page; the session and cookie basics are on the Servlet fundamentals page;
this page ties them together and adds what is not on those pages.
The Bookshelf Security Application
The WAR of this page adds the pieces that a security discussion needs to the Bookshelf of the getting started page: pages that echo input in every HTML context, a review form that stores text and needs a token, an upload servlet, a protected area and a handful of configuration files.
| File | Role |
|---|---|
|
the search page: one request parameter |
|
stored reviews, a hidden CSRF token, the check in |
|
|
|
|
|
a nonce-based |
|
a deliberately unsafe custom tag that evaluates its attribute as an expression |
|
a JSP in the public tree, to show what that means |
|
|
|
the cookie processor and the error report valve of this one web application |
The dependency that is new compared with the earlier pages is the encoder, a small library of the OWASP project:
<dependency>
<groupId>org.owasp.encoder</groupId>
<artifactId>encoder-jakarta-jsp</artifactId>
<version>1.5.0</version>
</dependency>
Maven resolves the encoder core library (same version) through it. 1.5.0 is the latest release on Maven Central on 2026-10-08.
Source: OWASP Java Encoder project
Where Untrusted Text Comes From
A JSP page has more inputs than the request parameters it reads. The expression language reads all of them with the same syntax, which is what makes the habit necessary.
| Source | Examples in the Bookshelf |
|---|---|
request parameters and the query string |
|
headers and cookies |
|
the request path and path parameters |
|
stored data |
a book title or a review that was saved by one user and is shown to another (stored XSS); the database is not a trusted source |
uploaded files |
the file name, the content type and the content itself |
data from other systems |
an imported feed, a web service answer, a |
Three rules cover almost every defect on this page. Encode on output, for the context in which the value is written, as late as possible. Never turn text into code: no expression, no OGNL (on the Struts side), no script or SQL is built from input. Keep the application away from the places where the container serves files directly. The sections below apply the rules one context at a time.
Cross-Site Scripting and the Expression Language
The expression language writes the value it computes to the response as it is. An expression in template text, ${param.q},
does not escape anything, and neither does an expression in a tag attribute whose handler prints the value unescaped. The
search page of the Bookshelf (see the Getting Started page) shows the four ways of writing the same parameter that the page set offers:
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="fn" uri="jakarta.tags.functions" %>
<%@ taglib prefix="e" uri="owasp.encoder.jakarta" %>
<p id="raw">Results for: ${param.q}</p>
<p id="cout">Results for: <c:out value="${param.q}"/></p>
<p id="fn">Results for: ${fn:escapeXml(param.q)}</p>
<p id="enc">Results for: <e:forHtml value="${param.q}"/></p>
Requested with q=<b>x</b>&"' (the parameter encoded for the URL), the page answered:
<p id="raw">Results for: <b>x</b>&"'</p>
<p id="cout">Results for: <b>x</b>&"'</p>
<p id="fn">Results for: <b>x</b>&"'</p>
<p id="enc">Results for: <b>x</b>&"'</p>
The first line is a cross-site scripting hole: the markup in the parameter became markup in the page, and a script element
would have run. The other three are correct for HTML text and for a quoted HTML attribute. c:out and fn:escapeXml replace
exactly the five characters <, >, &, " and ' (by <, >, &, " and '), which is the whole of
their job: they are an HTML-body encoder, not a universal one. The OWASP encoder writes numeric references for the quotes
(", '), which are the same characters. The details of c:out (the default attribute, escapeXml taking any text
as false except "true", reader values) are on the JSTL core page and those of
fn:escapeXml on the JSTL functions page; the one fact to take away is
that the default of c:out is the safe one, and that only a literal escapeXml="false" on text the page’s own authors
control may switch it off.
Which of the four to use is a style choice. c:out is the idiom that reviewers recognise and that a search for "an ${...}
that is not inside a tag" finds. ${fn:escapeXml(x)} is convenient inside an attribute value that contains other text. The
encoder tags and functions (the next section) are the only ones that also cover the other contexts, so a page that uses them
for the other contexts may use them for HTML text as well, to have a single vocabulary.
The unescaped ${b.title} of the first Bookshelf page of this section (an expression in template text) is the same hole
as ${param.q} as soon as a title comes from a form or an import: an expression is only as safe as the text it prints. The
syntax and escaping page lists the places where an
expression is printed, and the scripting elements page shows
that <%= x %> is unescaped by definition.
Encoding for the Context
HTML text is one context. The same value needs a different encoding when it lands in a quoted attribute, in a JavaScript string, in a URL or in CSS, and no single function is right for all of them. The diagram is the decision the page author makes for every dynamic value.
The OWASP Java Encoder implements the rules of the cheat sheet. For Jakarta Pages it ships as encoder-jakarta-jsp, whose tag
library has the URI owasp.encoder.jakarta (a second, advanced library has the URI owasp.encoder.jakarta.advanced; the
javax artifact is a separate encoder-jsp on Maven Central). The library offers each encoder both as a tag
(<e:forHtml value="${x}"/>) and as an EL function (${e:forHtml(x)}); the 1.5.0 TLD declares the same fifteen names
twice: forHtml, forHtmlContent, forHtmlAttribute, forHtmlUnquotedAttribute, forCssString, forCssUrl, forUri,
forUriComponent, forJavaScript, forXml, forXmlContent, forXmlAttribute, forXml11, forCDATA and forJson.
| Context | Encoder | What the run produced for q = <b>x</b>&"' |
|---|---|---|
HTML text |
|
|
quoted HTML attribute |
|
|
JavaScript string literal |
|
|
URL component |
|
|
URL built by the tag library |
|
|
<input id="attr" type="text" name="q" value="<e:forHtmlAttribute value="${param.q}"/>">
<script nonce="${cspNonce}">
var query = '<e:forJavaScript value="${param.q}"/>';
</script>
<a id="link" href="/search?q=<e:forUriComponent value="${param.q}"/>">again</a>
<a id="link2" href="<c:url value="/search"><c:param name="q" value="${param.q}"/></c:url>">again</a>
Source: OWASP Java Encoder project
The JavaScript Context
c:out is wrong inside a script block, and the run shows how. With q set to \';alert(1)// (a backslash, a quote and a
script fragment) the page produced:
<script nonce="...">
var query = '\\\x27;ale\x72\x74(1)\/\/';
var broken = '\';alert(1)//';
</script>
The first line, from e:forJavaScript, is a harmless string. The second, from c:out, is not safe in JavaScript: an HTML
entity means nothing inside a script element, so the value is the text ' and the backslash that the attacker supplied
escapes the closing quote of the literal, which breaks the page’s script and, in a page with a second injection point, lets
the attacker take over the statement. Two more observations from the run. The 1.5.0 encoder writes \x escapes for the
letters of the word "script" (c, i, p, r, s, t in either case) as well as for the characters that need it, so
alert became ale\x72\x74: the output is valid and equal to the input, only verbose, and it exists so that a value can
never spell the end of the script element. And the rule is not "encode, then everything is allowed": the safest design keeps
data out of script text altogether. Put the value in a data-* attribute (a quoted HTML attribute, encoded by c:out) and read
it with element.dataset, or put a JSON document in a <script type="application/json"> block written with e:forJson
(a tag of the library) and parse it. A Content-Security-Policy that forbids inline scripts (see below) then protects the
page even if a value is missed.
Quoted Attributes, Unquoted Attributes and Whole URLs
An attribute value must be quoted. The HTML attribute encoders are written for a quoted value; the OWASP library has a separate
forHtmlUnquotedAttribute because an unquoted value ends at the first space, and a page that leaves out the quotes needs it.
The better rule is to quote every attribute and never to use the unquoted form.
The other half of a link is the scheme. A value in href or src that is encoded correctly can still be an attack, because
javascript: is a valid URL. The run, with site=javascript:alert(1):
<a id="badsite" href="<c:out value="${param.site}"/>">site</a>
<c:choose>
<c:when test="${fn:startsWith(param.site, 'https://')}">
<a id="site" href="<c:out value="${param.site}"/>">site</a>
</c:when>
<c:otherwise><span id="site">no link</span></c:otherwise>
</c:choose>
<a id="badsite" href="javascript:alert(1)">site</a>
<span id="site">no link</span>
and with site=https://example.org/?a=1&b="x the second branch printed
<a id="site" href="https://example.org/?a=1&b="x">. The first anchor shows that escaping does not neutralise a
scheme; the c:choose shows the allow list (an HTTPS prefix test, case-sensitive, so compare on a lower-cased copy
if HTTP in capitals must pass), and that c:out still encodes the value that passed. A value that is a path of the
application belongs in c:url instead, which also adds the context path.
Stored Cross-Site Scripting: the Review Page
A value that is stored and shown later is the same defect, with a longer path and a larger audience. The review page prints
the book and the text of every review with c:out:
<ul>
<c:forEach var="r" items="${reviews}">
<li><c:out value="${r.book}"/>: <c:out value="${r.text}"/></li>
</c:forEach>
</ul>
A review posted with the text <script>alert(1)</script> was listed as:
<li>Dune: <script>alert(1)</script></li>
The encoding happens on output, in the page that knows the context, not on input in the servlet: a value escaped when it is
saved is double-encoded the next time it passes through a c:out, and wrong for any other consumer of the same data, such as
a JSON endpoint or a PDF. Input validation (a length limit, a character set, a format) is a separate defence that reduces what
can be stored. A page that must show formatted user text (reviews with bold and links) cannot use an encoder, because it
would show the tags; it needs an HTML sanitizer with an allow list of elements and attributes, run when the text is saved or
displayed, and the Content-Security-Policy below as a second line.
Content-Security-Policy and Header Defences
A policy that the browser enforces limits what an injected script can do and, with a nonce, forbids inline scripts that the server did not mark. The Bookshelf sets one for every response with a filter and passes the nonce to the pages through a request attribute:
@WebFilter("/*")
public class CspFilter implements Filter {
private static final SecureRandom RANDOM = new SecureRandom();
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
byte[] bytes = new byte[16];
RANDOM.nextBytes(bytes);
String nonce = Base64.getEncoder().encodeToString(bytes);
req.setAttribute("cspNonce", nonce);
((HttpServletResponse) resp).setHeader("Content-Security-Policy",
"default-src 'self'; script-src 'nonce-" + nonce + "'; object-src 'none'; "
+ "base-uri 'none'; frame-ancestors 'none'");
chain.doFilter(req, resp);
}
}
Every script block of a page then carries the nonce, <script nonce="${cspNonce}">, and the response had:
Content-Security-Policy: default-src 'self'; script-src 'nonce-xbVYu1qbsWZOTNUEpa1RBg=='; object-src 'none'; base-uri 'none'; frame-ancestors 'none'
<script nonce="xbVYu1qbsWZOTNUEpa1RBg==">
The nonce is base 64 text from a secure random generator, so printing it without encoding is safe; it must be fresh for every response, because a nonce that repeats is a password that is published. The policy is a defence in depth, never a replacement for encoding.
Tomcat ships a filter for three more headers. Adding it to web.xml is two elements, and no code:
<filter>
<filter-name>httpHeaderSecurity</filter-name>
<filter-class>org.apache.catalina.filters.HttpHeaderSecurityFilter</filter-class>
<async-supported>true</async-supported>
</filter>
<filter-mapping>
<filter-name>httpHeaderSecurity</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
and a response of the Bookshelf then carried X-Frame-Options: DENY and X-Content-Type-Options: nosniff. The filter
documentation names three protections: HSTS (added only to responses for HTTPS requests), click-jacking protection and
content-type sniffing protection; the policy header above is not among them, which is why the application sets it itself.
The filter is Tomcat-specific, so a WAR that must run on other containers sets the same headers in a filter of its own.
Expression Language Injection
Encoding protects the output. The second family of defects is about input that becomes code. An expression is a small
program: since EL 2.2 it can call methods, and with EL 3.0 the classes of java.lang are imported by default. When text
from a request is handed to something that evaluates it as an expression, the attacker writes the program. A value that is
only printed is never evaluated again: with q set to ${1+1}, the search page printed ${1+1} (the raw, c:out and
fn:escapeXml lines above), because ${param.q} is evaluated once, and its result is a string, not source text.
The defect needs a component that takes a string and parses it. The Bookshelf contains one on purpose, a custom tag that a careless author wrote to "let templates carry expressions":
public class EvalTag extends SimpleTagSupport {
private String expr;
public void setExpr(String expr) { this.expr = expr; }
@Override
public void doTag() throws JspException, IOException {
PageContext pc = (PageContext) getJspContext();
ELContext el = pc.getELContext();
ExpressionFactory factory = ExpressionFactory.newInstance();
Object value = factory.createValueExpression(el, expr, Object.class).getValue(el);
pc.getOut().print(value);
}
}
Used as <bs:eval expr="${param.q}"/>, the page evaluated the value of the parameter a second time ("double
evaluation"). The requests and the responses of the run:
q |
Result inside the paragraph |
|---|---|
|
|
|
|
|
|
|
|
|
|
|
the whole application scope, including attribute names and the class path of the web application |
The last three rows show that an attacker reaches static methods and objects, not only the data of the request; the
Runtime row ran an object method on a class the page never imported. Replacing availableProcessors() with a method that
starts a process is the end of the story, which is why the OWASP page on expression language injection rates the impact as
high. The same pattern exists in the wild in tag libraries that evaluate a message or a label: that page describes the
spring:message tag of Spring versions before 3.0.6, which resolved expressions twice, and states that the pattern must be
avoided. The defences:
-
Never pass request text to
createValueExpression,ELProcessor.evalor any "evaluate this string" API. If a feature needs user-defined formulas, parse them with a purpose-built, restricted grammar. -
Do not build an expression from input:
${param.x}as an operand is safe, a string concatenated into"${" + x + "}"is not. -
Do not let a message, a label or a tag attribute that comes from a database be evaluated. Messages are text for
MessageFormat, not for the expression language. -
Use a static
isELIgnoredordeferred-syntax-allowed-as-literalonly for what they are meant for (compatibility with old pages), not as an injection defence: an attacker’s text reaches the evaluator by a path that has nothing to do with the page’s own template text. -
Keep tag handlers pure. A handler that reads
getJspContext().getELContext()to do its own evaluation is the place where the problem lives; the container already evaluates every attribute of every tag.
The EL configuration and the evaluator API are on the EL configuration and ELProcessor page; the Spring tags and the Struts OGNL equivalent are on the Spring MVC with JSP and ValueStack, ActionContext & OGNL pages of this section, and on the Struts side the same defect has had a long list of severe CVEs.
Scripting Elements Switched Off
A scriptlet is Java code in the page, in the thread of the request, with the full power of the web application. The first
defence is to forbid it. One property group in web.xml turns every <% %>, <%! %> and <%= %> of the web application
into a translation error:
<jsp-config>
<jsp-property-group>
<url-pattern>*.jsp</url-pattern>
<scripting-invalid>true</scripting-invalid>
</jsp-property-group>
</jsp-config>
A page legacy.jsp that contained <% out.println("scriptlet"); %> failed on the first request with a 500 response (the
error valve of the Bookshelf prints no report, see below) and the log said:
org.apache.jasper.JasperException: /WEB-INF/views/legacy.jsp (line: [2], column: [2]) Scripting elements ( <%!, <jsp:declaration, <%=, <jsp:expression, <%, <jsp:scriptlet ) are disallowed here.
The error is a feature: a pull request that adds a scriptlet fails in the first test that renders the page. The property
groups, their url-pattern rules and the stricter ScriptFreeTLV validator are described on the
configuration page and the
scripting elements page, which also shows how to migrate a page
away from scriptlets.
Scriptlets are also where the classic server-side vulnerabilities of old JSP books live. The catalogue of "common JSP tasks"
of a book of 2001 includes a page that runs Runtime.exec with parts of the request, a mail form that puts form values into
message headers, and a "view source" button that reads a file whose name comes from the request: each is an injection of a
different kind (command, header, path traversal). The rule is the same for all: a value from outside is never part of a
command line, a header or a path; it is a parameter to an API that takes it as data (ProcessBuilder with a fixed program
and an argument list, a mail library that rejects line breaks, a lookup by identifier).
JSPs Under WEB-INF
A JSP in the public part of the web application is a URL. Anyone can request it, with any parameter, without the servlet
that was meant to prepare its model; it can show half-built pages, stack traces or data from an attribute that is not set. Files
under WEB-INF are never served by the container, and a servlet reaches them with a RequestDispatcher. The Bookshelf has
one JSP of each kind, and the run asked for both and for the neighbours:
| Request | Status | Meaning |
|---|---|---|
|
200 |
a JSP in the public tree is executed on request |
|
404 |
the container does not serve |
|
404 |
not even the configuration |
|
404 |
no directory listing, because the Default Servlet is configured with |
|
200 |
the servlet forwards to the JSP under |
@WebServlet("/search")
public class SearchServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/search.jsp").forward(req, resp);
}
}
The Struts security guide has a section titled "Never expose JSP files directly": a Struts
result that points to a JSP in the public tree lets a visitor skip the action. In a plain JSP application the servlet plays
the action’s role, and the same discipline applies. Two corollaries. A JSP that must be reachable directly (a static-looking
page that only uses the taglib) is still safer under a servlet mapping or a jsp-file servlet declaration than in the public
tree. And the container’s view source and directory index features of the early books (a "view source" button, a directory
listing page) are not needed by an application: the Default Servlet’s listings parameter is false by default, so leave it,
and do not write a page that lists a folder.
Every Page Starts a Session
A JSP takes part in the session by default (session="true" is the default of the page directive), so an anonymous visitor
who only reads the search page receives a session cookie. The response header of the Bookshelf’s search page had:
Set-Cookie: JSESSIONID=CF73FE31...; Path=/g7security; Secure; HttpOnly; SameSite=Strict
This is a performance and a privacy concern, not a flaw by itself, but it matters for security in two ways: a session id is
issued before the user logs in (so the id must change at login, below), and a page that does not need a session should say
so with <%@ page session="false" %>. The
implicit objects page has the rules.
Session Cookies and Fixation
Four lines of web.xml and one Context attribute harden the session cookie. The first three are the servlet specification’s
cookie-config; the last is Tomcat’s:
<session-config>
<session-timeout>30</session-timeout>
<cookie-config>
<http-only>true</http-only>
<secure>true</secure>
</cookie-config>
<tracking-mode>COOKIE</tracking-mode>
</session-config>
<Context>
<CookieProcessor className="org.apache.tomcat.util.http.Rfc6265CookieProcessor" sameSiteCookies="strict"/>
</Context>
With both in the WAR the cookie of the response above has Secure; HttpOnly; SameSite=Strict. HttpOnly hides it from
scripts, so an XSS cannot read the id (it can still act as the user, which is why XSS must not be allowed in the first place);
Secure keeps it off plain HTTP; SameSite=Strict keeps it off cross-site requests; and the COOKIE tracking mode prevents
the ;jsessionid= suffix on URLs. The Bookshelf scratch application runs on plain HTTP; curl still returned the Secure cookie from its jar for
localhost, but a browser on a non-TLS host that is not local does not send it back. SameSite=Strict also means that a link from another site arrives without the cookie, so a user
who follows an e-mail link to a logged-in page sees the login page once; Lax allows the cookie on top-level navigations
and is the usual compromise. The servlet fundamentals page lists the cookie attributes that the
specification itself defines, among them the generic attribute element of cookie-config (the specification’s
way of setting SameSite), and the Tomcat-specific CookieProcessor is the alternative that also covers the cookies the
application creates itself.
Session fixation is the attack where an attacker obtains a session id and gets the victim to use it, so that the victim’s login authenticates the attacker’s session. The defence is to change the id at the moment of login:
@WebServlet("/session/rotate")
public class RotateServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
HttpSession session = req.getSession();
String before = session.getId();
String after = req.changeSessionId();
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().println("changed: " + !before.equals(after));
}
}
curl -X POST with the session cookie answered changed: true and the cookie in the jar had a new value. The attributes of
the session (the cart, the token) survive the change; only the id differs. Tomcat’s authenticators change the id when a user logs in (the changeSessionIdOnAuthentication option of the
authenticator valves, documented with the Tomcat valves); an application that authenticates in its own servlet must call
changeSessionId itself.
The session security section of the servlet page has the full checklist, including the idle timeout and URL rewriting.
Requiring HTTPS
The servlet specification can require a secure transport for a set of URLs. A user-data-constraint with the guarantee
CONFIDENTIAL makes the container redirect a plain HTTP request to the connector’s redirectPort:
<security-constraint>
<web-resource-collection>
<web-resource-name>TLS only</web-resource-name>
<url-pattern>/tls-only/*</url-pattern>
</web-resource-collection>
<user-data-constraint>
<transport-guarantee>CONFIDENTIAL</transport-guarantee>
</user-data-constraint>
</security-constraint>
HTTP/1.1 302
Location: https://localhost:18443/g7security/tls-only/x
Which is what the run showed (the redirect port of the scratch server is 18443). A site that is TLS-only as a whole
configures the constraint for /*, or terminates TLS in a reverse proxy and tells Tomcat about it with the proxy’s headers;
the servlet container then must trust the proxy’s forwarded headers only from the proxy’s address.
Cross-Site Request Forgery
A browser attaches the cookie of a site to every request to that site, including a request that a page of another site triggers. A form on a hostile page that posts to the Bookshelf’s review URL would be authenticated as the visitor. The defences, in the order a servlet application should adopt them:
-
State changes only through POST (or PUT and DELETE), never GET: a link or an image tag can make a GET.
-
A synchroniser token: a random value stored in the session, written into every form as a hidden field and compared on the server with the submitted one.
-
SameSitecookies (above), which stop the cookie on cross-site POSTs. -
Optionally a check of the
OriginorRefererheader and Fetch Metadata headers, as a second line.
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession();
String token = (String) session.getAttribute("csrf");
if (token == null) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
session.setAttribute("csrf", token);
}
req.setAttribute("reviews", reviews);
req.getRequestDispatcher("/WEB-INF/views/reviews.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
HttpSession session = req.getSession(false);
String expected = session == null ? null : (String) session.getAttribute("csrf");
String actual = req.getParameter("csrf");
if (expected == null || actual == null
|| !MessageDigest.isEqual(expected.getBytes(StandardCharsets.UTF_8),
actual.getBytes(StandardCharsets.UTF_8))) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN, "Invalid CSRF token");
return;
}
reviews.add(new Review(req.getParameter("book"), req.getParameter("text")));
resp.sendRedirect(req.getContextPath() + "/reviews");
}
<form method="post" action="${pageContext.request.contextPath}/reviews">
<input type="hidden" name="csrf" value="<c:out value="${sessionScope.csrf}"/>">
<input type="text" name="book">
<input type="text" name="text">
<button type="submit">Add review</button>
</form>
The run: a POST without a session answered 403, a POST with a session but without the field answered 403, and a POST with the
token found in the rendered form answered 302 to /g7security/reviews and added the review. The redirect after the post
is the post/redirect/get pattern, which also prevents a duplicate on reload. The token is 43 characters of URL-safe base 64
for 32 random bytes; MessageDigest.isEqual compares in time that does not depend on how many leading characters match.
A JSP page that renders a form must write the token (a tag file that every form uses is the way to not forget it), and a
page that is served from a cache must not contain a token. Tomcat also ships a CsrfPreventionFilter that adds tokens to
URLs and checks them, and Spring Security and Struts have their own mechanisms; the principle is the same.
File Uploads: Never Into the Web Application
A cover upload is a way for a stranger to put a file on the server, and the CVE list below is mostly the story of what happens when that file ends up where the container can execute or overwrite it. Four rules:
-
Store outside the web application. An uploaded file in the public tree is a candidate for being executed or served with the wrong content type; one under
WEB-INFis not served, but is still inside the deployment, which can be replaced. -
Choose the name yourself. A generated name (a UUID) and an extension picked by the server from the validated content type; never the name the client sent, which can contain
..or a path. -
Allow-list the type and limit the size. The container enforces the limits that
@MultipartConfigdeclares. -
Serve it back through a servlet that sets
Content-TypeandX-Content-Type-Options: nosniff, not by a static link.
@WebServlet("/covers")
@MultipartConfig(maxFileSize = 1_048_576, maxRequestSize = 1_100_000)
public class CoverServlet extends HttpServlet {
private static final Set<String> ALLOWED = Set.of("image/png", "image/jpeg");
private final Path storage = Path.of(System.getProperty("java.io.tmpdir"), "bookshelf-covers");
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
Part part = req.getPart("cover");
if (part == null || !ALLOWED.contains(part.getContentType())) {
resp.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "Only PNG or JPEG covers");
return;
}
String extension = part.getContentType().equals("image/png") ? ".png" : ".jpg";
Files.createDirectories(storage);
Path target = storage.resolve(UUID.randomUUID() + extension);
try (InputStream in = part.getInputStream()) {
Files.copy(in, target, StandardCopyOption.REPLACE_EXISTING);
}
resp.setContentType("text/plain;charset=UTF-8");
resp.getWriter().println("stored " + target.getFileName() + " outside the web root");
}
}
The run, with curl -F: a PNG file answered stored ca1be081-....png outside the web root; a file with the content type
application/x-jsp answered 415; a 2 MB file with the type image/png answered 413 (over the 1 MB limit). The content type
is a claim of the client, so a production application also checks the file signature (the first bytes) and, for images,
decodes and re-encodes the image, which destroys most polyglot files. The file-upload cheat sheet has the full list,
and the multipart API is on the Servlet fundamentals page.
The Default Servlet: readonly Stays True
Static files, welcome files and (with listings) directory pages are served by the Default Servlet that Tomcat registers for
every web application in its global conf/web.xml. Its readonly initialisation parameter is true by default, and the
security guide says what changing it means: clients can delete or modify static resources and upload new ones, so it should
not be changed without requiring authentication. The two behaviours, side by side on the Bookshelf:
curl -s -D - -o /dev/null -X PUT -d x=1 http://localhost:18080/g7security/exposed/evil.txt | head -1
HTTP/1.1 405
and the same request against a second copy of the WAR whose web.xml overrides the servlet named default with readonly
set to false:
<servlet>
<servlet-name>default</servlet-name>
<servlet-class>org.apache.catalina.servlets.DefaultServlet</servlet-class>
<init-param>
<param-name>readonly</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>1</load-on-startup>
</servlet>
PUT /g7securityw/uploaded.txt -> 201 GET /g7securityw/uploaded.txt -> hello
PUT /g7securityw/uploaded.jsp -> 404 (the JSP servlet handles *.jsp and does not store the body)
PUT /g7securityw/exposed/x.txt -> 201
With the parameter set to false anyone can create files in the web application; the .jsp request was refused here because
the container maps *.jsp to the JSP servlet, but the history of the next table shows that the protection is thin: a
write-enabled Default Servlet is the root of CVE-2017-12617 (a JSP uploaded with PUT is executed), of CVE-2024-50379 and
CVE-2024-56337 (a race on case-insensitive file systems that treats an uploaded file as a JSP) and a prerequisite of
CVE-2025-24813 (a partial PUT). The rule is short: leave readonly alone, and do not use PUT and DELETE for uploads at
all, use a servlet with the rules of the previous section. Both scratch copies of the WAR were removed after the test.
Hardening Tomcat
The Tomcat security considerations page is the checklist of the container; it says that Tomcat is reasonably secure for most use cases by default and that some environments need more. The items that matter for a JSP deployment are below; the ones marked "observed" were run on the Bookshelf, the others were checked against the documentation of Tomcat 11.
| Item | What to do | How it was checked |
|---|---|---|
run as an unprivileged user |
a dedicated account with the minimum rights, no remote login; the distribution’s file permissions and umask are set for it |
documentation |
remove the default web applications |
|
documentation |
do not expose the management applications |
if they stay, restrict them with a |
observed in the shipped file |
disable the shutdown port |
|
documentation |
remove unused connectors; keep AJP off |
AJP is clear text and trusted more than HTTP; if it is needed, bind |
documentation, shipped |
hide the version |
|
observed |
do not leak stack traces or source |
the Tomcat guide says the default error report can show stack traces and JSP source to clients; use |
documentation |
lock out brute force |
wrap the realm in a |
observed in the shipped file |
keep the container current |
the CVE table below: every Tomcat issue listed there was fixed by a patch release of each supported line |
documentation |
default servlet |
|
observed |
The valve of the Bookshelf is declared in META-INF/context.xml, which affects only this web application (a server-wide valve
goes into server.xml or conf/context.xml; a hosted environment that does not trust its tenants sets the host’s
deployXML to false so that a packaged context.xml is ignored):
<Context>
<Valve className="org.apache.catalina.valves.ErrorReportValve" showReport="false" showServerInfo="false"/>
</Context>
A request for a missing page then had a body that carried only the status heading:
<h1>HTTP Status 404 – Not Found</h1>
whereas an application without the valve (another WAR of the same server) printed, besides the heading, a "Type" and a "Message"
with the requested path, a "Description", and the footer Apache Tomcat/11.0.26. The Tomcat documentation says that disabling both
attributes leaves only the HTTP status code. The following server.xml fragments are shown from the
documentation and were not applied to the shared test server:
<Server port="-1" shutdown="SHUTDOWN">
<!-- no AJP connector unless the front end needs it; if it does: -->
<Connector protocol="AJP/1.3" address="::1" port="8009" secret="CHANGE_ME_PLACEHOLDER" secretRequired="true" redirectPort="8443"/>
</Server>
The secret in the snippet is a placeholder, as everywhere in this section: a real one comes from the environment or a secrets store, never from a file in the repository.
Source: Apache Tomcat 11 — AJP Connector
Authentication and Authorisation
An application that authenticates in its own servlet, with a table of users and a hand-made "logged in" flag in the session, as
the books of the early 2000s did, owns every defect that comes with it: unhashed passwords, a missing session change, no lock-out
and a check that some page forgets. The platform’s alternatives are, in the order of preference for a new application, Jakarta
Security or Spring Security (see the Spring Security page), or at least the container’s declarative
security: a security-constraint names URLs and roles, a login-config the mechanism, and the realm of the server holds the
users.
<security-constraint>
<web-resource-collection>
<web-resource-name>Bookshelf administration</web-resource-name>
<url-pattern>/admin/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>bookshelf-admin</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>BASIC</auth-method>
<realm-name>Bookshelf</realm-name>
</login-config>
<security-role>
<role-name>bookshelf-admin</role-name>
</security-role>
An anonymous request for /admin/index.html answered 401 with WWW-Authenticate: Basic realm="Bookshelf", charset=UTF-8.
The run did not log in: the shared server has no user for the role and the page never contains a password. The facts to
know are those of the Tomcat guide. BASIC and FORM send the user name and the password in clear text, so they are
for HTTPS only (combine the constraint with CONFIDENTIAL). The realms do not lock accounts by default, and the MemoryRealm
and the UserDatabaseRealm are not for large or changing installations. FORM login is a page of the application that
posts to j_security_check. A hashed password store, a per-user salt,
and a real identity provider are the production answer. Hand-made login code in a filter (HttpSession attribute user) is
still seen; where it exists it must at least call changeSessionId at login, throw the session away at logout and compare
secrets in constant time.
The HttpServletRequest methods isUserInRole, getUserPrincipal and getRemoteUser read the container’s identity, so a page
can hide a link with ${pageContext.request.isUserInRole('bookshelf-admin')} (hiding a link is cosmetic, the constraint is
the enforcement).
The CVE List
The vulnerabilities below were checked on 2026-10-08 on the Tomcat 9, 10 and 11 security pages and on the CVE Program records; the Struts rows were checked against the Struts security bulletins. A CVE id is shown only after that check. "Fixed in" lists the first release of each line that contains the fix; every release of the Tomcat 11.0 line after 11.0.3 contains all of them, and the application of this page runs on 11.0.26.
| CVE | What | Affected | Fixed in |
|---|---|---|---|
XSS in generated JSPs: a fix for an improvement made pooled JSP tags not be released, so the output of some tags was not escaped. Rated Important; reported in the public tracker |
11.0.0; 10.1.31; 9.0.96 |
11.0.1; 10.1.33; 9.0.97 |
|
Remote code execution with a write-enabled Default Servlet on a case-insensitive file system: a concurrent read and upload of the same file bypassed the case checks and the file was treated as a JSP (the CVE record calls it a TOCTOU race in JSP compilation) |
11.0.0-M1 to 11.0.1; 10.1.0-M1 to 10.1.33; 9.0.0.M1 to 9.0.97 |
11.0.2; 10.1.34; 9.0.98 |
|
the mitigation of the previous issue was incomplete; Java 17 users on such a file system also need |
same versions |
same versions (11.0.2 or later; 10.1.34 or later; 9.0.98 or later) |
|
information disclosure, content injection and, with file-based session persistence and a deserialisation gadget in the application, remote code execution through a partial PUT, when the Default Servlet is write enabled |
11.0.0-M1 to 11.0.2; 10.1.0-M1 to 10.1.34; 9.0.0.M1 to 9.0.98 |
11.0.3; 10.1.35; 9.0.99 |
|
with HTTP PUT enabled (Default Servlet |
9.0.0.M1 to 9.0.0 (also 8.5, 8.0 and 7.0 releases in the CVE record) |
9.0.1 on the 9.0 line |
|
CVE-2020-1938 ("Ghostcat") |
the AJP connector trusted incoming connections: an attacker with access to the AJP port could read any file of a web application, including under |
9.0.0.M1 to 9.0.30; 8.5.0 to 8.5.50; 7.0.0 to 7.0.99 (CVE record) |
9.0.31, which also changed the default AJP connector configuration to harden it |
| Id | What | Affected | Fixed in |
|---|---|---|---|
CVE-2017-5638 (S2-045) |
remote code execution through the Jakarta Multipart parser with a malicious |
2.3.5 to 2.3.31 and 2.5 to 2.5.10 |
2.3.32; 2.5.10.1 |
CVE-2023-50164 (S2-066) |
file-upload path traversal that could lead to uploading a malicious file |
2.0.0 to 2.3.37 (EOL), 2.5.0 to 2.5.32, 6.0.0 to 6.3.0 |
2.5.33; 6.3.0.2 |
CVE-2024-53677 (S2-067) |
a flaw in the file upload logic that mixes the setters of uploaded files and of normal fields |
see the bulletin |
see the bulletin |
the Commons BeanUtils class loader manipulation through the |
Struts 1.x through 1.3.10 and BeanUtils through 1.9.2 (CVE record) |
no Struts 1 release; upgrade BeanUtils, or leave Struts 1 |
The bulletins S2-045, S2-066 and S2-067 are at the Apache Struts wiki. The later Struts bulletins (S2-068 and the 2026 ones, among them an OGNL injection in the legacy RESTful action mapper) and their fixed-in versions are on the Struts security bulletins page of this section.
Two lessons repeat in the table. First, five of the six Tomcat entries need the Default Servlet to be writable or AJP to be reachable: configuration the application of this page does not have. Second, the one entry about JSP output (CVE-2024-52318) was a regression in the container, fixed within weeks, which is what makes a current patch release part of the defence. The classic custom tags page has the technical side in the pooling section.
Source: Apache Struts — Bulletin S2-045
Source: Apache Struts — Bulletin S2-066
Source: Apache Struts — Bulletin S2-067
Injection Into Queries and Redirects
The output encoders above protect the browser. Two more places take request text and hand it to something that gives it
meaning: the database and the Location header. The Bookshelf search of this page reads q from the request and the
catalogue page of the MVC with Servlets & JSP page passes it
to a repository. If the repository builds the SQL by concatenation, a q of zzz%' OR title LIKE '% closes the string
and adds a condition that is always true. With a bound parameter the same text is only a string to look for. The listing is
the pair of methods that a scratch run exercised against an in-memory H2 database with two books (Dune and Emma).
static List<String> concatenated(Connection c, String q) throws SQLException {
List<String> out = new ArrayList<>();
try (Statement st = c.createStatement();
ResultSet rs = st.executeQuery("SELECT title FROM book WHERE title LIKE '%" + q + "%'")) {
while (rs.next()) out.add(rs.getString(1));
}
return out;
}
static List<String> bound(Connection c, String q) throws SQLException {
List<String> out = new ArrayList<>();
try (PreparedStatement ps = c.prepareStatement("SELECT title FROM book WHERE title LIKE ?")) {
ps.setString(1, "%" + q + "%");
try (ResultSet rs = ps.executeQuery()) {
while (rs.next()) out.add(rs.getString(1));
}
}
return out;
}
static String safeTarget(String contextPath, String next) {
if (next == null || !next.startsWith("/") || next.startsWith("//") || next.contains("\\")) {
return contextPath + "/books";
}
try {
URI u = URI.create(next);
if (u.getScheme() != null || u.getHost() != null) return contextPath + "/books";
} catch (IllegalArgumentException e) {
return contextPath + "/books";
}
return contextPath + next;
}
The output of the run, for the hostile q and for the harmless Du:
concatenated: [Dune, Emma]
bound: []
bound du: [Dune]
The concatenated query returned every book, including the ones that the search should not have matched; the bound query
returned none. A value can be bound, but a column name or a sort direction cannot, which is why the BookService of the
MVC page maps the sort parameter through a fixed map and never pastes it into the SQL. The sql:query tags of the
JSTL SQL and XML page bind with sql:param, but a query in a view is
the wrong place for any of this.
The second sink is the redirect. A login or a form that takes a next parameter and calls sendRedirect with it sends
the browser wherever the link says, which turns the application into a trusted launching pad for a phishing page.
safeTarget accepts only a path that starts with a single slash, and the same run printed:
/books?page=2 -> /g14sec/books?page=2
https://evil.example/ -> /g14sec/books
//evil.example/ -> /g14sec/books
/\evil.example -> /g14sec/books
books -> /g14sec/books
null -> /g14sec/books
A Review Checklist
| Check | Where it is settled |
|---|---|
every |
the page, one place per value |
no inline script that contains data; policy header with a nonce or with no inline scripts at all |
|
|
the page, |
no string from a request reaches |
code review, a search for those names |
|
|
every JSP is under |
the WAR layout |
uploads go to a directory outside the web application with generated names, type and size limits |
the servlet |
every query binds its values ( |
the repository, the controller |
every state change is a POST that carries and checks a token; cookies are |
|
session id changes at login; sessions expire; pages that do not need a session say |
the login code, the page directive |
errors show no stack trace, no version and no source |
|
Tomcat is a current patch release; |
the server |
dependencies ( |
|
The Same Ideas in Other Stacks
The rules do not depend on the view technology. The template security and XSS page
of the Thymeleaf section shows the same contexts with th:text and th:utext; the
Django security page and the ASP.NET Core security hardening page
have the framework’s own defaults, and the Spring Security page has the
authentication and CSRF machinery for a Spring Boot application. For the browser side, the CORS page explains
the cross-origin rules that interact with the cookie and token decisions above. The Struts counterpart of this page, the
Struts security hardening page, covers the framework side.
Pitfalls and Error Names
| Symptom or defect | Cause | Fix |
|---|---|---|
markup typed in a form appears as markup (observed) |
|
|
|
HTML entities are not decoded inside a script element |
|
|
the 1.5.0 encoder escapes the letters of "script" |
nothing: the value is equal and safe |
a link runs script (observed) |
|
allow-list the scheme before printing |
the |
the encoder is written for quoted values |
always quote attributes |
|
lower-case hex, |
use the output only inside a double-quoted attribute |
|
only the word "true" enables escaping |
omit the attribute |
a custom tag evaluates the request’s text (observed) |
a second evaluation of an already evaluated value |
no handler calls |
|
|
rewrite with EL and JSTL |
|
the JSP is in the public tree |
put it under |
a read-only page sends |
pages take part in the session by default |
|
login seems to loop on a plain HTTP test server |
the cookie is |
test over HTTPS, or on |
POST answered 403 |
no session, or the field is missing or wrong |
render the token in every form, and keep the session cookie |
an upload answers 413 or 415 (observed) |
over the size limit, or not an allowed content type |
the limits and the allow list work: tell the user |
files appear in the web application (observed on a copy with |
a write-enabled Default Servlet |
remove the override |
a 404 page names the Tomcat version (observed) |
the default error report valve |
|
|
the Default Servlet is read-only, the default |
leave it |
a CSP nonce that is the same on every response |
a constant in a template |
one |
the session id is the same before and after login |
no |
call it at login |
the Struts or Spring tag that evaluates a message is exploitable |
double evaluation of EL or OGNL |
do not pass request text as a message or an expression |
What Changed Recently
| Version | Change |
|---|---|
Tomcat 11.0.3, 10.1.35 and 9.0.99 (2025) |
the partial-PUT issue CVE-2025-24813 is fixed; the lesson is again that a write-enabled Default Servlet is dangerous |
Tomcat 11.0.2, 10.1.34 and 9.0.98 (December 2024) |
the incomplete mitigation CVE-2024-56337 is fixed; Java 21 no longer has the cache that needed the system property |
Tomcat 11.0.1, 10.1.33 and 9.0.97 (November 2024) |
CVE-2024-52318 (XSS from unreleased pooled tags) is fixed |
Tomcat 9.0.31 (2020) |
the default AJP connector configuration was hardened after CVE-2020-1938 (Ghostcat); the shipped |
OWASP Java Encoder 1.5.0 |
artifact |
Servlet 6.0 and 6.1 |
the |
Jakarta Pages 4.0 / EL 6.0 / Tags 3.0 |
no change to the escaping rules of |
Where the Books Are Outdated
The section bibliography lists Web Development with JavaServer Pages (Manning, 2001), whose chapters on filters and listeners, deployment and common tasks, and the example catalogue ("JSP by example"), and Struts in Action (Manning, 2002), whose last sample application uses container-managed security. Their concepts remain: declarative security, filters for authentication, the question of where a file lives. The advice around them does not hold.
| The books' era (JSP 1.2, Struts 1) | Today (Pages 4.0, Tomcat 11) |
|---|---|
application-managed login in a filter or a servlet, passwords in a table or a bean, a user attribute in the session |
container security, Jakarta Security or Spring Security; hashed passwords; |
|
|
JSP files in the web root; |
JSPs under |
|
scriptlets are switched off with |
|
|
no CSRF defence, because the browsers of the time did not need it |
tokens, |
no mention of Content-Security-Policy, HSTS or |
response headers as a defence in depth |
deployment advice written for the Tomcat of 2001, with the manager and the examples installed |
remove |
Struts 1 with its |
Struts 1 has been end of life since 2013 with unfixed issues such as CVE-2014-0114; read it only to migrate |
a servlet that accepts file uploads into the web application folder |
storage outside the web application, generated names, limits, type checks |
Source: Manning — Struts in Action
References
-
OWASP — XSS Prevention Cheat Sheet, Output Encoding for HTML Contexts
-
OWASP — XSS Prevention Cheat Sheet, Output Encoding for HTML Attribute Contexts
-
OWASP — XSS Prevention Cheat Sheet, Output Encoding for JavaScript Contexts
-
OWASP — XSS Prevention Cheat Sheet, Output Encoding for URL Contexts
-
OWASP — Content Security Policy Cheat Sheet, Defense Against XSS
-
OWASP — CSRF Prevention Cheat Sheet, Synchronizer Token Pattern
-
OWASP — CSRF Prevention Cheat Sheet, SameSite Cookie Attribute
-
OWASP — SQL Injection Prevention Cheat Sheet, Prepared Statements
-
OWASP — Java Security Cheat Sheet, Injection Prevention in Java
-
Apache Tomcat 11 — Security Considerations, Default web applications
-
Apache Tomcat 11 — Cookie Processor, Standard Implementation
-
Apache Struts — Security Guide, Never expose JSP files directly
-
Manning — Web Development with JavaServer Pages, Second Edition