From 4db2c82c3945715c885d403190586336f88eddc3 Mon Sep 17 00:00:00 2001 From: Coty Sutherland Date: Wed, 2 Sep 2026 14:31:28 -0400 Subject: [PATCH] Add opt-in ServerInfo.properties override support Co-Authored-By: Claude Opus 4.8 --- java/org/apache/catalina/Globals.java | 9 + java/org/apache/catalina/util/ServerInfo.java | 44 ++++- .../catalina/util/TestServerInfoOverride.java | 158 ++++++++++++++++++ webapps/docs/changelog.xml | 9 + webapps/docs/config/systemprops.xml | 11 ++ webapps/docs/security-howto.xml | 10 ++ 6 files changed, 235 insertions(+), 6 deletions(-) create mode 100644 test/org/apache/catalina/util/TestServerInfoOverride.java diff --git a/java/org/apache/catalina/Globals.java b/java/org/apache/catalina/Globals.java index 135aed7e043d..ae9e08a6d9d5 100644 --- a/java/org/apache/catalina/Globals.java +++ b/java/org/apache/catalina/Globals.java @@ -233,6 +233,15 @@ public Globals() { public static final boolean STRICT_SERVLET_COMPLIANCE = Boolean.getBoolean("org.apache.catalina.STRICT_SERVLET_COMPLIANCE"); + /** + * If {@code true}, every ServerInfo.properties on the class path is merged + * (bundled catalina.jar copy first as defaults, override files on top) so an + * override file can replace individual properties. Otherwise only the first + * ServerInfo.properties found is used. + */ + public static final boolean LOAD_SERVER_INFO_OVERRIDE = + Boolean.getBoolean("org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE"); + /** * Default domain for MBeans if none can be determined diff --git a/java/org/apache/catalina/util/ServerInfo.java b/java/org/apache/catalina/util/ServerInfo.java index 2a1e32f2fe66..d214bbc39b51 100644 --- a/java/org/apache/catalina/util/ServerInfo.java +++ b/java/org/apache/catalina/util/ServerInfo.java @@ -19,12 +19,15 @@ import java.io.File; import java.io.InputStream; +import java.net.URL; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Properties; import java.util.jar.JarFile; import java.util.jar.Manifest; +import org.apache.catalina.Globals; import org.apache.tomcat.util.ExceptionUtils; @@ -71,15 +74,44 @@ public ServerInfo() { String number = null; Properties props = new Properties(); - try (InputStream is = ServerInfo.class.getResourceAsStream("/org/apache/catalina/util/ServerInfo.properties")) { - props.load(is); - info = props.getProperty("server.info"); - built = props.getProperty("server.built"); - builtIso = props.getProperty("server.built.iso"); - number = props.getProperty("server.number"); + + try { + if (Globals.LOAD_SERVER_INFO_OVERRIDE) { + // Merge every ServerInfo.properties on the class path in reverse + // order, so the bundled catalina.jar copy provides the defaults + // and an override file (e.g. in $CATALINA_BASE/lib) replaces + // only the properties it sets. + List urls = Collections.list(ServerInfo.class.getClassLoader() + .getResources("org/apache/catalina/util/ServerInfo.properties")); + Collections.reverse(urls); + for (URL url : urls) { + try (InputStream is = url.openStream()) { + props.load(is); + } catch (Throwable t) { + // Catch Throwable so one unreadable or malformed resource + // does not stop the others loading; narrowing to + // IOException was rejected on apache/tomcat PR #366. + ExceptionUtils.handleThrowable(t); + } + } + } else { + // Original behavior: load only the first resource found + try (InputStream is = ServerInfo.class.getResourceAsStream( + "/org/apache/catalina/util/ServerInfo.properties")) { + if (is != null) { + props.load(is); + } + } + } } catch (Throwable t) { ExceptionUtils.handleThrowable(t); } + + info = props.getProperty("server.info"); + built = props.getProperty("server.built"); + builtIso = props.getProperty("server.built.iso"); + number = props.getProperty("server.number"); + if (info == null || info.equals("Apache Tomcat/@VERSION@")) { info = "Apache Tomcat/12.0.x-dev"; } diff --git a/test/org/apache/catalina/util/TestServerInfoOverride.java b/test/org/apache/catalina/util/TestServerInfoOverride.java new file mode 100644 index 000000000000..bcba8dc59cef --- /dev/null +++ b/test/org/apache/catalina/util/TestServerInfoOverride.java @@ -0,0 +1,158 @@ +/* + * 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.util; + +import java.io.File; +import java.io.OutputStream; +import java.lang.reflect.Method; +import java.net.URL; +import java.net.URLClassLoader; +import java.nio.charset.StandardCharsets; +import java.nio.file.Files; +import java.util.ArrayList; +import java.util.List; +import java.util.Properties; + +import org.junit.Assert; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; + +/** + * Tests the opt-in {@code ServerInfo.properties} merge behaviour enabled by the + * {@code org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE} system property. + *

+ * The default behaviour (property unset) is unchanged and is covered by + * {@code TestServerInfo}: only the first {@code ServerInfo.properties} on the + * class path is read, with no merging. When the property is set to {@code true} + * every {@code ServerInfo.properties} on the class path is merged so an override + * file can replace individual properties while the rest keep their bundled + * values. + *

+ * {@link ServerInfo} reads its values in a static initializer that runs once per + * class loader, and {@link org.apache.catalina.Globals} reads the controlling + * system property once when it is initialized. To exercise the real code this + * test loads a fresh copy of both classes in an isolated {@link URLClassLoader} + * whose class path places an override {@code ServerInfo.properties} ahead of a + * bundled one. + */ +public class TestServerInfoOverride { + + private static final String PROP = "org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE"; + private static final String RESOURCE = "org/apache/catalina/util/ServerInfo.properties"; + + private static final String BUNDLED = + "server.info=Apache Tomcat/99.0.0\nserver.number=99.0.0\n" + + "server.built=Jan 1 2026\nserver.built.iso=2026-01-01\n"; + + @Rule + public final TemporaryFolder tmp = new TemporaryFolder(); + + /** + * With the feature enabled, values present in the override file win while + * values it does not set fall back to the bundled defaults. + * + * @throws Exception if the test experiences an unexpected error + */ + @Test + public void testPartialOverrideMergedWhenEnabled() throws Exception { + File overrideDir = writeProps("override", + "server.info=Custom Tomcat/1.2.3\nserver.number=1.2.3\n"); + File bundledDir = writeProps("bundled", BUNDLED); + + Properties result = loadServerInfo(true, overrideDir, bundledDir); + + // Set by the override file - the override wins. + Assert.assertEquals("Custom Tomcat/1.2.3", result.getProperty("server.info")); + Assert.assertEquals("1.2.3", result.getProperty("server.number")); + // Not set by the override file - the bundled defaults are retained. + Assert.assertEquals("Jan 1 2026", result.getProperty("server.built")); + Assert.assertEquals("2026-01-01", result.getProperty("server.built.iso")); + } + + /** + * Create {@code /org/apache/catalina/util/ServerInfo.properties} under + * the temporary folder with the supplied content and return the root + * directory so it can be added to a class path. + */ + private File writeProps(String name, String content) throws Exception { + File root = tmp.newFolder(name); + File file = new File(root, RESOURCE); + Files.createDirectories(file.getParentFile().toPath()); + try (OutputStream os = Files.newOutputStream(file.toPath())) { + os.write(content.getBytes(StandardCharsets.UTF_8)); + } + return root; + } + + /** + * Load a fresh {@link ServerInfo} in an isolated class loader with the given + * override state and class path prefix, and return its resolved values. + *

+ * The supplied directories are placed at the front of the class path in + * order, so - once {@link ServerInfo} reverses the discovered resources when + * the feature is enabled - the later directories act as defaults that the + * earlier ones override. + */ + private Properties loadServerInfo(boolean overrideEnabled, File... classpathPrefix) + throws Exception { + + List urls = new ArrayList<>(); + for (File dir : classpathPrefix) { + urls.add(dir.toURI().toURL()); + } + // Include the current class path so the isolated loader can resolve the + // Tomcat classes (and their dependencies) it needs to load fresh. + for (String entry : System.getProperty("java.class.path").split(File.pathSeparator)) { + urls.add(new File(entry).toURI().toURL()); + } + + String previous = System.getProperty(PROP); + if (overrideEnabled) { + System.setProperty(PROP, "true"); + } else { + System.clearProperty(PROP); + } + + // Parent is the platform class loader so that ServerInfo and Globals are + // loaded fresh from our URLs (and thus re-run their static initializers) + // rather than being delegated to the application class loader. + try (URLClassLoader loader = new URLClassLoader(urls.toArray(new URL[0]), + ClassLoader.getPlatformClassLoader())) { + + Class clazz = Class.forName("org.apache.catalina.util.ServerInfo", true, loader); + + Properties result = new Properties(); + result.setProperty("server.info", invoke(clazz, "getServerInfo")); + result.setProperty("server.number", invoke(clazz, "getServerNumber")); + result.setProperty("server.built", invoke(clazz, "getServerBuilt")); + result.setProperty("server.built.iso", invoke(clazz, "getServerBuiltISO")); + return result; + } finally { + if (previous == null) { + System.clearProperty(PROP); + } else { + System.setProperty(PROP, previous); + } + } + } + + private static String invoke(Class clazz, String method) throws Exception { + Method m = clazz.getMethod(method); + return (String) m.invoke(null); + } +} diff --git a/webapps/docs/changelog.xml b/webapps/docs/changelog.xml index 6d46c8d137bc..d5ca27b4c4c5 100644 --- a/webapps/docs/changelog.xml +++ b/webapps/docs/changelog.xml @@ -121,6 +121,15 @@ + + Add an opt-in system property + org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE that, when + set to true, merges every ServerInfo.properties + on the class path so a file placed in $CATALINA_BASE/lib can + override individual server version strings while the properties it does + not set retain their bundled values. The default behavior is unchanged. + (csutherl) + Lower the log level to debug when OpenSSL initialization fails in OpenSSLLifecycleListener to avoid stack traces diff --git a/webapps/docs/config/systemprops.xml b/webapps/docs/config/systemprops.xml index a720ca4d031a..7a09dfe7d27d 100644 --- a/webapps/docs/config/systemprops.xml +++ b/webapps/docs/config/systemprops.xml @@ -388,6 +388,17 @@ (100 MiB) will be used.

+ +

If true, every ServerInfo.properties on the + class path is merged: the copy bundled in catalina.jar + provides the defaults and an override file (for example in + $CATALINA_BASE/lib/) replaces only the properties it sets. + This customizes the reported server version strings without modifying + catalina.jar.

+

If not specified, the default value of false is used and + only the first ServerInfo.properties found is loaded.

+
+ diff --git a/webapps/docs/security-howto.xml b/webapps/docs/security-howto.xml index acd15059b480..579dbcc48b0f 100644 --- a/webapps/docs/security-howto.xml +++ b/webapps/docs/security-howto.xml @@ -448,6 +448,11 @@ number reported in some of the management tools and may make it harder to determine the real version installed. The CATALINA_HOME/bin/version.bat|sh script will still report the correct version number.

+

By default only the first ServerInfo.properties on the class + path is used, so any property it omits falls back to a generic default. To + override only specific properties and keep the real values for the rest, set + org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE to + true so the bundled values are loaded first as defaults.

The default ErrorReportValve can display stack traces and/or JSP source code to clients when an error occurs. To avoid this, custom error @@ -555,6 +560,11 @@ determine the real version installed. The CATALINA_HOME/bin/version.bat|sh script will still report the correct version number.

+

By default only the first ServerInfo.properties on the class + path is used, so any property it omits falls back to a generic default. To + override only specific properties and keep the real values for the rest, set + org.apache.catalina.util.LOAD_SERVER_INFO_OVERRIDE to + true so the bundled values are loaded first as defaults.

The CGI Servlet is disabled by default. If enabled, the debug initialisation parameter should not be set to 10 or higher on a