Custom Tags: Classic Handlers
|
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 (API jakarta.servlet.jsp:jakarta.servlet.jsp-api 4.0.0, the specification
of Jakarta EE 11) together with Jakarta Expression Language 6.0, Jakarta Standard Tag Library 3.0 (API 3.0.2,
implementation 3.0.1) and Jakarta Servlet 6.1, running on Apache Tomcat 11.0.26 and Java 21. Everything was verified on
2026-10-08: the rules against chapter 7 of the Pages 4.0 specification, the Javadoc of the jakarta.servlet.jsp.tagext
package and the Tomcat 11.0 Jasper how-to, and every example by building it into a Maven WAR (context path
/g6classic) and running it on Tomcat 11.0.26 with curl; the outputs and error messages shown are the ones that run
produced. A classic tag handler is the oldest way to write a custom action in Java: a class that implements the Tag,
IterationTag or BodyTag interface, which the page’s generated code instantiates (or takes from a pool), configures
through setters and drives through a fixed protocol of doStartTag, doAfterBody and doEndTag calls whose return
codes steer what happens next. It is the model that JSP 1.1 introduced in 1999, and it is still fully supported in
Pages 4.0, but since JSP 2.0 the simpler SimpleTag API and tag files are the recommended way to write new tags. This page
explains the classic model in full (because the JSTL implementation, Struts 1 and a great deal of existing code are
classic handlers), shows every part of it on the Bookshelf scenario, and then explains the most dangerous property of
the model, the handler pooling that Tomcat applies to it, including the Tomcat vulnerability CVE-2024-52318 that
pooling caused. The SimpleTag model (Custom Tags: SimpleTag), the tag library descriptor in depth (Tag Library Descriptors & EL Functions) and tag files (Tag Files & Layouts) each have a page of their own in
this section; this page writes the descriptor only as far as a classic handler needs it. The expression language itself
is on the expression language basics page, and the Standard Tag
Library whose own handlers are classic ones is on the JSTL Core page.
What a Classic Handler Is and When to Use It
A custom action is an element such as <bs:shelf items="${books}">...</bs:shelf> whose prefix is bound by a taglib
directive to a tag library, a set of actions described by a tag library descriptor (TLD) and implemented by Java
classes. Chapter 7 of the Pages specification defines two ways to implement the Java side: classic tag handlers
(section 7.1.3) and simple tag handlers (section 7.1.5); and, since JSP 2.0, a third way that needs no Java at all, the
tag file. A classic handler is "a Java class that implements the Tag, IterationTag, or BodyTag interface, and is
the run-time representation of a custom action", as the specification puts it. The page implementation class
"instantiates a tag handler object, or reuses an existing tag handler object, for each action in the JSP page"; the second
half of that sentence is the pooling of the second half of this page.
| Model | What you write | Status in Pages 4.0 | Choose it when |
|---|---|---|---|
Tag file ( |
JSP syntax in |
current (JSP 2.0) |
the tag is mostly markup: layouts, components, formatting |
Simple tag handler |
a class implementing |
current (JSP 2.0) |
the tag needs Java but not the classic protocol; this is the default for new code |
Classic tag handler |
a class implementing |
supported, not deprecated; legacy by recommendation |
you maintain existing classic tags, you extend a library built on them (the JSTL |
What only classic handlers have is a short list: the return-code protocol itself, which lets one handler skip its
body, repeat it or end the page; a BodyContent buffer that the handler can read and rewrite; TryCatchFinally hooks
(the simple model has the doTag method and an ordinary try block instead); and compatibility with code that expects
a Tag, such as the JSTL base classes. Everything else is easier with a SimpleTag: there is no return-code protocol,
no pooling hazard (a simple handler is created for every use), no BodyContent, and the body is a JspFragment that
can be invoked any number of times. The practical rule is therefore:
read and fix classic handlers, extend the JSTL base classes when you need a conditional or loop tag compatible with
c:forEach and c:choose, and write everything else as a tag file or a simple handler.
The Bookshelf Tag Library Used on This Page
All the examples below are classes of one WAR, deployed under the context path /g6classic on Tomcat 11.0.26 as
g6classic.war. The classes are in the package org.example.bookshelf.tags; imports and the package line are left out
of the listings. A ServletContextListener puts the four books of the catalogue into the application attribute books
(a record Book(long id, String title, String author, int year): Dune 1965, Neuromancer 1984, The Dispossessed 1974,
Cosmos 1980), and the tag library bs is declared in WEB-INF/bs.tld with the URI https://example.org/bookshelf/classic.
The Maven dependencies are the same as for the rest of this section: the Jakarta Servlet, Pages and Expression Language
APIs with scope provided, jakarta.annotation-api 3.0.0 with scope provided (for the injection example), and the
JSTL API 3.0.2 and implementation 3.0.1 (the latter two inside the WAR, because Tomcat does not ship JSTL).
| Handler | Base | Demonstrates |
|---|---|---|
|
|
a plain conditional tag: |
|
|
iteration with |
|
|
a cooperating tag: |
|
|
|
|
|
|
|
|
an |
|
|
the JSTL base classes |
|
|
pooling: state that leaks, and state that does not |
|
|
injection callbacks, |
The Interfaces and the Support Classes
Three interfaces build on each other, and two classes implement them so that a handler overrides only what it needs. The
specification states the division of labour: Tag has the setters that initialise the handler and the doStartTag and
doEndTag methods; IterationTag adds doAfterBody, which is called to decide whether to evaluate the body again; and
BodyTag adds setBodyContent and doInitBody for a handler that wants the body in a buffer. TryCatchFinally is a
"mix-in" that can be added to any of the three.
| Type | Methods and constants | Role |
|---|---|---|
|
|
the basic protocol; setters first, then |
|
|
repeat the body any number of times |
|
|
receive the evaluated body in a |
|
|
guarantee cleanup when the body or a method throws |
|
default methods; |
the usual base class; |
|
|
the usual base class for a handler that processes its body |
|
|
the buffer that holds the body of a |
|
|
translation-time information: scripting variables and attribute validation |
|
|
the container passes a page-unique id to the handler |
Source: Jakarta Pages 4.0 API — Tag
The Lifecycle and the Return Codes
For every use of a custom action the generated code does the same things in the same order: get a handler instance,
call setPageContext and setParent, call one setter for every attribute that the page supplied, then call
doStartTag. What that returns decides whether and how the body runs. After the body, doEndTag runs, and what that
returns decides whether the rest of the page runs. A handler that implements TryCatchFinally is protected by a
try block around all of this. When the use ends, the handler goes back to a pool (or is discarded); release() is
not called between two uses.
one setter per attribute"] set --> start["doStartTag()"] start -->|"SKIP_BODY"| endt start -->|"EVAL_BODY_INCLUDE"| body["Evaluate the body
into the current out"] start -->|"EVAL_BODY_BUFFERED
(BodyTag only)"| sbc["setBodyContent(buffer)
pushBody"] sbc --> init["doInitBody()"] init --> body2["Evaluate the body
into the BodyContent buffer"] body --> after["doAfterBody()"] body2 --> after after -->|"EVAL_BODY_AGAIN"| again{"Buffered?"} again -->|"no"| body again -->|"yes"| body2 after -->|"SKIP_BODY"| pop["popBody (buffered case)"] pop --> endt["doEndTag()"] endt -->|"EVAL_PAGE"| rest["The rest of the page is evaluated"] endt -->|"SKIP_PAGE"| stop["The rest of the page is skipped:
request completes"] rest --> reuse["Handler goes back to the pool
or is destroyed"] reuse -.->|"only when the pool or page is discarded"| rel["release()"] start -.->|"exception in any step"| tcf["doCatch(t), then doFinally()
(TryCatchFinally handlers)"]
Four sets of constants steer the protocol, and each is legal only from certain methods.
| Constant | Declared in | Meaning and rules |
|---|---|---|
|
|
from |
|
|
from |
|
|
from |
|
|
from |
|
|
from |
|
|
from |
The protocol is not abstract: it is the Java that Jasper writes into the page’s servlet. The listing is the loop that
Tomcat 11.0.26 generated for the iteration tag of this page (bs:shelf, shown in full below), trimmed to the calls that
matter, with the generated names shortened; it was read from the work directory of the web application after the first
request.
IterationTag (trimmed)ShelfTag th = (ShelfTag) _jspx_tagPool_bs_shelf_items_id.get(ShelfTag.class);
boolean reused = false;
try {
th.setPageContext(_jspx_page_context);
th.setParent(null);
th.setId("book");
th.setItems((java.util.List) PageContextImpl.proprietaryEvaluate("${books}", java.util.List.class, ...));
int eval = th.doStartTag();
if (eval != Tag.SKIP_BODY) {
org.example.bookshelf.Book book = null;
book = (org.example.bookshelf.Book) _jspx_page_context.findAttribute("book"); // NESTED variable
do {
// ... the body is written here ...
int evalDoAfterBody = th.doAfterBody();
book = (org.example.bookshelf.Book) _jspx_page_context.findAttribute("book");
if (evalDoAfterBody != BodyTag.EVAL_BODY_AGAIN) break;
} while (true);
}
if (th.doEndTag() == Tag.SKIP_PAGE) { return; }
_jspx_tagPool_bs_shelf_items_id.reuse(th);
reused = true;
} finally {
JspRuntimeLibrary.releaseTag(th, _jsp_getInstanceManager(), reused);
}
Three things in the listing explain most classic-tag behaviour. The pool is keyed by the tag and the set of attributes
used (the field name carries shelf, items and id), so two uses of the same tag with different attributes never
share an instance. The body is a do ... while loop that doAfterBody controls. And the handler is returned to the pool
(reuse) only on the normal path; if an exception escapes, releaseTag is called with reused false and the handler
is thrown away (after release() and, if the container manages instances, destruction).
For a BodyTag that returns EVAL_BODY_BUFFERED, the generated code additionally brackets the loop with
JspRuntimeLibrary.startBufferedBody (which pushes a BodyContent onto the page context) and popBody; the handler
sees the buffer through setBodyContent, doInitBody and getBodyContent.
A Plain and a Conditional Tag
The simplest handler overrides doStartTag only. RecentTag decides, from two attributes, whether its body is
evaluated; this is the "conditional" of the specification, done with the return value of doStartTag and nothing else.
Attributes arrive through setters whose names follow the JavaBeans rule, one per <attribute> in the TLD, and the
handler resets its state in release().
public class RecentTag extends TagSupport {
private Book book;
private int since = 1980;
public void setBook(Book book) { this.book = book; }
public void setSince(int since) { this.since = since; }
@Override
public int doStartTag() throws JspException {
return book.year() >= since ? EVAL_BODY_INCLUDE : SKIP_BODY;
}
@Override
public void release() {
book = null;
since = 1980;
super.release();
}
}
The TLD entry is what makes the class a tag. The element names name, tag-class, body-content and attribute (with
name, required, rtexprvalue and type) are the ones of the Pages 4.0 TLD schema, web-jsptaglibrary_4_0.xsd;
rtexprvalue set to true means that the attribute accepts a ${...} expression and other request-time values. The full
schema, the location rules for descriptors and how Tomcat finds them are the subject of the tag library descriptor page
of this section.
bs:recent, inside the taglib element of WEB-INF/bs.tld<tag>
<name>recent</name>
<tag-class>org.example.bookshelf.tags.RecentTag</tag-class>
<body-content>JSP</body-content>
<attribute>
<name>book</name>
<required>true</required>
<rtexprvalue>true</rtexprvalue>
<type>org.example.bookshelf.Book</type>
</attribute>
<attribute>
<name>since</name>
<rtexprvalue>true</rtexprvalue>
<type>int</type>
</attribute>
</tag>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<%@ taglib prefix="bs" uri="https://example.org/bookshelf/classic" %>
<ul>
<c:forEach var="b" items="${applicationScope.books}">
<li>${b.title}<bs:recent book="${b}" since="1980"> (recent)</bs:recent></li>
</c:forEach>
</ul>
curl http://localhost:18080/g6classic/index.jsp (blank lines removed)<ul>
<li>Dune</li>
<li>Neuromancer (recent)</li>
<li>The Dispossessed</li>
<li>Cosmos (recent)</li>
</ul>
A handler that implements only Tag and returns EVAL_BODY_INCLUDE cannot post-process the body; it can only let the
body through or suppress it. That is exactly what c:if does, and it is why the JSTL ships a base class for it (see
below).
Iteration and Scripting Variables
An IterationTag repeats its body: doStartTag prepares the first item and returns EVAL_BODY_INCLUDE (or SKIP_BODY
for an empty collection), and every doAfterBody either prepares the next item and returns EVAL_BODY_AGAIN or returns
SKIP_BODY to leave the loop. The item is handed to the body by storing it in a scope of the page context, which is
what a classic handler uses for every result: pageContext.setAttribute(name, value). ShelfTag uses the id
attribute that TagSupport already provides as the variable name, and removes the variable in doEndTag, so that it does
not outlive the tag.
public class ShelfTag extends TagSupport {
private List<Book> items;
private Iterator<Book> iterator;
private int position;
public void setItems(List<Book> items) { this.items = items; }
public int getPosition() { return position; }
@Override
public int doStartTag() throws JspException {
iterator = items.iterator();
position = 0;
return export() ? EVAL_BODY_INCLUDE : SKIP_BODY;
}
@Override
public int doAfterBody() throws JspException {
return export() ? EVAL_BODY_AGAIN : SKIP_BODY;
}
@Override
public int doEndTag() throws JspException {
pageContext.removeAttribute(getId());
return EVAL_PAGE;
}
private boolean export() {
if (!iterator.hasNext()) {
return false;
}
pageContext.setAttribute(getId(), iterator.next());
position++;
return true;
}
@Override
public void release() {
items = null;
iterator = null;
position = 0;
super.release();
}
}
Because the item is a scoped attribute, the body can read it with EL (${book.title}) without any further declaration. A
scripting variable is something more: a Java local variable that the translator declares in the page so that a
scriptlet or an expression can use it as book. The page must be told about it at translation time, and there are two
ways, which the specification describes as "directly in the TLD for simple cases, or through subclasses of
TagExtraInfo`". Whichever way declares it, the handler still stores the object with `pageContext.setAttribute; the
translator generates the code that copies the attribute into the local variable ("synchronisation") at the right moments.
The moments are the scope of the variable.
| Scope | Synchronised | Typical use |
|---|---|---|
|
between the start tag and the end tag: before the body and after every |
the current item of a loop |
|
from the start tag to the end of the enclosing scope: after |
a value created at the start that also belongs after the tag |
|
after the end tag only |
a result that exists once the tag has finished |
ShelfTagExtraInfo declares the variable named by the id attribute, of type org.example.bookshelf.Book, with
declaration (true, a new Java variable) and NESTED scope. It is wired in with <tei-class> in the TLD entry of
bs:shelf. The TagData argument carries the attribute values known at translation time; getId() is the id
attribute, which is why id is declared with rtexprvalue false.
bs:shelfpublic class ShelfTagExtraInfo extends TagExtraInfo {
@Override
public VariableInfo[] getVariableInfo(TagData data) {
return new VariableInfo[] {
new VariableInfo(data.getId(), "org.example.bookshelf.Book", true, VariableInfo.NESTED)
};
}
}
<tag>
<name>shelf</name>
<tag-class>org.example.bookshelf.tags.ShelfTag</tag-class>
<tei-class>org.example.bookshelf.tags.ShelfTagExtraInfo</tei-class>
<body-content>JSP</body-content>
<attribute>
<name>id</name>
<required>true</required>
<rtexprvalue>false</rtexprvalue>
</attribute>
<attribute>
<name>items</name>
<required>true</required>
<rtexprvalue>true</rtexprvalue>
<type>java.util.List</type>
</attribute>
</tag>
A TagExtraInfo can do more than declare variables: its isValid and validate methods are called at translation time
to check the attributes of every use of the tag, which is how a library reports "attribute a and b are mutually
exclusive" as a translation error instead of a request-time failure (specification section 7.1.2, "Validation"). The
variable element of the TLD is the alternative for the simple cases, shown below with bs:count.
<ol>
<bs:shelf id="book" items="${applicationScope.books}">
<li>#<bs:position/> ${book.title} / <%= book.author() %></li>
</bs:shelf>
</ol>
after: ${empty pageScope.book ? 'book removed' : 'book still there'}
<ol>
<li>#1 Dune / Frank Herbert</li>
<li>#2 Neuromancer / William Gibson</li>
<li>#3 The Dispossessed / Ursula K. Le Guin</li>
<li>#4 Cosmos / Carl Sagan</li>
</ol>
after: book removed
The scriptlet <%= book.author() %> compiles because the translator declared book as a Book; that is the only
place on this page where a scriptlet appears, because it is the only way to see a scripting variable. In a body that the
TLD declares scriptless, or on a page whose property group sets scripting-invalid, scriptlets are not allowed and the
container "need not synchronize" the variables; the EL reads the page-context attribute directly and always works.
A Scripting Variable Declared in the TLD
For a variable whose name is a literal or comes from an attribute, no TagExtraInfo class is needed: the TLD variable
element declares it with name-given or name-from-attribute, variable-class, declare and scope. CountTag
exports the size of a list under the name given in var, with AT_END scope, so the variable exists after the tag.
variable elementpublic class CountTag extends TagSupport {
private List<?> items;
private String var;
public void setItems(List<?> items) { this.items = items; }
public void setVar(String var) { this.var = var; }
@Override
public int doEndTag() throws JspException {
pageContext.setAttribute(var, items.size());
return EVAL_PAGE;
}
@Override
public void release() {
items = null;
var = null;
super.release();
}
}
<tag>
<name>count</name>
<tag-class>org.example.bookshelf.tags.CountTag</tag-class>
<body-content>empty</body-content>
<variable>
<name-from-attribute>var</name-from-attribute>
<variable-class>java.lang.Integer</variable-class>
<declare>true</declare>
<scope>AT_END</scope>
</variable>
<attribute>
<name>items</name>
<required>true</required>
<rtexprvalue>true</rtexprvalue>
<type>java.util.List</type>
</attribute>
<attribute>
<name>var</name>
<required>true</required>
<rtexprvalue>false</rtexprvalue>
</attribute>
</tag>
total = 4; as scriptlet: 4<bs:count items="${applicationScope.books}" var="total"/>
<p>total = ${total}; as scriptlet: <%= total %></p>
Cooperating Tags
Two actions can cooperate "explicitly, via scoped variables" (one creates an object under a name and the other reads it
by that name) or "implicitly", through their nesting. For the second form the container gives every handler its parent
through setParent, and TagSupport.findAncestorWithClass(from, class) walks up the chain and returns the nearest
ancestor that is an instance of the class or implements the interface. PositionTag finds the enclosing ShelfTag and
prints its position; used outside a shelf it reports the mistake with a JspTagException, which is the standard way to
say "this tag is in the wrong place" (a translation-time check in a TagExtraInfo or a validator is better when it is
possible, because the author sees the error before any request).
public class PositionTag extends TagSupport {
@Override
public int doStartTag() throws JspException {
ShelfTag shelf = (ShelfTag) findAncestorWithClass(this, ShelfTag.class);
if (shelf == null) {
throw new JspTagException("bs:position must be nested in bs:shelf");
}
try {
pageContext.getOut().print(shelf.getPosition());
} catch (IOException e) {
throw new JspTagException(e);
}
return SKIP_BODY;
}
}
<bs:position/> outside a shelf (HTTP 500; the root cause shown by Tomcat 11.0.26)jakarta.servlet.jsp.JspTagException: bs:position must be nested in bs:shelf
org.example.bookshelf.tags.PositionTag.doStartTag(PositionTag.java:14)
org.apache.jsp.orphan_jsp._jspx_meth_bs_005fposition_005f0(orphan_jsp.java:162)
The parent of a handler is the nearest enclosing custom action, whichever library it comes from, so a tag nested in
c:if inside bs:shelf still finds the shelf even though the c:if handler is in between. A page of
<bs:shelf ...><c:if test="${book.year gt 1970}">[<bs:position/> ${book.title}]</c:if></bs:shelf> printed
[2 Neuromancer][3 The Dispossessed][4 Cosmos] on Tomcat 11.0.26: the position is that of the shelf, not of the c:if.
Body Tags: BodyTagSupport and BodyContent
A handler that wants to see, change or discard its body returns EVAL_BODY_BUFFERED from doStartTag (that is the
default of BodyTagSupport). The container then calls setBodyContent with a new BodyContent, a JspWriter that
collects everything the body writes, calls doInitBody (the place to prepare the first pass), evaluates the body into the
buffer, and calls doAfterBody. In doAfterBody the handler reads the text with getBodyContent().getString() (or
getReader()), and writes the result to the writer that was current before the buffer was pushed. That writer is
getPreviousOut() and it is the single most common source of errors for beginners: writing to pageContext.getOut()
inside a buffered doAfterBody writes into the very buffer that is about to be discarded, so the output is lost.
public class ShoutTag extends BodyTagSupport {
@Override
public int doStartTag() {
return EVAL_BODY_BUFFERED;
}
@Override
public int doAfterBody() throws JspException {
try {
String text = getBodyContent().getString();
getPreviousOut().print(text.toUpperCase(Locale.ROOT));
} catch (IOException e) {
throw new JspTagException(e);
}
return SKIP_BODY;
}
}
What the body is depends on the body-content element of the TLD, which also decides what the translator accepts. The
same ShoutTag class is declared three times in the example TLD (bs:shout with JSP, bs:rawshout with
tagdependent, bs:plainshout with scriptless) to show the difference.
| Value | What the body may contain | What the handler receives |
|---|---|---|
|
nothing; a body is a translation error: |
nothing; |
|
template text, EL, standard and custom actions, scripting elements |
the evaluated output, as text |
|
template text, EL, standard and custom actions, but no scripting: |
the evaluated output, as text; the usual value for simple tags and tag files |
|
anything, but the body is passed through unprocessed: EL, nested actions and scripting are not evaluated |
the raw text, exactly as written |
b holds Dune)<c:set var="b" value="${applicationScope.books[0]}"/>
jsp: <bs:shout>${b.title} <i>x</i></bs:shout>
tagdependent:<bs:rawshout>${b.title} <i>x</i></bs:rawshout>
scriptless: <bs:plainshout>${b.title}</bs:plainshout>
jsp: DUNE <I>X</I>
tagdependent:${B.TITLE} <I>X</I>
scriptless: DUNE
The tagdependent line is the reason that value exists: a tag that embeds another language in its body (a code
sample, a SQL fragment, a JavaScript template) must not have its text interpreted as EL. Note also what the example
shows about security: the handler transformed markup that was already part of the page, so nothing it does makes the text
safe or unsafe; a body tag that echoes request data must escape it (see Security Notes below).
TryCatchFinally: Cleaning Up Reliably
A handler that opens something in doStartTag (a lock, a transaction, a counter, a temporary file) cannot rely on
doEndTag to close it, because an exception thrown by the body or by a nested tag skips doEndTag. The specification
added TryCatchFinally in JSP 1.2 for exactly this, and defines the call pattern: doCatch(t) is called if an
exception is thrown from doStartTag, from the body, from doAfterBody or from doEndTag (not from the setters), and
doFinally() is always called afterwards, on both paths. If doCatch returns normally the exception is swallowed; if it
throws, that is what propagates. release() and doFinally() are not the same thing: doFinally runs after every use
and is where per-use state is cleaned up.
public class GuardTag extends TagSupport implements TryCatchFinally {
static final String KEY = "openGuards";
private AtomicInteger counter() {
AtomicInteger c = (AtomicInteger) pageContext.getServletContext().getAttribute(KEY);
if (c == null) {
c = new AtomicInteger();
pageContext.getServletContext().setAttribute(KEY, c);
}
return c;
}
@Override
public int doStartTag() throws JspException {
counter().incrementAndGet();
return EVAL_BODY_INCLUDE;
}
@Override
public void doCatch(Throwable t) throws Throwable {
pageContext.getServletContext().log("guard saw " + t.getClass().getSimpleName());
throw t;
}
@Override
public void doFinally() {
counter().decrementAndGet();
}
}
BoomTag throws JspTagException("the shelf collapsed") from doStartTag. The page wraps the guard in c:catch,
which is itself a TryCatchFinally handler, so the exception is caught by the page and the request completes; the
counter shows that the guard closed.
c:catch<c:catch var="ex">
<bs:guard>inside<bs:boom/>never printed</bs:guard>
</c:catch>
caught: ${ex.message}<br/>
open guards: ${applicationScope.openGuards}
inside
caught: the shelf collapsed<br/>
open guards: 0
The text inside was written before the nested tag failed, which shows that an exception does not undo output that
reached the buffer; doCatch logged the exception and rethrew it (the ServletContext log line is guard saw
JspTagException), and open guards: 0 shows that doFinally ran. Without TryCatchFinally the counter would stay at
one for ever, and every failed request would leak a guard.
The JSTL Base Classes
The Standard Tag Library’s own conditional and loop tags are classic handlers, and it publishes the base classes they
are built on in jakarta.servlet.jsp.jstl.core, so that a library author gets the same behaviour and the same
attributes without re-implementing them.
| Type | What it provides | What the subclass writes |
|---|---|---|
|
|
the abstract |
|
|
the abstract |
|
|
nothing: a tag that cooperates with any loop looks for it with |
|
|
nothing: it is what |
OldBooksTag is a conditional tag that stores its result for a c:if or a c:when to use, and EachTag is a loop that
iterates over a list of books with the full c:forEach attribute set. The base class does the arithmetic of begin,
end and step and the bookkeeping of varStatus; the subclass supplies the three iteration methods and the
setters for the three numeric attributes (the base class declares the fields, not the setters, because only the
subclass knows which attributes its TLD offers).
public class OldBooksTag extends ConditionalTagSupport {
private Book book;
public void setBook(Book book) { this.book = book; }
@Override
protected boolean condition() {
return book.year() < 1980;
}
@Override
public void release() {
book = null;
super.release();
}
}
public class EachTag extends LoopTagSupport {
private List<Book> items;
private Iterator<Book> iterator;
public void setItems(List<Book> items) { this.items = items; }
public void setBegin(int begin) throws JspTagException {
this.beginSpecified = true;
this.begin = begin;
validateBegin();
}
public void setEnd(int end) throws JspTagException {
this.endSpecified = true;
this.end = end;
validateEnd();
}
public void setStep(int step) throws JspTagException {
this.stepSpecified = true;
this.step = step;
validateStep();
}
@Override
protected void prepare() throws JspTagException { iterator = items.iterator(); }
@Override
protected boolean hasNext() throws JspTagException { return iterator.hasNext(); }
@Override
protected Object next() throws JspTagException { return iterator.next(); }
@Override
public void release() {
items = null;
iterator = null;
super.release();
}
}
var on the conditional, then c:if; varStatus, begin and step on the loop<c:forEach var="b" items="${applicationScope.books}">
<bs:oldBook book="${b}" var="old"/>
<c:if test="${old}">${b.title} is older than 1980<br/></c:if>
</c:forEach>
<bs:each items="${applicationScope.books}" var="b" varStatus="st" begin="1" step="2">
[${st.index}/${st.count}] ${b.title} first=${st.first} last=${st.last}<br/>
</bs:each>
Dune is older than 1980<br/>
The Dispossessed is older than 1980<br/>
[1/1] Neuromancer first=true last=false<br/>
[3/2] Cosmos first=false last=true<br/>
The loop output shows the two numbers that confuse every first-time user of varStatus: index is the position in the
underlying collection (1 and 3 with begin="1" step="2") and count is the number of the pass (1 and 2). The
iteration page of this section covers the same two properties for c:forEach. A tag that must run inside any loop and
ask "which pass is this?" implements nothing; it calls findAncestorWithClass(this, LoopTag.class) and reads
getLoopStatus(), and then works with c:forEach, c:forTokens and bs:each alike.
Tag Handler Pooling
Creating a handler object for every use of every tag, many times per request, is wasteful, and the specification
therefore allows the page to reuse handlers rather than create new ones. Jasper, the JSP engine of Tomcat, does the
latter: every page servlet owns a TagHandlerPool for each distinct combination of tag and attribute names it uses
(visible in the generated source as a field whose name Jasper mangles from _jspx_tagPool_bs_shelf_items_id); get takes a handler from the pool or creates one, and reuse puts it back after a
normal doEndTag. When the page is unloaded, _jspDestroy releases the pools, and that is when release() is called on
every pooled handler. The Tomcat Jasper how-to lists the feature as "JSP Custom Tag Pooling", describes the switch as
enablePooling ("Determines whether tag handler pooling is enabled. This is a compilation option. It will not alter the
behaviour of JSPs that have already been compiled", true or false, default true), and the
poolTagsWithExtends option for pages that use their own base class (both are listed with the other options on Jasper Configuration & Precompilation). The consequence is the one rule of classic
handlers that everything else follows from.
A pooled handler is used again by the same page, by later requests and by other threads (never two at once), without
release() in between. The Tag Javadoc says it in its lifecycle notes: setters "may be called again before a tag handler
is reused", attribute setters "are called if the values differ or are expressed as request-time attribute values", and
release() is for "releasing long-term data". Anything a handler stores in a field and does not reset is therefore seen by
the next use. The two handlers below differ only in that respect; both print how many times they have been called since
their state was last reset, and both are used four times in a loop.
public class StaleTag extends TagSupport { // BROKEN on purpose
private int seen;
@Override
public int doStartTag() throws JspException {
seen++;
print(seen);
return SKIP_BODY;
}
}
public class FreshTag extends TagSupport {
private int seen;
@Override
public int doStartTag() throws JspException {
seen++;
print(seen);
return SKIP_BODY;
}
@Override
public int doEndTag() {
seen = 0; // per-use state is cleared after every use
return EVAL_PAGE;
}
@Override
public void release() {
seen = 0;
super.release();
}
}
(print stands for pageContext.getOut().print(...) wrapped in a try block that turns an IOException into a
JspTagException.)
enablePooling set to false (observed)<p>stale: <c:forEach begin="1" end="4"><bs:stale/> </c:forEach></p>
<p>fresh: <c:forEach begin="1" end="4"><bs:fresh/> </c:forEach></p>
| Pooling | bs:stale |
bs:fresh |
|---|---|---|
on (default) |
|
|
off ( |
|
|
StaleTag is a bug that depends on a performance feature: the code is "correct" with pooling off and fails with it on,
and the failure depends on how handlers are recycled, which is a property of the container and of load. With pooling off the same page prints
four times 1, which is what an author who tested on a single request would have seen. The rule that follows is simple and
absolute: everything that is set per use must be set in doStartTag or by a setter, and everything that accumulates
per use must be reset in doEndTag or in doFinally`; `release() is a place for long-term state only. An optional
attribute that a page does not give is not set by the container at all: its setter is not called. Jasper keeps one pool
per tag and set of attribute names, so on Tomcat a use without since never receives a handler that had since set;
the specification does not promise that, and another container may reuse the instance, so a handler keeps its defaults in
field initialisers and puts them back after every use, in doEndTag or doFinally.
The pool is how to disable the problem, and the switch is a servlet initialisation parameter of Jasper’s own servlet. It applies when pages are compiled, so existing compiled pages are not affected until they are recompiled.
WEB-INF/web.xml)<servlet>
<servlet-name>jsp</servlet-name>
<servlet-class>org.apache.jasper.servlet.JspServlet</servlet-class>
<init-param>
<param-name>enablePooling</param-name>
<param-value>false</param-value>
</init-param>
<load-on-startup>3</load-on-startup>
</servlet>
Turning pooling off is a diagnostic, not a fix: it hides handlers that keep state and it costs allocation on every use,
and it is not available to code that must run on other containers. Jasper also disables pooling by itself for some
tags. A page that declares its own base class with the extends attribute of the page directive is not pooled unless
poolTagsWithExtends is true (the how-to explains that Jasper cannot guarantee that the base class has done the
initialisation that the pools need), and, as the next section shows, a handler that implements JspIdConsumer is created anew for
every use.
Pooling and the Tomcat Vulnerability CVE-2024-52318
Pooling is not only a correctness risk; it has been a security one in Tomcat itself. CVE-2024-52318, titled "Incorrect JSP tag recycling leads to XSS" in the CVE record and "XSS in generated JSPs" in the Tomcat security pages, was rated Important. The Tomcat advisory states the cause: the fix for an earlier improvement (Bugzilla 69333) stopped pooled JSP tags from being released after use, which could make the output of some tags unescaped and so lead to cross-site scripting. Only a handful of releases were affected, and the CVE record lists them: Tomcat 11.0.0, 10.1.31 and 9.0.96. The fix is in 11.0.1, 10.1.33 (10.1.32 was prepared, but its release vote failed, so Tomcat’s pages say to download 10.1.33) and 9.0.97, all published in November 2024; the application in this page runs on 11.0.26, which contains the fix. The lesson for a handler author is the same as the one in the previous section seen from the other side: the output of a tag must not depend on whether the handler is fresh or recycled, because the container, not the author, decides when that is, and a bug in the container’s recycling can turn a stale field into an injection. The lesson for an operator is to keep Tomcat on a current patch release of its line.
| Line | Affected | Fixed in |
|---|---|---|
11.0.x |
11.0.0 |
11.0.1 |
10.1.x |
10.1.31 |
10.1.33 (the CVE record says 10.1.32; the Tomcat page explains that 10.1.32 was not released) |
9.0.x |
9.0.96 |
9.0.97 |
Source: CVE Program — CVE-2024-52318
Container Services for Handlers: Injection and the JSP Id
Chapter 7 of the specification gives classic handlers three services beyond the protocol. First, resource injection
(section 7.1.11): handlers that implement Tag or SimpleTag "may be annotated for injection", which happens
"immediately after an instance of the tag handler is constructed, and before any of the tag properties are initialized".
The supported annotations are @EJB, @PersistenceContext, @PersistenceUnit, @PostConstruct, @PreDestroy,
@Resource and @WebServiceRef (and their plural forms), and a container that is not part of a Jakarta EE
implementation is "encouraged, but not required" to support them; resource injection is not supported for JSP pages or tag
files. Second, a handler that implements JspIdConsumer receives, through setJspId, an identification of its use in the
page that is unique within the translation (section 7.1.10). Third, tag libraries can declare event listeners in the
TLD, which the container registers before the application starts (section 7.1.9); that is the subject of the descriptor
page of this section.
AuditTag counts the construction and destruction callbacks of its instances, and IdTag implements JspIdConsumer.
Each is used in a loop; the page was requested twice, and the counters are static, so they accumulate over the lifetime of
the web application.
public class AuditTag extends TagSupport {
private static final AtomicInteger CREATED = new AtomicInteger();
private static final AtomicInteger DESTROYED = new AtomicInteger();
@PostConstruct
void created() { CREATED.incrementAndGet(); }
@PreDestroy
void destroyed() { DESTROYED.incrementAndGet(); }
@Override
public int doStartTag() throws JspException {
print("created=" + CREATED + " destroyed=" + DESTROYED + "<br/>");
return SKIP_BODY;
}
}
public class IdTag extends TagSupport implements JspIdConsumer {
private String jspId;
@Override
public void setJspId(String id) { this.jspId = id; }
@Override
public int doStartTag() throws JspException {
print("jspId=" + jspId + "<br/>");
return SKIP_BODY;
}
}
<c:forEach begin="1" end="3"><bs:audit/></c:forEach>
<c:forEach begin="1" end="2"><bs:id/></c:forEach>
created=1 destroyed=0<br/>created=1 destroyed=0<br/>created=1 destroyed=0<br/>
jspId=jsp_652656546_0<br/>jspId=jsp_652656546_0<br/>
The first line is the proof that pooling works and that injection callbacks run once per instance and not once per use:
three uses in a loop, and two requests, produced one @PostConstruct call and no @PreDestroy. The generated source for
bs:id is different: Jasper writes new IdTag(), then _jsp_getInstanceManager().newInstance(...) and
setJspId("jsp_652656546_0"), for every use and never puts the handler in a pool. That behaviour is not stated in the specification or the Jasper how-to; it is
what Tomcat 11.0.26 does, and it is a reason not to depend on JspIdConsumer in a hot path.
Security Notes
A classic handler writes to the response, so the rules of any output code apply to it unchanged.
-
Escape what is not yours. A handler that prints an attribute value, a request parameter or a property of a model object into HTML must escape it (with a small escaping method of your own, since a handler cannot call
c:out);c:outescapes by default and is the reason to prefer it in the page over a bare expression.ShoutTagabove is safe only because its input is template text: a body tag that wraps text of a user in markup must escape that text first. -
Keep handlers free of per-user state in fields. A pooled handler is shared between users in sequence; a field that holds the name of the current user and is not cleared is a data leak (and the pooling section showed how a stale field becomes an XSS in the case of CVE-2024-52318).
-
Do not build expressions from request input. A tag attribute that is evaluated as EL by the handler (the old way of supporting EL in a tag before JSP 2.0) is an expression injection if its text can come from a request; since JSP 2.0 the container evaluates attributes, and the handler receives the value.
-
Do not extend scripting. The scripting variables of a classic tag exist for the scriptlets that are discouraged on the scripting elements page; new tags need not declare any, and a library used in scripting-free pages (
scripting-invalidin the JSP property groups) loses nothing. -
Keep the container patched. CVE-2024-52318 shows that the pooling machinery itself can be the vulnerability.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
a counter or a flag keeps its value from the previous use: |
a field is not reset and the handler is pooled |
reset per-use state in |
an optional attribute seems to keep the value of an earlier use (on a container that shares pools between attribute sets) |
the setter is not called when the attribute is absent |
reset it after every use |
|
a body inside a tag whose |
remove the body, or change the declaration |
|
a scriptlet in a |
use EL, or declare the body |
the body prints |
|
that is the point of the value; use |
the output of a body tag is missing |
written to |
use |
|
|
throw a clear message, or validate in a |
a body is evaluated forever |
|
return |
|
the constant belongs to |
extend |
|
the constant of JSP 1.1 was removed in 4.0 |
use |
a lock, a transaction or a file is never closed when the body fails |
cleanup is in |
implement |
a scripting variable is |
no |
declare it, and choose |
the rest of the page is missing after a tag |
|
return |
|
|
leave it false, or make the base class call |
the handler behaves differently under load than in a single-request test |
pooling reuses the instance only after the first use |
test each handler at least twice in a loop, with pooling on and off |
What Changed Recently
| Version | Change |
|---|---|
Jakarta Pages 4.0 (Jakarta EE 11, 2024) |
|
Jakarta Pages 3.0 and 3.1 (Jakarta EE 9 and 10) |
the package becomes |
Tomcat 11.0.1, 10.1.33, 9.0.97 (November 2024) |
the fix for CVE-2024-52318, the tag recycling vulnerability introduced by an earlier pooling change |
JSP 2.1 (2006) |
unified EL with deferred attributes ( |
JSP 2.0 (2003) |
|
JSP 1.2 (2001) |
|
JSP 1.1 (1999) |
the original |
Where the Books Are Outdated
The books in the section bibliography that teach custom tags were written for JSP 1.1 and 1.2 and for JSTL 1.0. What they say about the classic API is still correct as far as it goes; what has changed around it is listed below.
| The books say | Today |
|---|---|
packages |
|
the tag library descriptor is a DTD document with |
an XML Schema document in the |
the body constant is |
removed in Pages 4.0; use |
a tag that accepts an expression evaluates it itself, with the Jakarta Taglibs |
the container evaluates every attribute whose TLD says |
the handler is "pooled by the container" with no further warning, and a handler may keep state between calls |
pooling is a Jasper option with real hazards (above); write every handler so that it works with pooling on and off |
new tags are classic handlers because that is all there is |
|
scripting variables as the way to hand a value to the page |
EL and |
References
-
Jakarta Server Pages 4.0 — 7.1.4 Simple Examples of Classic Tag Handlers
-
Jakarta Server Pages 4.0 — 7.1.4.7 Actions Defining Scripting Variables
-
Jakarta Pages 4.0 API — Tag.SKIP_BODY and the other return codes
-
Jakarta EE — web-jsptaglibrary_4_0.xsd (the Pages 4.0 TLD schema)
-
Apache Tomcat 11.0 — Jasper 2 JSP Engine How To, Configuration (enablePooling)
-
Apache Tomcat 11 — Security Reports, Fixed in 11.0.1 (CVE-2024-52318)
-
Apache Tomcat 10 — Security Reports, Fixed in 10.1.33 (CVE-2024-52318)