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.

Table 1. The files of the security WAR (context path /g7security)
File Role

WEB-INF/views/search.jsp

the search page: one request parameter q written in every context, once the wrong way and once the right way

WEB-INF/views/reviews.jsp and ReviewServlet

stored reviews, a hidden CSRF token, the check in doPost

CoverServlet

@MultipartConfig upload that stores outside the web root

RotateServlet

request.changeSessionId()

CspFilter

a nonce-based Content-Security-Policy header

EvalTag and WEB-INF/tlds/bookshelf.tld

a deliberately unsafe custom tag that evaluates its attribute as an expression

exposed/secret.jsp

a JSP in the public tree, to show what that means

WEB-INF/web.xml

scripting-invalid, cookie configuration, the Tomcat header filter and two security-constraint elements

META-INF/context.xml

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:

pom.xml: the OWASP Java Encoder for Jakarta Pages
<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.

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.

Table 2. Sources of untrusted text on a page
Source Examples in the Bookshelf

request parameters and the query string

${param.q}, ${paramValues.tag}

headers and cookies

${header['user-agent']}, ${cookie.theme.value}; a Referer header is attacker-chosen as well

the request path and path parameters

${pageContext.request.requestURI}, ${pageContext.request.pathInfo}

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 c:import of a remote page

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:

search.jsp: the same value, four ways
<%@ 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: &lt;b&gt;x&lt;/b&gt;&amp;&#034;&#039;</p>
<p id="fn">Results for: &lt;b&gt;x&lt;/b&gt;&amp;&#034;&#039;</p>
<p id="enc">Results for: &lt;b&gt;x&lt;/b&gt;&amp;&#34;&#39;</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.

flowchart TD v["Untrusted value from request, database or file"] --> q{"Where is it written?"} q -->|"HTML text between tags"| html["c:out, fn:escapeXml or e:forHtml"] q -->|"HTML attribute value in quotes"| attr["c:out or e:forHtmlAttribute, and always quote the attribute"] q -->|"Inside a script block or event handler"| js["e:forJavaScript, or better: no inline script, pass data in a data attribute or a JSON script block"] q -->|"Query string or path segment of a URL"| uri["c:url with c:param, or e:forUriComponent"] q -->|"Whole URL in href or src"| scheme["Allow only known schemes such as https, then c:out the value"] q -->|"CSS string or url"| css["e:forCssString or e:forCssUrl, or do not put data in CSS"] q -->|"Rich text with markup that must stay"| san["An HTML sanitizer with an allow list, never an encoder"] html --> out["Response"] attr --> out js --> out uri --> out scheme --> out css --> out san --> out out --> csp["Content-Security-Policy and X-Content-Type-Options limit the damage of a miss"]

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.

Table 3. Which encoder for which context (observed with encoder 1.5.0 on the search page)
Context Encoder What the run produced for q = <b>x</b>&"'

HTML text

<e:forHtml value="${param.q}"/>, c:out

<b>x</b>&"'

quoted HTML attribute

<e:forHtmlAttribute value="${param.q}"/> inside value="..."

<b>x</b>&"' (the > stays, which is harmless inside quotes)

JavaScript string literal

<e:forJavaScript value="${param.q}"/> inside '...'

\x3cb\x3ex\x3c\/b\x3e\x26\x22\x27

URL component

<e:forUriComponent value="${param.q}"/>

%3Cb%3Ex%3C%2Fb%3E%26%22%27

URL built by the tag library

c:url with c:param

%3cb%3ex%3c%2fb%3e%26%22' (lower-case hex, the apostrophe is left alone)

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

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 = '\&#039;;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 &#039; 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&amp;b=&#034;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:

reviews.jsp: reviews written by one user and read by all
<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: &lt;script&gt;alert(1)&lt;/script&gt;</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:

CspFilter.java: a policy with a per-request nonce
@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:

web.xml: Tomcat’s HTTP header security filter
<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":

EvalTag.java: unsafe on purpose, never write this
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:

Table 4. Double evaluation through the custom tag (q is the request parameter, observed)
q Result inside the paragraph

${1+1}

2

${header["user-agent"]}

curl/8.7.1

${pageContext.servletContext.serverInfo}

Apache Tomcat/11.0.26

${System.getProperty("java.version")}

21.0.6

${Runtime.getRuntime().availableProcessors()}

8

${applicationScope}

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.eval or 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 isELIgnored or deferred-syntax-allowed-as-literal only 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:

web.xml: no scripting anywhere
<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:

Table 5. Direct requests (observed on Tomcat 11.0.26)
Request Status Meaning

/exposed/secret.jsp

200

a JSP in the public tree is executed on request

/WEB-INF/views/search.jsp

404

the container does not serve WEB-INF

/WEB-INF/web.xml

404

not even the configuration

/exposed/

404

no directory listing, because the Default Servlet is configured with listings set to false

/search

200

the servlet forwards to the JSP under WEB-INF

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

web.xml: the cookie of the session
<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>
META-INF/context.xml: SameSite for every cookie of this web application
<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:

RotateServlet.java: a new id after authentication
@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:

web.xml: TLS only for a folder
<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:

  1. State changes only through POST (or PUT and DELETE), never GET: a link or an image tag can make a GET.

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

  3. SameSite cookies (above), which stop the cookie on cross-site POSTs.

  4. Optionally a check of the Origin or Referer header and Fetch Metadata headers, as a second line.

sequenceDiagram participant B as Browser participant S as ReviewServlet participant H as Session B->>S: GET reviews S->>H: store random csrf token S-->>B: form with hidden field csrf and the session cookie B->>S: POST reviews with csrf and the cookie S->>H: read the stored token alt token present and equal S-->>B: 302 to reviews else missing or different S-->>B: 403 Invalid CSRF token end
ReviewServlet.java: create the token on GET and compare in constant time on POST
@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");
}
reviews.jsp: the hidden field
<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:

  1. 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-INF is not served, but is still inside the deployment, which can be replaced.

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

  3. Allow-list the type and limit the size. The container enforces the limits that @MultipartConfig declares.

  4. Serve it back through a servlet that sets Content-Type and X-Content-Type-Options: nosniff, not by a static link.

CoverServlet.java: the Bookshelf’s cover upload
@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:

web.xml: the dangerous override (shown for the test, not for use)
<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.

Table 6. Tomcat hardening checklist for a JSP application
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

examples (always, for any security-sensitive installation), docs, ROOT, manager and host-manager unless needed; examples contains cookie pages that an attacker can chain with another vulnerability

documentation

do not expose the management applications

if they stay, restrict them with a RemoteCIDRValve (the context.xml of the shipped manager shows allow="127.0.0.0/8,::1/128")

observed in the shipped file

disable the shutdown port

<Server port="-1" shutdown="SHUTDOWN">; with a port, a strong shutdown word

documentation

remove unused connectors; keep AJP off

AJP is clear text and trusted more than HTTP; if it is needed, bind address to a loopback or a private address and set a secret (secretRequired is true by default)

documentation, shipped server.xml has the connector commented out with address="::1" (observed)

hide the version

ErrorReportValve with showServerInfo="false" and showReport="false"

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 error-page elements of your own (the error handling page)

documentation

lock out brute force

wrap the realm in a LockOutRealm; the shipped server.xml already does

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

readonly true, listings false (shipped defaults)

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

META-INF/context.xml: no report, no version
<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.

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.

web.xml: the admin area needs a role
<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.

Table 7. Apache Tomcat issues that concern JSP applications
CVE What Affected Fixed in

CVE-2024-52318

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

CVE-2024-50379

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

CVE-2024-56337

the mitigation of the previous issue was incomplete; Java 17 users on such a file system also need sun.io.useCanonCaches unset or false, Java 21 and later need nothing

same versions

same versions (11.0.2 or later; 10.1.34 or later; 9.0.98 or later)

CVE-2025-24813

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

CVE-2017-12617

with HTTP PUT enabled (Default Servlet readonly false) a JSP could be uploaded and then executed

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 WEB-INF, or have any file processed as a JSP

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

Table 8. Apache Struts issues named on this page
Id What Affected Fixed in

CVE-2017-5638 (S2-045)

remote code execution through the Jakarta Multipart parser with a malicious Content-Type header: an invalid value threw an exception whose message was then used in the error shown to the user; the best-known Struts incident

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

CVE-2014-0114

the Commons BeanUtils class loader manipulation through the class request parameter, with a Struts 1 ActionForm as the demonstrated path; Struts 1 reached end of life in 2013

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.

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

Concatenated query versus bound parameter, and a redirect target that stays inside the application
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

Table 9. Review checklist for a JSP web application
Check Where it is settled

every ${...} that prints user-influenced text is inside c:out, fn:escapeXml or an encoder for its context

the page, one place per value

no inline script that contains data; policy header with a nonce or with no inline scripts at all

CspFilter or the equivalent

href and src values from data have an allow-listed scheme

the page, c:choose or the controller

no string from a request reaches createValueExpression, ELProcessor, OGNL or Runtime.exec

code review, a search for those names

scripting-invalid is true for the whole application

web.xml

every JSP is under WEB-INF and reached through a servlet

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 (PreparedStatement, never concatenation) and every redirect target built from a parameter is checked against a path allow-list

the repository, the controller

every state change is a POST that carries and checks a token; cookies are Secure, HttpOnly and SameSite

web.xml, context.xml, the servlet

session id changes at login; sessions expire; pages that do not need a session say session="false"

the login code, the page directive

errors show no stack trace, no version and no source

error-page, ErrorReportValve

Tomcat is a current patch release; examples, docs, manager and AJP are gone or restricted; readonly is true

the server

dependencies (encoder, JSTL, the Jakarta APIs, the Struts jars) are current

pom.xml, a dependency check in the build

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

Table 10. Security pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
Symptom or defect Cause Fix

markup typed in a form appears as markup (observed)

${param.q} is not escaped

c:out or ${fn:escapeXml(...)}

c:out inside a script string shows &#039;, and a backslash breaks the literal (observed)

HTML entities are not decoded inside a script element

e:forJavaScript, or no data in scripts

forJavaScript output looks garbled, alert becomes ale\x72\x74 (observed)

the 1.5.0 encoder escapes the letters of "script"

nothing: the value is equal and safe

a link runs script (observed)

href="javascript:..." passes any encoder

allow-list the scheme before printing

the > stays in forHtmlAttribute output (observed)

the encoder is written for quoted values

always quote attributes

c:url prints %3c and ' as they are (observed)

lower-case hex, ' is not reserved in a query

use the output only inside a double-quoted attribute

c:out escapeXml="flase" prints raw markup

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 createValueExpression on request text

JasperException: ... Scripting elements ( ... ) are disallowed here. (observed)

scripting-invalid is true

rewrite with EL and JSTL

/exposed/secret.jsp runs without its servlet (observed)

the JSP is in the public tree

put it under WEB-INF

a read-only page sends Set-Cookie: JSESSIONID (observed)

pages take part in the session by default

<%@ page session="false" %>

login seems to loop on a plain HTTP test server

the cookie is Secure and the browser does not send it back over HTTP

test over HTTPS, or on localhost

POST answered 403 Invalid CSRF token (observed)

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 readonly false)

a write-enabled Default Servlet

remove the override

a 404 page names the Tomcat version (observed)

the default error report valve

showServerInfo and showReport false

HTTP/1.1 405 for PUT on a static file (observed)

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 SecureRandom value per response

the session id is the same before and after login

no changeSessionId

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

Table 11. Security-relevant changes
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 server.xml of 11.0.26 has the connector commented out

OWASP Java Encoder 1.5.0

artifact encoder-jakarta-jsp with the tag library URI owasp.encoder.jakarta; the javax namespace has its own artifact encoder-jsp

Servlet 6.0 and 6.1

the cookie-config of web.xml accepts generic attributes such as SameSite; request.changeSessionId() is available since Servlet 3.1

Jakarta Pages 4.0 / EL 6.0 / Tags 3.0

no change to the escaping rules of c:out and fn:escapeXml; the javax packages became jakarta in Jakarta EE 9

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.

Table 12. The books' material and its current equivalent
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; changeSessionId; placeholders only in examples

encodeURL everywhere so that sessions work without cookies

<tracking-mode>COOKIE</tracking-mode>; a session id in a URL is a leak

JSP files in the web root; jsp-file servlet mappings and "view source" and directory-index pages as examples

JSPs under WEB-INF, a servlet in front, no listings

<%= request.getParameter("q") %> and Runtime.exec as example code

scriptlets are switched off with scripting-invalid, and the values are encoded

out.println of form values, "sticky" form widgets by string concatenation

c:out and attribute encoding, per context

no CSRF defence, because the browsers of the time did not need it

tokens, SameSite and POST-only state changes

no mention of Content-Security-Policy, HSTS or X-Content-Type-Options

response headers as a defence in depth

deployment advice written for the Tomcat of 2001, with the manager and the examples installed

remove examples, docs and the manager, restrict what stays, keep the container patched

Struts 1 with its ActionForm and its validator, the Artimus sample application

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

References