This is an automated email from the ASF dual-hosted git repository.
markt-asf pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/tomcat.git
The following commit(s) were added to refs/heads/main by this push:
new b9fafd4cd9 Add best efforts protection for a mis-configured Valve
b9fafd4cd9 is described below
commit b9fafd4cd9d29f7a685d572bf3bb43a1d692d48b
Author: Mark Thomas <[email protected]>
AuthorDate: Fri Aug 28 09:48:43 2026 +0100
Add best efforts protection for a mis-configured Valve
---
.../valves/CrawlerSessionManagerValve.java | 42 ++++---
.../apache/catalina/valves/LocalStrings.properties | 2 +
.../valves/TestCrawlerSessionManagerValve.java | 49 ++++----
.../TestCrawlerSessionManagerValveIntegration.java | 131 +++++++++++++++++++++
webapps/docs/changelog.xml | 5 +
webapps/docs/config/valve.xml | 6 +
6 files changed, 199 insertions(+), 36 deletions(-)
diff --git a/java/org/apache/catalina/valves/CrawlerSessionManagerValve.java
b/java/org/apache/catalina/valves/CrawlerSessionManagerValve.java
index 553588e7b8..437e9e1e19 100644
--- a/java/org/apache/catalina/valves/CrawlerSessionManagerValve.java
+++ b/java/org/apache/catalina/valves/CrawlerSessionManagerValve.java
@@ -26,13 +26,13 @@ import java.util.function.Function;
import java.util.regex.Pattern;
import jakarta.servlet.ServletException;
-import jakarta.servlet.http.HttpSession;
import jakarta.servlet.http.HttpSessionBindingEvent;
import jakarta.servlet.http.HttpSessionBindingListener;
import org.apache.catalina.Context;
import org.apache.catalina.Host;
import org.apache.catalina.LifecycleException;
+import org.apache.catalina.Session;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
import org.apache.juli.logging.Log;
@@ -284,24 +284,36 @@ public class CrawlerSessionManagerValve extends ValveBase
{
getNext().invoke(request, response);
if (isBot) {
- if (sessionId == null) {
- // Has bot just created a session, if so make a note of it
- HttpSession s = request.getSession(false);
- if (s != null) {
- clientIdSessionId.put(clientIdentifier, s.getId());
- // #valueUnbound() will be called on session expiration
- s.setAttribute(this.getClass().getName(),
- new
CrawlerHttpSessionBindingListener(clientIdSessionId, clientIdentifier));
- s.setMaxInactiveInterval(sessionInactiveInterval);
-
+ Session s = request.getSessionInternal(false);
+ if (s == null || s.getPrincipal() == null) {
+ if (sessionId == null) {
+ // Has bot just created a session, if so make a note of it
+ if (s != null) {
+ clientIdSessionId.put(clientIdentifier, s.getId());
+ // #valueUnbound() will be called on session expiration
+ s.getSession().setAttribute(this.getClass().getName(),
+ new
CrawlerHttpSessionBindingListener(clientIdSessionId, clientIdentifier));
+ s.setMaxInactiveInterval(sessionInactiveInterval);
+
+ if (log.isTraceEnabled()) {
+ log.trace(request.hashCode() + ": New bot session.
SessionID=" + s.getId());
+ }
+ }
+ } else {
if (log.isTraceEnabled()) {
- log.trace(request.hashCode() + ": New bot session.
SessionID=" + s.getId());
+ log.trace(request.hashCode() + ": Bot session
accessed. SessionID=" + sessionId);
}
}
} else {
- if (log.isTraceEnabled()) {
- log.trace(request.hashCode() + ": Bot session accessed.
SessionID=" + sessionId);
- }
+ /*
+ * The session is authenticated. That shouldn't happen and
indicates some form of mis-configuration.
+ * Make a best efforts (i.e. this is hardening against
mis-configuration, NOT vulnerability mitigation)
+ * attempt to protect against the authenticated session being
shared.
+ */
+ s.expire();
+ clientIdSessionId.remove(clientIdentifier, s.getIdInternal());
+ log.warn(sm.getString("crawlerSessionManagerValve.principal",
clientIdentifier,
+ request.getHeader("User-Agent"), s.getPrincipal()));
}
}
}
diff --git a/java/org/apache/catalina/valves/LocalStrings.properties
b/java/org/apache/catalina/valves/LocalStrings.properties
index 0cb4d4545b..4a9383a247 100644
--- a/java/org/apache/catalina/valves/LocalStrings.properties
+++ b/java/org/apache/catalina/valves/LocalStrings.properties
@@ -26,6 +26,8 @@ accessLogValve.rotateFail=Failed to rotate access log
accessLogValve.unsupportedEncoding=Failed to set encoding to [{0}], will use
the system default character set.
accessLogValve.writeFail=Failed to write log message [{0}]
+crawlerSessionManagerValve.principal=The crawler session being used by client
[{0}] with user agent [{1}] was authenticated as [{2}] so the session was
expired.
+
# Default error page should not have '[' ']' symbols around substituted text
fragments.
# https://bz.apache.org/bugzilla/show_bug.cgi?id=61134
errorReportValve.contentTypeFail=Failure to set the content-type of response
diff --git
a/test/org/apache/catalina/valves/TestCrawlerSessionManagerValve.java
b/test/org/apache/catalina/valves/TestCrawlerSessionManagerValve.java
index c8131564c5..64c42d40e5 100644
--- a/test/org/apache/catalina/valves/TestCrawlerSessionManagerValve.java
+++ b/test/org/apache/catalina/valves/TestCrawlerSessionManagerValve.java
@@ -33,6 +33,7 @@ import org.junit.Test;
import org.apache.catalina.Context;
import org.apache.catalina.Host;
import org.apache.catalina.Manager;
+import org.apache.catalina.Session;
import org.apache.catalina.Valve;
import org.apache.catalina.connector.Request;
import org.apache.catalina.connector.Response;
@@ -40,7 +41,6 @@ import org.apache.catalina.core.StandardContext;
import org.apache.catalina.session.StandardManager;
import org.apache.catalina.session.StandardSession;
import org.easymock.EasyMock;
-import org.easymock.IExpectationSetters;
public class TestCrawlerSessionManagerValve {
@@ -58,14 +58,15 @@ public class TestCrawlerSessionManagerValve {
valve.setCrawlerIps("216\\.58\\.206\\.174");
valve.setCrawlerUserAgents(valve.getCrawlerUserAgents());
valve.setNext(EasyMock.createMock(Valve.class));
- HttpSession session = createSessionExpectations(valve, true);
+ HttpSession httpSession = EasyMock.createMock(HttpSession.class);
+ Session session = createSessionExpectations(valve, httpSession, true);
Request request = createRequestExpectations("216.58.206.174", session,
true);
- EasyMock.replay(request, session);
+ EasyMock.replay(request, session, httpSession);
valve.invoke(request, EasyMock.createMock(Response.class));
- EasyMock.verify(request, session);
+ EasyMock.verify(request, session, httpSession);
}
@Test
@@ -74,14 +75,15 @@ public class TestCrawlerSessionManagerValve {
valve.setCrawlerIps("216\\.58\\.206\\.174");
valve.setCrawlerUserAgents(valve.getCrawlerUserAgents());
valve.setNext(EasyMock.createMock(Valve.class));
- HttpSession session = createSessionExpectations(valve, false);
+ HttpSession httpSession = EasyMock.createMock(HttpSession.class);
+ Session session = createSessionExpectations(valve, httpSession, false);
Request request = createRequestExpectations("127.0.0.1", session,
false);
- EasyMock.replay(request, session);
+ EasyMock.replay(request, session, httpSession);
valve.invoke(request, EasyMock.createMock(Response.class));
- EasyMock.verify(request, session);
+ EasyMock.verify(request, session, httpSession);
}
@Test
@@ -137,36 +139,41 @@ public class TestCrawlerSessionManagerValve {
private void verifyCrawlingLocalhost(CrawlerSessionManagerValve valve,
String hostname)
throws IOException, ServletException {
- HttpSession session = createSessionExpectations(valve, true);
+ HttpSession httpSession = EasyMock.createMock(HttpSession.class);
+ Session session = createSessionExpectations(valve, httpSession, true);
Request request = createRequestExpectations("127.0.0.1", session,
true, hostname, "/examples", "tomcatBot 1.0");
- EasyMock.replay(request, session);
+ EasyMock.replay(request, session, httpSession);
valve.invoke(request, EasyMock.createMock(Response.class));
- EasyMock.verify(request, session);
+ EasyMock.verify(request, session, httpSession);
}
private void verifyCrawlingContext(CrawlerSessionManagerValve valve,
String contextPath)
throws IOException, ServletException {
- HttpSession session = createSessionExpectations(valve, true);
+ HttpSession httpSession = EasyMock.createMock(HttpSession.class);
+ Session session = createSessionExpectations(valve, httpSession, true);
Request request = createRequestExpectations("127.0.0.1", session,
true, "localhost", contextPath,
"tomcatBot 1.0");
- EasyMock.replay(request, session);
+ EasyMock.replay(request, session, httpSession);
valve.invoke(request, EasyMock.createMock(Response.class));
- EasyMock.verify(request, session);
+ EasyMock.verify(request, session, httpSession);
}
- private HttpSession createSessionExpectations(CrawlerSessionManagerValve
valve, boolean isBot) {
- HttpSession session = EasyMock.createMock(HttpSession.class);
+ private Session createSessionExpectations(CrawlerSessionManagerValve
valve, HttpSession httpSession,
+ boolean isBot) {
+ Session session = EasyMock.createMock(Session.class);
if (isBot) {
- EasyMock.expect(session.getId()).andReturn("id").times(1);
- session.setAttribute(EasyMock.eq(valve.getClass().getName()),
+ EasyMock.expect(session.getPrincipal()).andReturn(null);
+ EasyMock.expect(session.getId()).andReturn("id");
+ EasyMock.expect(session.getSession()).andReturn(httpSession);
+ httpSession.setAttribute(EasyMock.eq(valve.getClass().getName()),
EasyMock.anyObject(HttpSessionBindingListener.class));
EasyMock.expectLastCall();
session.setMaxInactiveInterval(60);
@@ -176,11 +183,11 @@ public class TestCrawlerSessionManagerValve {
}
- private Request createRequestExpectations(String ip, HttpSession session,
boolean isBot) {
+ private Request createRequestExpectations(String ip, Session session,
boolean isBot) {
return createRequestExpectations(ip, session, isBot, "localhost",
"/examples", "something 1.0");
}
- private Request createRequestExpectations(String ip, HttpSession session,
boolean isBot, String hostname,
+ private Request createRequestExpectations(String ip, Session session,
boolean isBot, String hostname,
String contextPath, String userAgent) {
Request request = EasyMock.createMock(Request.class);
EasyMock.expect(request.getRemoteAddr()).andReturn(ip);
@@ -188,9 +195,9 @@ public class TestCrawlerSessionManagerValve {
EasyMock.expect(request.getHost()).andReturn(simpleHostWithName(hostname));
EasyMock.expect(request.getHost()).andReturn(simpleHostWithName(hostname));
EasyMock.expect(request.getContext()).andReturn(simpleContextWithName(contextPath));
- IExpectationSetters<HttpSession> setter =
EasyMock.expect(request.getSession(false)).andReturn(null);
+ EasyMock.expect(request.getSession(false)).andReturn(null);
if (isBot) {
- setter.andReturn(session);
+
EasyMock.expect(request.getSessionInternal(false)).andReturn(session);
}
EasyMock.expect(request.getHeaders("user-agent"))
.andAnswer(() ->
Collections.enumeration(Arrays.asList(userAgent)));
diff --git
a/test/org/apache/catalina/valves/TestCrawlerSessionManagerValveIntegration.java
b/test/org/apache/catalina/valves/TestCrawlerSessionManagerValveIntegration.java
new file mode 100644
index 0000000000..82c5f667c5
--- /dev/null
+++
b/test/org/apache/catalina/valves/TestCrawlerSessionManagerValveIntegration.java
@@ -0,0 +1,131 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements. See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.catalina.valves;
+
+import java.io.IOException;
+import java.nio.charset.StandardCharsets;
+import java.security.Principal;
+import java.util.Base64;
+import java.util.HashMap;
+import java.util.List;
+import java.util.Map;
+
+import jakarta.servlet.http.HttpServlet;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
+
+import org.junit.Assert;
+import org.junit.Test;
+
+import org.apache.catalina.Context;
+import org.apache.catalina.authenticator.BasicAuthenticator;
+import org.apache.catalina.startup.TesterMapRealm;
+import org.apache.catalina.startup.Tomcat;
+import org.apache.catalina.startup.TomcatBaseTest;
+import org.apache.tomcat.util.buf.ByteChunk;
+import org.apache.tomcat.util.descriptor.web.LoginConfig;
+import org.apache.tomcat.util.descriptor.web.SecurityCollection;
+import org.apache.tomcat.util.descriptor.web.SecurityConstraint;
+
+public class TestCrawlerSessionManagerValveIntegration extends TomcatBaseTest {
+
+ private static final String USER = "alice";
+ private static final String PASSWORD = "alice-password";
+ private static final String ROLE = "account-holder";
+ private static final String SECRET = "alice-private-token";
+
+ /**
+ * Test for handling of Valve mis-configuration that results in one of the
clients identified as a crawler creating
+ * an authenticated session. The Valve tries (but does not guarantee) to
protect against this.
+ * <p>
+ * Alice authenticates with a crawler-classified User-Agent. A separate
HTTP request with no Authorization or Cookie
+ * header uses a different crawler-classified User-Agent from the same
apparent address.
+ * <p>
+ * The separate request should not see Alice's authenticated session.
+ *
+ * @throws Exception If the test experiences an unexpected error
+ */
+ @Test
+ public void testUnauthenticatedCrawlerMustNotReuseAuthenticatedSession()
throws Exception {
+ Tomcat tomcat = getTomcatInstance();
+ Context context = tomcat.addContext("", null);
+
+ TesterMapRealm realm = new TesterMapRealm();
+ realm.addUser(USER, PASSWORD);
+ realm.addUserRole(USER, ROLE);
+ context.setRealm(realm);
+ context.setLoginConfig(new LoginConfig(HttpServletRequest.BASIC_AUTH,
"crawler-test", null, null));
+
+ context.getPipeline().addValve(new CrawlerSessionManagerValve());
+ BasicAuthenticator authenticator = new BasicAuthenticator();
+ authenticator.setAlwaysUseSession(true);
+ context.getPipeline().addValve(authenticator);
+
+ Tomcat.addServlet(context, "account", new HttpServlet() {
+ @Override
+ protected void doGet(HttpServletRequest request,
HttpServletResponse response) throws IOException {
+ Principal principal = request.getUserPrincipal();
+ response.setContentType("text/plain");
+ response.getWriter().print("principal=" + principal.getName()
+ " secret=" + SECRET + " session=" +
+ request.getSession(true).getId());
+ }
+ });
+ context.addServletMapping("/account", "account");
+ context.addSecurityRole(ROLE);
+ SecurityCollection collection = new SecurityCollection();
+ collection.addPattern("/account");
+ SecurityConstraint constraint = new SecurityConstraint();
+ constraint.addCollection(collection);
+ constraint.addAuthRole(ROLE);
+ context.addConstraint(constraint);
+
+ tomcat.start();
+
+ ByteChunk baselineBody = new ByteChunk();
+ int baselineStatus = getUrl(url(), baselineBody,
headers("ordinary-browser/1.0", null), null);
+ Assert.assertEquals("protected resource must reject an unauthenticated
ordinary client", 401, baselineStatus);
+
+ String credentials = "Basic " +
+ Base64.getEncoder().encodeToString((USER + ":" +
PASSWORD).getBytes(StandardCharsets.ISO_8859_1));
+ ByteChunk aliceBody = new ByteChunk();
+ int aliceStatus = getUrl(url(), aliceBody, headers("aliceBot/1.0",
credentials), null);
+ Assert.assertEquals(200, aliceStatus);
+ Assert.assertTrue(aliceBody.toString().contains("principal=alice
secret=" + SECRET));
+
+ // New client request: no Cookie and no Authorization. It only shares
+ // the server-observed address/Host/Context and matches the bot
pattern.
+ ByteChunk malloryBody = new ByteChunk();
+ int malloryStatus = getUrl(url(), malloryBody,
headers("malloryBot/9.9", null), null);
+
+ Assert.assertEquals("unauthenticated crawler inherited Alice's session
and response: " + malloryBody, 401,
+ malloryStatus);
+ Assert.assertFalse(malloryBody.toString().contains(SECRET));
+ }
+
+ private String url() {
+ return "http://localhost:" + getPort() + "/account";
+ }
+
+ private static Map<String,List<String>> headers(String userAgent, String
authorization) {
+ Map<String,List<String>> headers = new HashMap<>();
+ headers.put("user-agent", List.of(userAgent));
+ if (authorization != null) {
+ headers.put("authorization", List.of(authorization));
+ }
+ return headers;
+ }
+}
\ No newline at end of file
diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml
index 81865b721a..1cb95ee48a 100644
--- a/webapps/docs/changelog.xml
+++ b/webapps/docs/changelog.xml
@@ -289,6 +289,11 @@
Resolve null or missing rewrite substitutions as an empty string, to
align with the mod_rewrite behavior. (remm)
</fix>
+ <add>
+ Add a best efforts protection in the
+ <code>CrawlerSessionManagerValve</code> against crawlers being
+ associated with an authenticated session. (markt)
+ </add>
</changelog>
</subsection>
<subsection name="Coyote">
diff --git a/webapps/docs/config/valve.xml b/webapps/docs/config/valve.xml
index 6e6ba1f1dc..08435424f3 100644
--- a/webapps/docs/config/valve.xml
+++ b/webapps/docs/config/valve.xml
@@ -2435,6 +2435,12 @@ RequestHeader unset SSL_CLIENT_ESCAPED_CERT</source>
should be defined before this valve to ensure that the correct client IP
address is presented to this valve.</p>
+ <p>Care should be taken when configuring this Valve to ensure that crawlers
+ are not associated with the authenticated session of a genuine user. In
+ particular, the <code>crawlerUserAgents</code> must be chosen with care.
The
+ Valve will attempt (but does NOT guarantee) to detect this situation and
+ will expire the authenticated session if detected.</p>
+
</subsection>
<subsection name="Attributes">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]