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'.
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.
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.
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.
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.
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.
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.
ApplicationFilterFactory.createFilterChain()
Builds an ApplicationFilterChain containing exactly the filters
whose url-pattern and dispatcher type match this request. The
chain is constructed per request.
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.
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:
| Accessor | Attacker controls | Commonly 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-set | correctly trusted, if auth is configured |
| getContextPath() | no — from deployment | correctly 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.
| Accessor | Decoded? | For /app/files/%2e%2e/secret |
|---|---|---|
getRequestURI() | No — as sent | /app/files/%2e%2e/secret |
getServletPath() | Yes | the matched, decoded, normalised portion |
getPathInfo() | Yes | the decoded remainder |
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.
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
| Setting | Risk when wrong |
|---|---|
<Connector address> unset | Binds 0.0.0.0. Verified default on a fresh 10.1.61 install — the lab connector sets 127.0.0.1 explicitly |
| AJP connector enabled | Ghostcat (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 DefaultServlet | Directory listing discloses structure |
| manager / host-manager deployed | WAR 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"> reachable | Plaintext 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.
An auth filter is mapped to
/admin/*. A public servlet callsgetRequestDispatcher("/admin/report").forward(req, resp). Does the filter run?No. A filter mapping applies to theREQUESTdispatcher type only unless it declares others, so aFORWARDskips it entirely and the admin servlet executes unauthenticated. Add<dispatcher>FORWARD</dispatcher>(plusINCLUDEandERROR), and separately never build a forward target from a parameter —WEB-INFis reachable by forward even though it is not reachable by URL.A logging filter reads the request body for auditing. Afterwards every form handler sees
nullparameters. Why?The body is a one-shot stream.Request.parseParameters()runs lazily on the firstgetParametercall 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 getnull, not an exception, and the symptom surfaces in the application rather than in the filter.Which of
getRequestURI()andgetServletPath()is URL-decoded, and why does the difference matter for access control?getServletPath()andgetPathInfo()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.A servlet has a
private User currentUserfield assigned at the top ofdoGet. What is the defect and how severe is it?The container creates one servlet instance and callsservice()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-finalinstance field on a servlet, filter or listener is suspect.