Message-ID: From: "vlsi (@vlsi)" To: "pgjdbc/pgjdbc" Date: Tue, 02 Jun 2026 12:28:46 +0000 Subject: [pgjdbc/pgjdbc] PR #4136: fix: limit SCRAM PBKDF2 iterations in 42.4 List-Id: X-GitHub-Additions: 154 X-GitHub-Author-Id: 213894 X-GitHub-Author-Login: vlsi X-GitHub-Base: release/42.4.x X-GitHub-Changed-Files: 7 X-GitHub-Commits: 1 X-GitHub-Deletions: 2 X-GitHub-Head-Branch: codex/backport-scram-cve-42.4 X-GitHub-Head-SHA: 74e7dbc621e6a4bdd0631192085ce69ca6ad9da6 X-GitHub-Issue: 4136 X-GitHub-Labels: security X-GitHub-Merge-SHA: 2120619c3af21e616021d916c0fd1140fc58e079 X-GitHub-Merged-By: vlsi X-GitHub-Repo: pgjdbc/pgjdbc X-GitHub-State: merged X-GitHub-Type: pull_request X-GitHub-Url: https://github.com/pgjdbc/pgjdbc/pull/4136 Content-Type: text/plain; charset=utf-8 Backport the SCRAM PBKDF2 iteration cap to the 42.4 release line. This adds the scramMaxIterations connection property, rejects server-advertised SCRAM iteration counts above that cap before PBKDF2 runs, and covers the behavior in ScramTest. Verification: - JAVA_HOME=/Users/vlsi/Library/Java/JavaVirtualMachines/liberica-1.8.0_312 ./gradlew --quiet --no-daemon :pgjdbc:cleanTest :pgjdbc:compileTestJava - JAVA_HOME=/Users/vlsi/Library/Java/JavaVirtualMachines/liberica-1.8.0_312 ./gradlew --quiet --no-daemon -PjdkTestVersion=8 :pgjdbc:test --tests org.postgresql.jdbc.ScramTest -x :pgjdbc-osgi-test:test - JAVA_HOME=/Users/vlsi/Library/Java/JavaVirtualMachines/liberica-1.8.0_312 ./gradlew --quiet --no-daemon :pgjdbc:checkstyleMain :pgjdbc:checkstyleTest - git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index 95fef3fca6..7093c62f73 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,10 @@ Notable changes since version 42.0.0, read the complete [History of Changes](htt The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/). ## [Unreleased] +### Security +* fix: Limit SCRAM PBKDF2 iterations accepted from the server. + pgjdbc was vulnerable to a client-side denial of service in SCRAM-SHA-256 authentication, where a malicious or compromised PostgreSQL server could specify an extremely large PBKDF2 iteration count that the driver would process before authentication completed. The driver now rejects SCRAM iteration counts above `scramMaxIterations`, which defaults to 100000. + ### Changed ### Added diff --git a/docs/documentation/head/connect.md b/docs/documentation/head/connect.md index 4ae093a8f7..ab801c97c6 100644 --- a/docs/documentation/head/connect.md +++ b/docs/documentation/head/connect.md @@ -588,6 +588,15 @@ Connection conn = DriverManager.getConnection(url); If we quote them, then we end up sending ""colname"" to the backend instead of "colname" which will not be found. +* **scramMaxIterations** == int + + Maximum PBKDF2 iteration count that pgjdbc will accept from the server during SCRAM authentication. + During SCRAM-SHA-256 authentication, the server sends the iteration count used to derive the salted password. + If the server advertises a value higher than `scramMaxIterations`, the driver rejects authentication before + starting the PBKDF2 computation. + This limits client CPU exposure if a malicious or compromised server sends an excessively large iteration count. + A value of zero disables this check. + * **authenticationPluginClassName** == String Fully qualified class name of the class implementing the AuthenticationPlugin interface. diff --git a/pgjdbc/src/main/java/org/postgresql/PGProperty.java b/pgjdbc/src/main/java/org/postgresql/PGProperty.java index d7851d4271..de0ca42fb4 100644 --- a/pgjdbc/src/main/java/org/postgresql/PGProperty.java +++ b/pgjdbc/src/main/java/org/postgresql/PGProperty.java @@ -541,6 +541,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/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java b/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java index 6ef64db941..59e4acb5f6 100644 --- a/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java +++ b/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java @@ -798,6 +798,13 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope 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); + } scramAuthenticator = AuthenticationPluginManager.withPassword(AuthenticationRequestType.SASL, info, password -> { if (password == null) { throw new PSQLException( @@ -811,7 +818,8 @@ private void doAuthentication(PGStream pgStream, String host, String user, Prope "The server requested SCRAM-based authentication, but the password is an empty string."), PSQLState.CONNECTION_REJECTED); } - return new org.postgresql.jre7.sasl.ScramAuthenticator(user, String.valueOf(password), pgStream); + return new org.postgresql.jre7.sasl.ScramAuthenticator(user, + String.valueOf(password), pgStream, scramMaxIterations); }); scramAuthenticator.processServerMechanismsAndInit(); scramAuthenticator.sendScramClientFirstMessage(); diff --git a/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java b/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java index 4a75fdc736..c5d8940d6e 100644 --- a/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java +++ b/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java @@ -1213,6 +1213,22 @@ public void setSocketFactoryArg(@Nullable String socketFactoryArg) { PGProperty.SOCKET_FACTORY_ARG.set(properties, socketFactoryArg); } + /** + * @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); + } + /** * @param replication set to 'database' for logical replication or 'true' for physical replication * @see PGProperty#REPLICATION diff --git a/pgjdbc/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java b/pgjdbc/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java index 59f272a0d1..85cbd7d746 100644 --- a/pgjdbc/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java +++ b/pgjdbc/src/main/java/org/postgresql/jre7/sasl/ScramAuthenticator.java @@ -7,6 +7,7 @@ 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 @@ private void sendAuthenticationMessage(int bodyLength, BodySender bodySender) 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 { @@ -143,6 +146,15 @@ public void processServerFirstMessage(int length) throws IOException, PSQLExcept new Object[] { serverFirstProcessor.getSalt(), serverFirstProcessor.getIteration() } ); } + 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); diff --git a/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java b/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java index 20443cfe4a..0317e9b090 100644 --- a/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java +++ b/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java @@ -12,12 +12,15 @@ import static org.junit.jupiter.api.Assertions.fail; 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.Arguments; import org.junit.jupiter.params.provider.MethodSource; @@ -28,6 +31,7 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.text.NumberFormat; import java.util.Properties; import java.util.stream.Stream; @@ -124,6 +128,79 @@ void testInvalidPasswords(String password, String expectedMessage) throws SQLExc } } + private PSQLException scramAuthExpectingFailure(String scramMaxIterations, + int serverScramIterations, String password) throws SQLException { + createRoleWithCustomScramIters(serverScramIterations); + Properties props = scramProperties(password); + if (scramMaxIterations != null) { + PGProperty.SCRAM_MAX_ITERATIONS.set(props, scramMaxIterations); + } + return assertThrows(PSQLException.class, + () -> DriverManager.getConnection(TestUtil.getURL(), 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()); + NumberFormat nf = NumberFormat.getNumberInstance(); + assertTrue(ex.getMessage().contains(nf.format(serverScramIterations)), + "error should include the server-supplied iteration count, 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"); + 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 = scramProperties(password); + PGProperty.SCRAM_MAX_ITERATIONS.set(props, "1234"); + PSQLException ex = assertThrows(PSQLException.class, + () -> DriverManager.getConnection(TestUtil.getURL(), props)); + 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)); + int serverScramIterations = Integer.parseInt( + TestUtil.queryForString(con, "SHOW scram_iterations")); + String password = "t0pSecret"; + createRole(password); + Properties props = scramProperties(password); + PGProperty.SCRAM_MAX_ITERATIONS.set(props, Integer.toString(serverScramIterations)); + try (Connection conn = DriverManager.getConnection(TestUtil.getURL(), props)) { + String username = TestUtil.queryForString(conn, "SELECT USER"); + assertEquals(ROLE_NAME, username); + } + } + + private Properties scramProperties(String password) { + Properties props = new Properties(); + PGProperty.USER.set(props, ROLE_NAME); + props.setProperty("username", ROLE_NAME); + PGProperty.PASSWORD.set(props, password); + return props; + } + private void createRole(String passwd) throws SQLException { try (Statement stmt = con.createStatement()) { stmt.execute("SET password_encryption='scram-sha-256'"); @@ -132,4 +209,16 @@ private void createRole(String passwd) throws SQLException { } } + private void createRoleWithCustomScramIters(int iters) throws SQLException { + TestUtil.execute(con, "DROP ROLE IF EXISTS " + ROLE_NAME); + TestUtil.execute(con, "CREATE ROLE " + ROLE_NAME + " WITH LOGIN"); + // SCRAM-SHA-256$:$: + String encodedPassword = "SCRAM-SHA-256$" + iters + + ":AAAAAAAAAAAAAAAAAAAAAA==" + + "$AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=" + + ":AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="; + TestUtil.execute(con, "UPDATE pg_authid SET rolpassword = '" + encodedPassword + + "' WHERE rolname = '" + ROLE_NAME + "'"); + } + }