From cda68fb74340f5b661b0014a99c24758b6c7f20b Mon Sep 17 00:00:00 2001 From: Marian Koncek Date: Mon, 8 Jun 2026 14:51:26 +0200 Subject: [PATCH 2/2] Add tests for CVE-2026-42198 Co-authored-by: Cursor --- .../org/postgresql/core/ServerVersion.java | 6 +- .../java/org/postgresql/jdbc/ScramTest.java | 196 ++++++++++++++++++ .../java/org/postgresql/test/TestUtil.java | 15 ++ 3 files changed, 216 insertions(+), 1 deletion(-) create mode 100644 src/test/java/org/postgresql/jdbc/ScramTest.java diff --git a/src/main/java/org/postgresql/core/ServerVersion.java b/src/main/java/org/postgresql/core/ServerVersion.java index 9348ba1..e3c9bd7 100644 --- a/src/main/java/org/postgresql/core/ServerVersion.java +++ b/src/main/java/org/postgresql/core/ServerVersion.java @@ -26,7 +26,11 @@ public enum ServerVersion implements Version { v9_6("9.6.0"), v10("10"), v11("11"), - v12("12") + v12("12"), + v13("13"), + v14("14"), + v15("15"), + v16("16") ; private final int version; diff --git a/src/test/java/org/postgresql/jdbc/ScramTest.java b/src/test/java/org/postgresql/jdbc/ScramTest.java new file mode 100644 index 0000000..60bc75a --- /dev/null +++ b/src/test/java/org/postgresql/jdbc/ScramTest.java @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2021, PostgreSQL Global Development Group + * See the LICENSE file in the project root for more information. + */ + +package org.postgresql.jdbc; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertEquals; +import static org.junit.jupiter.api.Assertions.assertThrows; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import org.postgresql.PGProperty; +import org.postgresql.core.ServerVersion; +import org.postgresql.test.TestUtil; +import org.postgresql.util.PSQLException; +import org.postgresql.util.PSQLState; + +import org.junit.jupiter.api.AfterAll; +import org.junit.jupiter.api.BeforeAll; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; + +import java.sql.Connection; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.text.NumberFormat; +import java.util.Properties; + +class ScramTest { + + private static Connection con; + private static final String ROLE_NAME = "testscram"; + + @BeforeAll + public static void setUp() throws Exception { + con = TestUtil.openPrivilegedDB(); + assumeTrue(TestUtil.haveMinimumServerVersion(con, ServerVersion.v10)); + } + + @AfterAll + public static void tearDown() throws Exception { + try (Statement stmt = con.createStatement()) { + stmt.execute("DROP ROLE IF EXISTS " + ROLE_NAME); + } + TestUtil.closeDB(con); + } + + /** + * Test creating a role with passwords WITH spaces and opening a connection using the same + * password, should work because is the "same" password. + * + *

https://github.com/pgjdbc/pgjdbc/issues/1970 + */ + @ParameterizedTest + @ValueSource(strings = {"My Space", "$ec ret", " rover june spelling ", + "!zj5hs*k5 STj@DaRUy", "q\u00A0w\u2000e\u2003r\u2009t\u3000y"}) + void testPasswordWithSpace(String passwd) throws SQLException { + createRole(passwd); // Create role password with spaces. + + Properties props = new Properties(); + props.setProperty("username", ROLE_NAME); + props.setProperty("password", passwd); + + try (Connection c = assertDoesNotThrow(() -> TestUtil.openDB(props)); + Statement stmt = c.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT current_user")) { + assertTrue(rs.next()); + assertEquals(ROLE_NAME, rs.getString(1)); + } + } + + /** + * Test creating a role with passwords WITHOUT spaces and opening a connection using password with + * spaces should fail since the spaces should not be stripped out. + * + *

https://github.com/pgjdbc/pgjdbc/issues/2000 + */ + @ParameterizedTest + @ValueSource(strings = {"My Space", "$ec ret", "rover june spelling", + "!zj5hs*k5 STj@DaRUy", "q\u00A0w\u2000e\u2003r\u2009t\u3000y"}) + void testPasswordWithoutSpace(String passwd) throws SQLException { + String passwdNoSpaces = passwd.codePoints() + .filter(i -> !Character.isSpaceChar(i)) + .collect(StringBuilder::new, StringBuilder::appendCodePoint, StringBuilder::append) + .toString(); + + createRole(passwdNoSpaces); // Create role password without spaces. + + Properties props = new Properties(); + props.setProperty("username", ROLE_NAME); + props.setProperty("password", passwd); // Open connection with spaces + + SQLException ex = assertThrows(SQLException.class, () -> TestUtil.openDB(props)); + assertEquals(PSQLState.INVALID_PASSWORD.getState(), ex.getSQLState()); + } + + private PSQLException scramAuthExpectingFailure(String scramMaxIterations, int serverScramIterations, String password) throws SQLException { + createRoleWithCustomScramIters(serverScramIterations); + Properties props = new Properties(); + props.setProperty("username", ROLE_NAME); + props.setProperty("password", password); + if (scramMaxIterations != null) { + PGProperty.SCRAM_MAX_ITERATIONS.set(props, scramMaxIterations); + } + return assertThrows(PSQLException.class, () -> TestUtil.openDB(props)); + } + + @Test + void rejectIterationCountAboveDefaultCap() throws SQLException { + int serverScramIterations = 789_123_456; + PSQLException ex = scramAuthExpectingFailure(null, serverScramIterations, "does-not-matter"); + assertTrue(ex.getMessage().contains("exceeds"), + "expected iteration-cap error, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains("scramMaxIterations"), + "error should reference the connection property name, got: " + ex.getMessage()); + // The message is formatted through MessageFormat, which applies locale-aware grouping + // to integer arguments; format the expected numbers the same way. + NumberFormat nf = NumberFormat.getNumberInstance(); + assertTrue(ex.getMessage().contains(nf.format(serverScramIterations)), + "error should include the configured cap, got: " + ex.getMessage()); + } + + @Test + void rejectIterationCountAboveCustomCap() throws SQLException { + int scramMaxIterations = 123_456; + int serverScramIterations = 789_123_456; + PSQLException ex = scramAuthExpectingFailure(Integer.toString(scramMaxIterations), serverScramIterations, "does-not-matter"); + // The message is formatted through MessageFormat, which applies locale-aware grouping + // to integer arguments; format the expected numbers the same way. + NumberFormat nf = NumberFormat.getNumberInstance(); + assertTrue(ex.getMessage().contains(nf.format(scramMaxIterations)), + "error should include the configured cap, got: " + ex.getMessage()); + assertTrue(ex.getMessage().contains(nf.format(serverScramIterations)), + "error should include the server-supplied iteration count, got: " + ex.getMessage()); + } + + @Test + void rejectValidCredentialsAboveCustomCap() throws SQLException { + String password = "t0pSecret"; + createRole(password); + Properties props = new Properties(); + props.setProperty("username", ROLE_NAME); + props.setProperty("password", password); + PGProperty.SCRAM_MAX_ITERATIONS.set(props, "1234"); + PSQLException ex = assertThrows(PSQLException.class, () -> TestUtil.openDB(props)); + // The message is formatted through MessageFormat, which applies locale-aware grouping + // to integer arguments; format the expected numbers the same way. + NumberFormat nf = NumberFormat.getNumberInstance(); + assertTrue(ex.getMessage().contains(nf.format(1234)), + "error should include the configured cap, got: " + ex.getMessage()); + } + + @Test + void acceptsValidCredentialsBelowCustomCap() throws SQLException { + assumeTrue(TestUtil.haveMinimumServerVersion(con, ServerVersion.v16), + "scram_iterations configuration requires PostgreSQL 16+"); + int serverScramIterations = Integer.parseInt(TestUtil.queryForString(con, "SHOW scram_iterations")); + String password = "t0pSecret"; + createRole(password); + Properties props = new Properties(); + props.setProperty("username", ROLE_NAME); + props.setProperty("password", password); + PGProperty.SCRAM_MAX_ITERATIONS.set(props, Integer.toString(serverScramIterations)); + try (Connection conn = TestUtil.openDB(props)) { + String username = TestUtil.queryForString(conn, "SELECT USER"); + assertEquals(ROLE_NAME, username); + } + } + + private void createRole(String passwd) throws SQLException { + try (Statement stmt = con.createStatement()) { + stmt.execute("SET password_encryption='scram-sha-256'"); + stmt.execute("DROP ROLE IF EXISTS " + ROLE_NAME); + stmt.execute("CREATE ROLE " + ROLE_NAME + " WITH LOGIN PASSWORD '" + passwd + "'"); + } + } + + private static void createRoleWithCustomScramIters(int iters) throws SQLException { + TestUtil.execute("DROP ROLE IF EXISTS " + ROLE_NAME, con); + TestUtil.execute("CREATE ROLE " + ROLE_NAME + " WITH LOGIN", con); + // SCRAM-SHA-256$:$: + // salt: 16 zero bytes, StoredKey and ServerKey: 32 zero bytes each. + String encodedPassword = "SCRAM-SHA-256$" + iters + + ":AAAAAAAAAAAAAAAAAAAAAA==" + + "$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + ":AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + // NOTE: We must directly update the system catalog to prevent the server from trying to + // verify the password at creation time. Otherwise it will try to hash empty string with + // our huge number of iterations to ensure the password is not an empty string. + TestUtil.execute("UPDATE pg_authid SET rolpassword = '" + encodedPassword + "' WHERE rolname = '" + ROLE_NAME + "'", con); + } +} diff --git a/src/test/java/org/postgresql/test/TestUtil.java b/src/test/java/org/postgresql/test/TestUtil.java index 0ce472c..a7fe975 100644 --- a/src/test/java/org/postgresql/test/TestUtil.java +++ b/src/test/java/org/postgresql/test/TestUtil.java @@ -1020,4 +1020,19 @@ public class TestUtil { } } } + + /** + * Execute a SQL query with a given connection, fetch the first row, and return its + * string value. + */ + public static String queryForString(Connection conn, String sql) throws SQLException { + Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql); + Assert.assertTrue("Query should have returned exactly one row but none was found: " + sql, rs.next()); + String value = rs.getString(1); + Assert.assertFalse("Query should have returned exactly one row but more than one found: " + sql, rs.next()); + rs.close(); + stmt.close(); + return value; + } } -- 2.54.0