From 6e318b53fa3b1b84139fa13b2f121670b884c7da Mon Sep 17 00:00:00 2001 From: Marian Koncek Date: Wed, 20 May 2026 12:58:33 +0200 Subject: [PATCH] Add tests for CVE-2026-42198 --- .../org/postgresql/core/ServerVersion.java | 4 +- .../java/org/postgresql/jdbc/ScramTest.java | 91 +++++++++++++++++++ .../java/org/postgresql/test/TestUtil.java | 15 +++ 3 files changed, 109 insertions(+), 1 deletion(-) diff --git a/src/main/java/org/postgresql/core/ServerVersion.java b/src/main/java/org/postgresql/core/ServerVersion.java index 5d18da1..4036b01 100644 --- a/src/main/java/org/postgresql/core/ServerVersion.java +++ b/src/main/java/org/postgresql/core/ServerVersion.java @@ -30,7 +30,9 @@ public enum ServerVersion implements Version { v11("11"), v12("12"), v13("13"), - v14("14") + 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 index 9c6e3e0..60bc75a 100644 --- a/src/test/java/org/postgresql/jdbc/ScramTest.java +++ b/src/test/java/org/postgresql/jdbc/ScramTest.java @@ -11,12 +11,15 @@ 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; @@ -24,6 +27,7 @@ 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 { @@ -94,6 +98,79 @@ class ScramTest { 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'"); @@ -102,4 +179,18 @@ class ScramTest { } } + 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 7064f86..e89dab0 100644 --- a/src/test/java/org/postgresql/test/TestUtil.java +++ b/src/test/java/org/postgresql/test/TestUtil.java @@ -975,6 +975,21 @@ public class TestUtil { return hasNext; } + /** + * Execute a SQL query with a given connection, fetch the first row, and return its + * string value. + */ + public static /* @Nullable */ 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; + } + /** * Retrieve the backend process id for a given connection. */ -- 2.54.0