luap.in/java
← Index · 03 · Container Tomcat 10.1.61 · Servlet 6.0

Socket to servlet, through Tomcat

Between accept() and your doGet there are roughly a dozen components, and three of them make decisions an attacker can influence. Knowing which component normalised the path tells you whether your filter and your servlet are looking at the same string.

01The full path

Every class named here exists in your installation. Verify with unzip -Z1 lib/catalina.jar 'org/apache/catalina/core/Standard*Valve.class'.

01
NioEndpoint$Acceptor — accept the connection

A dedicated thread blocks in accept(). It does not read anything; it registers the new socket with a Poller and goes back to accepting. Connection acceptance is therefore never blocked by a slow request.

02
NioEndpoint$Poller — wait for readable data

A selector loop watches many sockets. When one becomes readable the wrapper is handed to the executor. This is why Tomcat holds tens of thousands of idle connections on a few threads, and why a slow-read attack costs the attacker almost nothing.

03
Http11Processor.service() — parse the protocol

Drives Http11InputBuffer.parseRequestLine() then parseHeaders(), filling org.apache.coyote.Request. Fields are MessageBytes — raw bytes with a lazily-applied charset, reused between requests. Nothing is a String yet.

04
CoyoteAdapter.service() — the trust boundary

Wraps the coyote request in org.apache.catalina.connector.Request, which implements HttpServletRequest. postParseRequest() decodes and normalises the URI — resolving . and .., rejecting encoded separators unless configured otherwise.

Everything reachable through this object is attacker-controlled from here on.

05
Mapper.map() — select the target

Longest-prefix match over Host, then Context, then Wrapper, writing the result into MappingData. This is where contextPath, servletPath and pathInfo are split apart. A 404 with nothing in the application log is almost always a decision made here.

06
The valve pipeline

StandardEngineValve → StandardHostValve → StandardContextValve → StandardWrapperValve. Custom valves such as RemoteAddrValve or AccessLogValve insert here. Valves are Tomcat-specific and run before any filter, which makes them the right place for container-wide IP restrictions and the wrong place for application logic.

07
ApplicationFilterFactory.createFilterChain()

Builds an ApplicationFilterChain containing exactly the filters whose url-pattern and dispatcher type match this request. The chain is constructed per request.

08
ApplicationFilterChain.internalDoFilter()

Walks its array, calling each Filter.doFilter. When the array is exhausted it calls servlet.service(). A filter that does not call chain.doFilter() terminates the request — which is how authentication filters reject, and how a bug in one silently disables every filter after it.

09
HttpServlet.service() → doGet / doPost

Reads the method and dispatches. Overriding service() directly loses that dispatch — and loses the automatic HEAD, OPTIONS and TRACE handling with it.

02Where the trust boundary sits

Step 04 is the line. Before it, the request is bytes in a recycled buffer; after it, it is an object graph your code reads. Everything on the far side is attacker-controlled, including fields that feel structural:

AccessorAttacker controlsCommonly assumed safe because
getMethod()fully"only GET and POST exist"
getRequestURI()fully, undecoded"the container normalised it"
getHeader("Host")fully"the proxy sets it"
getHeader("X-Forwarded-For")fully"it comes from the load balancer"
getContentType()fully"the browser sets it" — see S2-045
getRemoteUser()no — container-setcorrectly trusted, if auth is configured
getContextPath()no — from deploymentcorrectly trusted

Only the last two are genuinely container-derived. X-Forwarded-For is worth dwelling on: it is trusted only if a reverse proxy overwrites it, and only if the application reads it through RemoteIpValve with internalProxies configured. Reading the raw header and taking the first entry is a rate-limit and ACL bypass.

03Three views of the URI

The Servlet specification defines several path accessors with different decoding rules, and the difference is exploitable.

AccessorDecoded?For /app/files/%2e%2e/secret
getRequestURI()No — as sent/app/files/%2e%2e/secret
getServletPath()Yesthe matched, decoded, normalised portion
getPathInfo()Yesthe decoded remainder
The mismatch

A filter that enforces access control by inspecting getRequestURI() is comparing against the raw string, while the mapper selected the servlet using the normalised one. They can disagree.

// Filter: looks at the raw URI
if (req.getRequestURI().startsWith("/app/admin/")) {
    requireAdmin(req);          // never matches /app/./admin/ etc.
}
chain.doFilter(req, resp);       // mapper already resolved it to the admin servlet
// Use the container's own decoded, normalised view...
String path = req.getServletPath()
           + (req.getPathInfo() == null ? "" : req.getPathInfo());
if (path.startsWith("/admin/")) requireAdmin(req);

// ...or better: let the container do the matching, declaratively.
<filter-mapping>
  <filter-name>AuthFilter</filter-name>
  <url-pattern>/admin/*</url-pattern>
</filter-mapping>

Not a fix: calling URLDecoder.decode on getRequestURI() yourself. That double-decodes the already-decoded parts and introduces a different bypass.

The one-character gap

/admin/* matches /admin/ and /admin/x. It does not match /admin. If a servlet is also mapped at exactly /admin, it is reachable with the filter skipped entirely. Map both, or map the filter to /* and decide inside it.

04Filter chains and dispatcher types

Ordering

Filter order comes from <filter-mapping> declaration order in web.xml. For @WebFilter annotations the order is not specified — it depends on classpath scan order, which can change between builds.

// Two annotated filters. Which runs first? Undefined.
@WebFilter("/*") public class AuthFilter      implements Filter { … }
@WebFilter("/*") public class AuditLogFilter implements Filter { … }

If ordering matters — and for security filters it always does — declare the mappings in web.xml even when the filter classes use annotations. An annotated filter can have its mapping overridden by a matching <filter-mapping>.

Dispatcher types — the forward bypass

A filter mapping applies to REQUEST only, unless it says otherwise. The other types are FORWARD, INCLUDE, ERROR and ASYNC.

// Auth filter, default dispatcher = REQUEST only
<filter-mapping>
  <filter-name>AuthFilter</filter-name>
  <url-pattern>/admin/*</url-pattern>
</filter-mapping>

// A public servlet forwards based on a parameter:
req.getRequestDispatcher("/admin/" + req.getParameter("view"))
   .forward(req, resp);     // FORWARD -> AuthFilter does NOT run

The request reaches the admin servlet having never passed the authentication filter. The attacker needs no header manipulation — just the public endpoint.

<filter-mapping>
  <filter-name>AuthFilter</filter-name>
  <url-pattern>/admin/*</url-pattern>
  <dispatcher>REQUEST</dispatcher>
  <dispatcher>FORWARD</dispatcher>
  <dispatcher>INCLUDE</dispatcher>
  <dispatcher>ERROR</dispatcher>
</filter-mapping>

And separately: never build a forward target from a parameter. That is path traversal into your own application, and WEB-INF is reachable by forward even though it is not reachable by URL.

// WEB-INF is protected from direct requests but NOT from forwards
req.getRequestDispatcher("/WEB-INF/" + p).forward(req, resp);
// ?p=web.xml renders your deployment descriptor

Forward vs redirect, on one line

forward() is server-side: one HTTP transaction, the browser never learns, the request object is reused, filters re-run only for FORWARD. sendRedirect() is a 302: the browser makes a new request, which runs the full chain from step 01 — and carries no request attributes, which is why data has to go through the session or the query string, and why putting it in the query string is how it ends up in logs and Referer headers.

05Lazy parameter parsing

Request.parseParameters() runs on the first call to getParameter, getParameterMap or getParameterNames — not when the request arrives. It merges the query string with, for application/x-www-form-urlencoded POSTs, the body.

Reading the body destroys the parameters
String body = new String(req.getInputStream().readAllBytes());
String name = req.getParameter("name");   // null for a form POST

The body is a one-shot stream. Consuming it first leaves parseParameters nothing to read, and it fails silently — you get null, not an exception. A logging filter that reads the body for auditing breaks every downstream form handler this way, and the symptom appears in the application rather than the filter.

maxParameterCount

Tomcat 10.1 ships with an explicit guard in conf/server.xml:

$ grep maxParameterCount ~/tools/tomcat/conf/server.xml
               maxParameterCount="1000"

This caps parameters per request. Without it, a POST containing a very large number of keys costs the server hash-map work and memory per request — the HashDoS class of attack. It applies to the merged query-plus-body set, and exceeding it causes the excess to be dropped silently rather than rejected, which can itself be a logic bypass if your code assumes a parameter it does not find is absent rather than discarded.

06One servlet instance, many threads

The container creates one instance of each servlet and calls service() on it from many request threads at once. An instance field is therefore shared mutable state across concurrent users.

public class ProfileServlet extends HttpServlet {
    private User currentUser;          // shared by every thread

    protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
        currentUser = lookup(req.getRemoteUser());
        // another thread can overwrite currentUser between these lines
        resp.getWriter().write(currentUser.getEmail());
    }
}

Under concurrency this serves one user's email to another. It is not a race that needs an attacker to win a narrow window — ordinary load triggers it, and it is reported as an intermittent data-leak bug rather than as a vulnerability.

protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
    User currentUser = lookup(req.getRemoteUser());   // local = per-thread
    resp.getWriter().write(currentUser.getEmail());
}

The review rule: any non-final instance field on a servlet, filter or listener is suspect. final references to immutable or thread-safe objects are fine; everything else needs justification. SingleThreadModel was the old escape hatch and was removed from the specification — do not expect to find it, and do not reach for it.

Struts differs here: it creates a new Action instance per request, so Action fields are safe. Spring controllers are singletons by default, so they have the servlet problem, not the Struts one.

07Security consequences

Watch the pipeline run

# Every request, with the matched servlet and the time taken
$ ./scripts/run/tomcat.sh log

# Breakpoints worth setting when a request goes somewhere unexpected:
org.apache.catalina.connector.CoyoteAdapter#postParseRequest   # normalisation
org.apache.catalina.mapper.Mapper#map                          # target selection
org.apache.catalina.core.ApplicationFilterChain#internalDoFilter # chain contents

Breaking on internalDoFilter and inspecting the filters array answers "did my filter run?" definitively, which no amount of logging inside the filter can — a filter that was never added to the chain cannot log that it was skipped.

Container settings that are findings

SettingRisk when wrong
<Connector address> unsetBinds 0.0.0.0. Verified default on a fresh 10.1.61 install — the lab connector sets 127.0.0.1 explicitly
AJP connector enabledGhostcat (CVE-2020-1938): file read and potential RCE. Commented out by default in 10.1; confirm it stayed that way
allowLinking="true"Symlinks escape the context root
listings="true" on DefaultServletDirectory listing discloses structure
manager / host-manager deployedWAR upload is RCE by design. Check tomcat-users.xml by parsing it — the shipped accounts are inside XML comments and grep reports them as live
<Server port="8005"> reachablePlaintext SHUTDOWN string stops the server
# Parse, do not grep — the shipped users are commented out
$ python3 -c "import xml.etree.ElementTree as E,sys;\
print(len(E.parse(sys.argv[1]).getroot().findall('user')),'active users')" \
    ~/tools/tomcat/conf/tomcat-users.xml
0 active users

Error pages leak the stack

Without an <error-page> declaration, Tomcat's default error page renders the exception type, message and sometimes a stack trace, disclosing internal paths, library versions and SQL fragments. Declare catch-all handlers:

<error-page>
  <exception-type>java.lang.Throwable</exception-type>
  <location>/WEB-INF/views/error.jsp</location>
</error-page>
<error-page>
  <error-code>500</error-code>
  <location>/WEB-INF/views/error.jsp</location>
</error-page>

Put the target under WEB-INF so it cannot be requested directly, and remember that the ERROR dispatcher type means your filters will not run for it unless you listed ERROR in the mapping.

Check yourself
  1. An auth filter is mapped to /admin/*. A public servlet calls getRequestDispatcher("/admin/report").forward(req, resp). Does the filter run?
    No. A filter mapping applies to the REQUEST dispatcher type only unless it declares others, so a FORWARD skips it entirely and the admin servlet executes unauthenticated. Add <dispatcher>FORWARD</dispatcher> (plus INCLUDE and ERROR), and separately never build a forward target from a parameter — WEB-INF is reachable by forward even though it is not reachable by URL.
  2. A logging filter reads the request body for auditing. Afterwards every form handler sees null parameters. Why?
    The body is a one-shot stream. Request.parseParameters() runs lazily on the first getParameter call and merges the query string with the form body — but the body has already been consumed, so there is nothing left to parse. It fails silently: you get null, not an exception, and the symptom surfaces in the application rather than in the filter.
  3. Which of getRequestURI() and getServletPath() is URL-decoded, and why does the difference matter for access control?
    getServletPath() and getPathInfo() are decoded and normalised; getRequestURI() is returned as sent. A filter enforcing access control on the raw URI is comparing a different string from the one the mapper used to select the servlet, so the two can disagree. Use the container's decoded view, or better, let a declarative <url-pattern> do the matching.
  4. A servlet has a private User currentUser field assigned at the top of doGet. What is the defect and how severe is it?
    The container creates one servlet instance and calls service() on it from many request threads, so the field is shared mutable state. Under ordinary load one user's data is served to another — no narrow race window required. Make it a local variable. Any non-final instance field on a servlet, filter or listener is suspect.
luap.in/java · source Java 21 · Tomcat 10.1.61 · Struts 6 · Spring Boot 3