This is an automated email from the ASF dual-hosted git repository. rmaucher pushed a commit to branch main in repository https://gitbox.apache.org/repos/asf/tomcat.git
commit 358ffc6051d0f8cdfe1ae51cb3b4a2d6af545e6b Author: opencode <[email protected]> AuthorDate: Thu Oct 29 23:56:58 2026 +0100 Mask credential values embedded in the url and connectionProperties diagnostics output The masking added for the dedicated password property in PoolProperties.toString() covered only the field itself, while the url and connectionProperties raw strings were printed verbatim, so a JDBC URL such as jdbc:mysql://host/db?password=xxx or a connectionProperties pair like password=inline-secret appeared in cleartext wherever the dump was surfaced: JMX reads, diagnostics logging and DataSource.toString(). The two raw strings now pass through a linear scan that replaces the value of a case-insensitive password, pass or pwd key at a segment boundary with the same mask the field uses, matching the key segmentation of the real properties parser; lookalike keys such as compass or passwordfile are left readable. Forms that would need full URL-dialect parsing, such as the userinfo component, remain documented as not recognised. This is defence-in-depth for diagnostics output of trusted configuration, not a security fix. --- .../apache/tomcat/jdbc/pool/PoolProperties.java | 107 ++++++++++++++++++++- .../tomcat/jdbc/pool/TestPoolProperties.java | 44 +++++++++ 2 files changed, 149 insertions(+), 2 deletions(-) diff --git a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolProperties.java b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolProperties.java index 1782e75782..d3c744574e 100644 --- a/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolProperties.java +++ b/modules/jdbc-pool/src/main/java/org/apache/tomcat/jdbc/pool/PoolProperties.java @@ -41,6 +41,9 @@ public class PoolProperties implements PoolConfiguration, Cloneable, Serializabl private static final long serialVersionUID = -8519283440854213745L; private static final Log log = LogFactory.getLog(PoolProperties.class); + /** The mask the {@link #toString()} dump prints in place of a password. */ + private static final String PASSWORD_MASK = "********"; + /** * Constructs a PoolProperties with default values. */ @@ -781,12 +784,19 @@ public class PoolProperties implements PoolConfiguration, Cloneable, Serializabl } catch (NoSuchMethodException nm) { continue; } + Object value = m.invoke(this, new Object[0]); buf.append(field); buf.append('='); if (DataSourceFactory.PROP_PASSWORD.equals(field)) { - buf.append("********"); + buf.append(PASSWORD_MASK); + } else if (DataSourceFactory.PROP_URL.equals(field) + || DataSourceFactory.PROP_CONNECTIONPROPERTIES.equals(field)) { + // The same dump prints these raw configuration + // strings, so a credential typed into one of them + // has to be masked too, not just the password field. + buf.append(value == null ? "null" : maskCredentials((String) value)); } else { - buf.append(m.invoke(this, new Object[0])); + buf.append(value); } buf.append("; "); break; @@ -802,6 +812,99 @@ public class PoolProperties implements PoolConfiguration, Cloneable, Serializabl return buf.toString(); } + + /** + * Masks the value of credential-like {@code key=value} pairs embedded in a + * raw configuration string so that a password typed into the JDBC URL query + * string or the connection properties is not printed in cleartext the way + * the dedicated password field is masked in {@link #toString()}. The keys + * recognised, compared ignoring case, are {@code password}, {@code pass} + * and {@code pwd}. A key is honoured only at a segment boundary - the start + * of the string or one of {@code ;}, {@code &} or {@code ?} - so a key such + * as {@code compass} is left alone, and its value runs to the next + * {@code ;}, {@code &} or the end of the string. + * <p> + * Only the {@code key=value} shape is scrubbed. The + * {@code user:password@host} userinfo form, the {@code key:value} and + * {@code key value} forms that a {@link java.util.Properties} reader + * additionally accepts, and driver-specific secrets that use a key other + * than the three above are not recognised; recognising them would require + * a full parse of each driver's URL dialect. + * + * @param value the raw configuration string, may be {@code null} + * + * @return the string with every credential key's value replaced by the mask, + * or the string unchanged when it holds no recognised credential key + */ + private static String maskCredentials(String value) { + if (value == null || value.indexOf('=') < 0) { + return value; + } + StringBuilder result = new StringBuilder(value.length()); + int i = 0; + while (i < value.length()) { + int equals = value.indexOf('=', i); + if (equals < 0) { + break; + } + int keyStart = equals; + while (keyStart > 0 && !isCredentialSegmentSeparator(value.charAt(keyStart - 1))) { + keyStart--; + } + String key = value.substring(keyStart, equals).trim(); + int valueStart = equals + 1; + int valueEnd = value.length(); + for (int k = valueStart; k < value.length(); k++) { + char c = value.charAt(k); + if (c == ';' || c == '&') { + valueEnd = k; + break; + } + } + result.append(value, i, equals); + result.append('='); + if (isCredentialKey(key)) { + result.append(PASSWORD_MASK); + } else { + result.append(value, valueStart, valueEnd); + } + // Resume after the value so a '=' inside a masked value cannot be + // mistaken for the start of a new key. + i = valueEnd; + } + result.append(value, i, value.length()); + return result.toString(); + } + + + /** + * Whether {@code c} ends one configuration segment and starts the next, + * covering the URL query separators and the semicolon used by the + * connection properties string. + * + * @param c the character to test + * + * @return {@code true} when the character is a segment boundary + */ + private static boolean isCredentialSegmentSeparator(char c) { + return c == ';' || c == '&' || c == '?'; + } + + + /** + * Whether {@code key} names a credential, ignoring case and surrounding + * whitespace. + * + * @param key the extracted key + * + * @return {@code true} when the key is one of the recognised credential keys + */ + private static boolean isCredentialKey(String key) { + return DataSourceFactory.PROP_PASSWORD.equalsIgnoreCase(key) + || "pass".equalsIgnoreCase(key) + || "pwd".equalsIgnoreCase(key); + } + /** * Returns the current pool counter value. * diff --git a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestPoolProperties.java b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestPoolProperties.java index bfd2202d6d..689a8a73ed 100644 --- a/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestPoolProperties.java +++ b/modules/jdbc-pool/src/test/java/org/apache/tomcat/jdbc/pool/TestPoolProperties.java @@ -249,4 +249,48 @@ class TestPoolProperties { Assertions.assertTrue(asString.contains("jdbc:h2:mem:shown"), "the JDBC URL must stay readable, was: " + asString); } + + @Test + void toStringMasksAPasswordEmbeddedInTheConnectionProperties() { + PoolProperties properties = new PoolProperties(); + properties.setConnectionProperties("socketTimeout=1000;password=inline-secret;PWD=Shouted-Secret"); + + String asString = properties.toString(); + + Assertions.assertFalse(asString.contains("inline-secret"), + "the password embedded in the string must not leak, was: " + asString); + Assertions.assertFalse(asString.contains("Shouted-Secret"), + "the key must be recognised ignoring case, was: " + asString); + Assertions.assertTrue(asString.contains("connectionProperties=socketTimeout=1000;password=********;PWD=********"), + "the other pair must keep printing with the pair masked, was: " + asString); + } + + @Test + void toStringMasksAPasswordParameterInTheUrl() { + PoolProperties properties = new PoolProperties(); + properties.setUrl("jdbc:mysql://localhost:3306/db?user=me&password=url-secret&sessionVariables=foo=bar&ssl=true"); + + String asString = properties.toString(); + + Assertions.assertFalse(asString.contains("url-secret"), + "the password in the URL query must not leak, was: " + asString); + Assertions.assertTrue(asString.contains("url=jdbc:mysql://localhost:3306/db?user=me" + + "&password=********&sessionVariables=foo=bar&ssl=true"), + "the rest of the URL must stay readable, was: " + asString); + } + + @Test + void toStringKeepsNonCredentialPairsInsideRawStringsUntouched() { + PoolProperties properties = new PoolProperties(); + properties.setUrl("jdbc:h2:mem:shown"); + properties.setConnectionProperties("socketTimeout=1000;mypassword=not-the-key;passwordfile=/etc/pw"); + + String asString = properties.toString(); + + Assertions.assertTrue(asString.contains("connectionProperties=socketTimeout=1000;" + + "mypassword=not-the-key;passwordfile=/etc/pw"), + "only the whole keys password, pass and pwd may be masked, was: " + asString); + Assertions.assertTrue(asString.contains("url=jdbc:h2:mem:shown"), + "a URL without an equals sign must come out unchanged, was: " + asString); + } } --------------------------------------------------------------------- To unsubscribe, e-mail: [email protected] For additional commands, e-mail: [email protected]
