Message-ID: From: "vlsi (@vlsi)" To: "pgjdbc/pgjdbc" Date: Tue, 02 Jun 2026 05:53:08 +0000 Subject: [pgjdbc/pgjdbc] PR #4126: fix: limit SCRAM PBKDF2 iterations in 42.6 List-Id: X-GitHub-Additions: 146 X-GitHub-Author-Id: 213894 X-GitHub-Author-Login: vlsi X-GitHub-Base: release/42.6.x X-GitHub-Changed-Files: 7 X-GitHub-Commits: 1 X-GitHub-Deletions: 2 X-GitHub-Head-Branch: codex/backport-scram-cve-42.6 X-GitHub-Head-SHA: 902a6862b83d9d49b24a4beea7e90e22eae4f260 X-GitHub-Issue: 4126 X-GitHub-Labels: security X-GitHub-Merge-SHA: b316c1d0326f34c0482209dca9c4843a3032c23a 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/4126 Content-Type: text/plain; charset=utf-8 Backport the SCRAM PBKDF2 iteration cap to the 42.6 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=/Library/Java/JavaVirtualMachines/liberica-jdk-17.jdk/Contents/Home ./gradlew --quiet :pgjdbc:compileJava :pgjdbc:compileTestJava - JAVA_HOME=/Library/Java/JavaVirtualMachines/liberica-jdk-17.jdk/Contents/Home ./gradlew --quiet :pgjdbc:test --tests org.postgresql.jdbc.ScramTest -x :pgjdbc-osgi-test:test - JAVA_HOME=/Library/Java/JavaVirtualMachines/liberica-jdk-17.jdk/Contents/Home ./gradlew --quiet :pgjdbc:checkstyleMain :pgjdbc:checkstyleTest - git diff --check diff --git a/CHANGELOG.md b/CHANGELOG.md index bb0cb7e3d9..09f165bbdf 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 ### Fixed diff --git a/docs/content/documentation/use.md b/docs/content/documentation/use.md index 0925eed322..7fea9bf9c2 100644 --- a/docs/content/documentation/use.md +++ b/docs/content/documentation/use.md @@ -424,6 +424,12 @@ By default, this is set to `true`, server error details are propagated. This may Quote returning columns. There are some ORM's that quote everything, including returning columns If we quote them, then we end up sending ""colname"" to the backend instead of "colname" which will not be found. +* **`scramMaxIterations (`*int*`)`** *Default `100000`*\ +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*`)`** *Default `null`*\ Fully qualified class name of the class implementing the AuthenticationPlugin interface. If this is null, the password value in the connection properties will be used. diff --git a/pgjdbc/src/main/java/org/postgresql/PGProperty.java b/pgjdbc/src/main/java/org/postgresql/PGProperty.java index 4ee9310ac4..f0eea01d9c 100644 --- a/pgjdbc/src/main/java/org/postgresql/PGProperty.java +++ b/pgjdbc/src/main/java/org/postgresql/PGProperty.java @@ -554,6 +554,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 1e5c23cad2..34455a9954 100644 --- a/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java +++ b/pgjdbc/src/main/java/org/postgresql/core/v3/ConnectionFactoryImpl.java @@ -832,6 +832,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( @@ -845,7 +852,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 5b499d2d8c..2a7034d4d7 100644 --- a/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java +++ b/pgjdbc/src/main/java/org/postgresql/ds/common/BaseDataSource.java @@ -1235,6 +1235,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 6dabe1041d..88477f9425 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 ScramSession.@Nullable 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 b337875011..657e09a798 100644 --- a/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java +++ b/pgjdbc/src/test/java/org/postgresql/jdbc/ScramTest.java @@ -15,10 +15,12 @@ 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; @@ -29,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; @@ -125,6 +128,75 @@ void testInvalidPasswords(String password, String expectedMessage) throws SQLExc } } + private PSQLException scramAuthExpectingFailure(String scramMaxIterations, + int serverScramIterations, String password) throws SQLException { + createRoleWithCustomScramIters(serverScramIterations); + Properties props = new Properties(); + PGProperty.USER.set(props, ROLE_NAME); + PGProperty.PASSWORD.set(props, 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()); + 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 = new Properties(); + PGProperty.USER.set(props, ROLE_NAME); + PGProperty.PASSWORD.set(props, password); + PGProperty.SCRAM_MAX_ITERATIONS.set(props, "1234"); + PSQLException ex = assertThrows(PSQLException.class, () -> TestUtil.openDB(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 = new Properties(); + PGProperty.USER.set(props, ROLE_NAME); + PGProperty.PASSWORD.set(props, 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'"); @@ -133,4 +205,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 + "'"); + } + }