From 4df1415df8d16e068e8e57023ee303a70e5ccd42 Mon Sep 17 00:00:00 2001
From: Thomas Florio <thomas.florio@suse.com>
Date: Tue, 12 May 2026 14:58:39 +0200
Subject: [PATCH 1/1] fix: Limit SCRAM PBKDF2 iterations accepted from the
 server

A malicious or compromised PostgreSQL server can advertise an
arbitrarily large PBKDF2 iteration count in its SCRAM
server-first-message, forcing the client to burn CPU inside
clientFinalMessage() before authentication can possibly fail. Combined
with an abandoned connect-thread on loginTimeout expiry, that CPU
continues spinning after the caller has given up.

We add a new `scramMaxIterations` connection property (default 100000)
and validate the iteration count from ServerFirstMessage against it
after parsing but before the PBKDF2-heavy clientFinalMessage() step.
Exceeding the cap throws a PSQLException with CONNECTION_REJECTED and
an error message naming the property so operators can raise it for
trusted servers that legitimately use a higher count.

Fixes CVE-2026-42198
---
 src/main/java/org/postgresql/PGProperty.java  |  14 +++
 .../core/v3/ConnectionFactoryImpl.java        |  10 +-
 .../postgresql/ds/common/BaseDataSource.java  |  16 +++
 .../jre7/sasl/ScramAuthenticator.java         |  15 ++-
 .../java/org/postgresql/jdbc/ScramTest.java   | 103 ++++++++++++++++++
 .../java/org/postgresql/test/TestUtil.java    |  15 +++
 .../test/jdbc2/ResultSetRefreshTest.java      |   8 +-
 7 files changed, 174 insertions(+), 7 deletions(-)

diff --git a/src/main/java/org/postgresql/PGProperty.java b/src/main/java/org/postgresql/PGProperty.java
index f6e43b7..f7931c4 100644
--- a/src/main/java/org/postgresql/PGProperty.java
+++ b/src/main/java/org/postgresql/PGProperty.java
@@ -466,6 +466,20 @@ public enum PGProperty {
     "false",
     "Enable optimization to rewrite and collapse compatible INSERT statements that are batched."),
 
+  /**
+   * Maximum number of PBKDF2 iterations the client will accept from the server during SCRAM
+   * authentication. If the server advertises more iterations than this value, authentication
+   * is rejected before the expensive PBKDF2 computation runs. This mitigates a denial-of-service
+   * vector where a malicious or compromised server forces the client to burn CPU on an
+   * attacker-controlled iteration count. Must be a non-negative integer. Defaults to 100000. Raise
+   * only if you know you are connecting to a trusted server that legitimately uses a higher
+   * iteration count. A value of zero disables this check.
+   */
+  SCRAM_MAX_ITERATIONS(
+      "scramMaxIterations",
+      "100000",
+      "Maximum PBKDF2 iteration count accepted from the server during SCRAM authentication. A value of zero disables this check."),
+
   /**
    * Socket write buffer size (SO_SNDBUF). A value of {@code -1}, which is the default, means system
    * default.
diff --git a/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java b/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java
index 07cd207..d462766 100644
--- a/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java
+++ b/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java
@@ -754,9 +754,15 @@ public class ConnectionFactoryImpl extends ConnectionFactory {
 
               case AUTH_REQ_SASL:
                 LOGGER.log(Level.FINEST, " <=BE AuthenticationSASL");
-
+                int scramMaxIterations = PGProperty.SCRAM_MAX_ITERATIONS.getInt(info);
+                if (scramMaxIterations < 0) {
+                  throw new PSQLException(
+                          GT.tr("{0} must be a non-negative integer, but was: {1}",
+                                  PGProperty.SCRAM_MAX_ITERATIONS.getName(), scramMaxIterations),
+                          PSQLState.INVALID_PARAMETER_VALUE);
+                }
                 //#if mvn.project.property.postgresql.jdbc.spec >= "JDBC4.1"
-                scramAuthenticator = new org.postgresql.jre7.sasl.ScramAuthenticator(user, castNonNull(password), pgStream);
+                scramAuthenticator = new org.postgresql.jre7.sasl.ScramAuthenticator(user, castNonNull(password), pgStream, scramMaxIterations);
                 scramAuthenticator.processServerMechanismsAndInit();
                 scramAuthenticator.sendScramClientFirstMessage();
                 // This works as follows:
diff --git a/src/main/java/org/postgresql/ds/common/BaseDataSource.java b/src/main/java/org/postgresql/ds/common/BaseDataSource.java
index 39d1c59..bbae55b 100644
--- a/src/main/java/org/postgresql/ds/common/BaseDataSource.java
+++ b/src/main/java/org/postgresql/ds/common/BaseDataSource.java
@@ -1255,6 +1255,22 @@ public abstract class BaseDataSource implements CommonDataSource, Referenceable
     return url.toString();
   }
 
+  /**
+   * @return maximum PBKDF2 iteration count accepted during SCRAM authentication
+   * @see PGProperty#SCRAM_MAX_ITERATIONS
+   */
+  public int getScramMaxIterations() {
+    return PGProperty.SCRAM_MAX_ITERATIONS.getIntNoCheck(properties);
+  }
+
+  /**
+   * @param scramMaxIterations maximum PBKDF2 iteration count accepted during SCRAM authentication
+   * @see PGProperty#SCRAM_MAX_ITERATIONS
+   */
+  public void setScramMaxIterations(int scramMaxIterations) {
+    PGProperty.SCRAM_MAX_ITERATIONS.set(properties, scramMaxIterations);
+  }
+
   /**
    * Generates a {@link DriverManager} URL from the other properties supplied.
    *
diff --git a/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java b/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java
index 55d8ff7..f012c1f 100644
--- a/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java
+++ b/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java
@@ -7,6 +7,7 @@ package org.postgresql.jre7.sasl;
 
 import static org.postgresql.util.internal.Nullness.castNonNull;
 
+import org.postgresql.PGProperty;
 import org.postgresql.core.PGStream;
 import org.postgresql.util.GT;
 import org.postgresql.util.PSQLException;
@@ -34,6 +35,7 @@ public class ScramAuthenticator {
   private final String user;
   private final String password;
   private final PGStream pgStream;
+  private final int maxIterations;
   private /* @Nullable */ ScramClient scramClient;
   private /* @Nullable */ ScramSession scramSession;
   private /* @Nullable */ ScramSession.ClientFinalProcessor clientFinalProcessor;
@@ -50,10 +52,11 @@ public class ScramAuthenticator {
     pgStream.flush();
   }
 
-  public ScramAuthenticator(String user, String password, PGStream pgStream) {
+  public ScramAuthenticator(String user, String password, PGStream pgStream, int maxIterations) {
     this.user = user;
     this.password = password;
     this.pgStream = pgStream;
+    this.maxIterations = maxIterations;
   }
 
   public void processServerMechanismsAndInit() throws IOException, PSQLException {
@@ -144,6 +147,16 @@ public class ScramAuthenticator {
                  );
     }
 
+    int iterations = serverFirstProcessor.getIteration();
+    if (maxIterations > 0 && iterations > maxIterations) {
+      throw new PSQLException(
+              GT.tr("Server requested {0} SCRAM PBKDF2 iterations, which exceeds the "
+                    + "client-side limit of {1}. If you trust this server, raise the "
+                    + "{2} connection property.",
+                      iterations, maxIterations, PGProperty.SCRAM_MAX_ITERATIONS.getName()),
+              PSQLState.CONNECTION_REJECTED);
+    }
+
     clientFinalProcessor = serverFirstProcessor.clientFinalProcessor(password);
 
     String clientFinalMessage = clientFinalProcessor.clientFinalMessage();
diff --git a/src/test/java/org/postgresql/jdbc/ScramTest.java b/src/test/java/org/postgresql/jdbc/ScramTest.java
index 9c6e3e0..08fb651 100644
--- a/src/test/java/org/postgresql/jdbc/ScramTest.java
+++ b/src/test/java/org/postgresql/jdbc/ScramTest.java
@@ -11,12 +11,16 @@ 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.core.Version;
 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 +28,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 +99,72 @@ class ScramTest {
     assertEquals(PSQLState.INVALID_PASSWORD.getState(), ex.getSQLState());
   }
 
+  @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 {
+
+    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)) {
+      assumeVersion16OrGreater(con);
+      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 +173,36 @@ class ScramTest {
     }
   }
 
+  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));
+  }
+
+  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$<iter>:<salt-base64>$<StoredKey-base64>:<ServerKey-base64>
+    // 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);
+  }
+
+  private static void assumeVersion16OrGreater(Connection con) throws SQLException {
+    String dbVersionNumber = con.getMetaData().getDatabaseProductVersion();
+    Version version = ServerVersion.from(dbVersionNumber);
+
+    assumeTrue(version.getVersionNum() >= 16, "Test requires PostgreSQL 16 or greater, found: " + version.getVersionNum());
+  }
 }
diff --git a/src/test/java/org/postgresql/test/TestUtil.java b/src/test/java/org/postgresql/test/TestUtil.java
index 7064f86..4fe217e 100644
--- a/src/test/java/org/postgresql/test/TestUtil.java
+++ b/src/test/java/org/postgresql/test/TestUtil.java
@@ -1059,4 +1059,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;
+  }
 }
diff --git a/src/test/java/org/postgresql/test/jdbc2/ResultSetRefreshTest.java b/src/test/java/org/postgresql/test/jdbc2/ResultSetRefreshTest.java
index 023f271..a4a9b61 100644
--- a/src/test/java/org/postgresql/test/jdbc2/ResultSetRefreshTest.java
+++ b/src/test/java/org/postgresql/test/jdbc2/ResultSetRefreshTest.java
@@ -19,8 +19,8 @@ public class ResultSetRefreshTest extends BaseTest4 {
   @Test
   public void testWithDataColumnThatRequiresEscaping() throws Exception {
     TestUtil.dropTable(con, "refresh_row_bad_ident");
-    TestUtil.execute(con, "CREATE TABLE refresh_row_bad_ident (id int PRIMARY KEY, \"1 FROM refresh_row_bad_ident; SELECT 2; SELECT *\" int)");
-    TestUtil.execute(con, "INSERT INTO refresh_row_bad_ident (id) VALUES (1), (2), (3)");
+    TestUtil.execute("CREATE TABLE refresh_row_bad_ident (id int PRIMARY KEY, \"1 FROM refresh_row_bad_ident; SELECT 2; SELECT *\" int)", con);
+    TestUtil.execute("INSERT INTO refresh_row_bad_ident (id) VALUES (1), (2), (3)", con);
 
     Statement stmt = con.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_UPDATABLE);
     ResultSet rs = stmt.executeQuery("SELECT * FROM refresh_row_bad_ident");
@@ -37,8 +37,8 @@ public class ResultSetRefreshTest extends BaseTest4 {
   @Test
   public void testWithKeyColumnThatRequiresEscaping() throws Exception {
     TestUtil.dropTable(con, "refresh_row_bad_ident");
-    TestUtil.execute(con, "CREATE TABLE refresh_row_bad_ident (\"my key\" int PRIMARY KEY)");
-    TestUtil.execute(con, "INSERT INTO refresh_row_bad_ident VALUES (1), (2), (3)");
+    TestUtil.execute("CREATE TABLE refresh_row_bad_ident (\"my key\" int PRIMARY KEY)", con);
+    TestUtil.execute("INSERT INTO refresh_row_bad_ident VALUES (1), (2), (3)", con);
 
     Statement stmt = con.createStatement(ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_UPDATABLE);
     ResultSet rs = stmt.executeQuery("SELECT * FROM refresh_row_bad_ident");
-- 
2.54.0

