From 22bbac9d6ab7e4e41035f3ac252449e2e99c4e11 Mon Sep 17 00:00:00 2001 From: Stefan Miklosovic Date: Wed, 17 Jun 2026 14:28:52 +0200 Subject: [PATCH] Fix PasswordObfuscator failing to obfuscate certain passwords patch by Stefan Miklosovic; reviewed by Dmitry Konstantinov for CASSANDRA-21113 --- CHANGES.txt | 1 + .../cassandra/cql3/PasswordObfuscator.java | 10 +- .../cql3/PasswordObfuscatorTest.java | 126 +++++++++++++++++- 3 files changed, 134 insertions(+), 3 deletions(-) diff --git a/CHANGES.txt b/CHANGES.txt index f39b7ff06a..fbc2301eac 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -1,4 +1,5 @@ 4.0.21 + * Fix PasswordObfuscator failing to obfuscate certain passwords (CASSANDRA-21113) * Fix negative memtable allocator ownership when an update is shadowed by an existing row deletion (CASSANDRA-21469) * Consider first token of SSTable when calculating SSTable intersection in LeveledScanner (CASSANDRA-21369) * Remove inFlightEcho entry on ECHO_REQ failure (CASSANDRA-21428) diff --git a/src/java/org/apache/cassandra/cql3/PasswordObfuscator.java b/src/java/org/apache/cassandra/cql3/PasswordObfuscator.java index 89962f90d7..f648ccf87f 100644 --- a/src/java/org/apache/cassandra/cql3/PasswordObfuscator.java +++ b/src/java/org/apache/cassandra/cql3/PasswordObfuscator.java @@ -18,6 +18,8 @@ package org.apache.cassandra.cql3; +import java.util.regex.Pattern; + import com.google.common.base.Optional; import org.apache.cassandra.auth.PasswordAuthenticator; @@ -65,7 +67,11 @@ public class PasswordObfuscator if (!pass.isPresent() || pass.get().isEmpty()) return query; - // match new line, case insensitive (?si), and PASSWORD_TOKEN up to the actual password greedy. Group that and replace the password - return query.replaceAll("((?si)"+ PASSWORD_TOKEN + ".+?)" + pass.get(), "$1" + PasswordObfuscator.OBFUSCATION_TOKEN); + // CQL-escape the password, the password in RoleOptions is unescaped, but the query contains the escaped form. + String escapedPassword = pass.get().replace("'", "''"); + + // Use Pattern.quote() to safely handle all special regex characters. + String pattern = "((?si)" + PASSWORD_TOKEN + ".+?)" + Pattern.quote(escapedPassword); + return query.replaceAll(pattern, "$1" + OBFUSCATION_TOKEN); } } diff --git a/test/unit/org/apache/cassandra/cql3/PasswordObfuscatorTest.java b/test/unit/org/apache/cassandra/cql3/PasswordObfuscatorTest.java index 09a366e49c..f1bebae139 100644 --- a/test/unit/org/apache/cassandra/cql3/PasswordObfuscatorTest.java +++ b/test/unit/org/apache/cassandra/cql3/PasswordObfuscatorTest.java @@ -18,24 +18,31 @@ package org.apache.cassandra.cql3; +import java.util.ArrayList; +import java.util.List; + import org.junit.BeforeClass; import org.junit.Test; +import org.apache.cassandra.auth.IRoleManager; import org.apache.cassandra.auth.RoleOptions; import static org.apache.cassandra.cql3.PasswordObfuscator.*; import static java.lang.String.format; import static org.junit.Assert.assertEquals; +import static org.junit.Assert.fail; public class PasswordObfuscatorTest { private static final RoleOptions opts = new RoleOptions(); private static final String optsPassword = "testpassword"; + public static final int ASCII_START = 32; + public static final int ASCII_END = 126; @BeforeClass public static void startup() { - opts.setOption(org.apache.cassandra.auth.IRoleManager.Option.PASSWORD, "testpassword"); + opts.setOption(org.apache.cassandra.auth.IRoleManager.Option.PASSWORD, optsPassword); } @Test @@ -237,4 +244,121 @@ public class PasswordObfuscatorTest "APPLY BATCH;", optsPassword, optsPassword), opts)); } + + /** + * Tests that passwords containing the literal string \E are properly obfuscated. + *

+ * The \E sequence is special in Java regex as it ends a \Q...\E quoted block. + * If inline \Q...\E quoting is used instead of Pattern.quote(), passwords containing + * \E will break the regex pattern and cause either an exception or failed obfuscation. + */ + @Test + public void testPasswordWithRegexEndQuote() + { + // \E in the middle + assertPasswordObfuscated("secret\\Epassword"); + + // \E at the start + assertPasswordObfuscated("\\Esecretpassword"); + + // \E at the end + assertPasswordObfuscated("secretpassword\\E"); + + // \E followed by regex special char + + assertPasswordObfuscated("secret\\E+password"); + + // Multiple \E sequences + assertPasswordObfuscated("sec\\Eret\\Epass"); + } + + /** + * Helper method to test that a password is properly obfuscated in a CREATE ROLE statement. + * + * @param password the password to test (unescaped, as stored in RoleOptions) + */ + private void assertPasswordObfuscated(String password) + { + RoleOptions roleOpts = new RoleOptions(); + roleOpts.setOption(IRoleManager.Option.PASSWORD, password); + + // Escape single quotes for CQL query string + String escapedPassword = password.replace("'", "''"); + String query = format("CREATE ROLE role1 WITH PASSWORD = '%s'", escapedPassword); + String expected = format("CREATE ROLE role1 WITH PASSWORD = '%s'", OBFUSCATION_TOKEN); + + assertEquals("Password should be obfuscated: " + password, expected, obfuscate(query, roleOpts)); + } + + /** + * Tests that passwords containing each printable special character (outside a-z, A-Z, 0-9) + * are properly obfuscated by PasswordObfuscator. + *

+ * This test iterates through all printable ASCII special characters (32-126, excluding + * alphanumeric) and tests each character at three positions: start, middle, and end of + * the password. Any failures are collected and reported at the end. + */ + @Test + public void testAllPrintableSpecialCharactersObfuscation() + { + List failures = new ArrayList<>(); + + for (char c = ASCII_START; c <= ASCII_END; c++) + { + // Skip alphanumeric characters + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9')) + continue; + + // Test character at start, middle, and end of password + String[] passwords = { + c + "password", // at start + "pass" + c + "word", // in middle + "password" + c // at end + }; + + for (String password : passwords) + { + String escapedPassword = password.replace("'", "''"); + String query = format("CREATE ROLE role1 WITH PASSWORD = '%s'", escapedPassword); + String expected = format("CREATE ROLE role1 WITH PASSWORD = '%s'", OBFUSCATION_TOKEN); + + RoleOptions testOpts = new RoleOptions(); + testOpts.setOption(IRoleManager.Option.PASSWORD, password); + + try + { + String result = obfuscate(query, testOpts); + + if (!expected.equals(result)) + { + if (result.contains(escapedPassword) || result.contains(password)) + { + failures.add(format("Password '%s': PASSWORD LEAKED - Result: %s", + password, result)); + } + else + { + failures.add(format("Password '%s': Unexpected result - Expected: %s, Got: %s", + password, expected, result)); + } + } + } + catch (Exception e) + { + failures.add(format("Password '%s': Exception thrown - %s: %s", + password, e.getClass().getSimpleName(), e.getMessage())); + } + } + } + + if (!failures.isEmpty()) + { + StringBuilder sb = new StringBuilder(); + sb.append(format("%d password(s) with special characters failed obfuscation:\n", failures.size())); + for (String failure : failures) + { + sb.append(" - ").append(failure).append("\n"); + } + fail(sb.toString()); + } + } }