From 8dab520e0a604d2c80998bc3bc7a4e0e0e9e2d6b Mon Sep 17 00:00:00 2001 From: Sehrope Sarkuni Date: Mon, 11 May 2026 16:55:42 -0400 Subject: [PATCH] Add scramMaxIterations option to limit SCRAM iteration count Caps the number of SCRAM iterations the driver will perform during SASL auth, defaulting to 100000. Protects against malicious or misconfigured servers requesting unbounded PBKDF2 work. A value of zero disables the check entirely. --- packages/pg/lib/client.js | 18 ++++- packages/pg/lib/crypto/sasl.js | 18 ++++- .../pg/test/unit/client/sasl-scram-tests.js | 74 +++++++++++++++++++ 3 files changed, 108 insertions(+), 2 deletions(-) diff --git a/packages/pg/lib/client.js b/packages/pg/lib/client.js index 3525cf5ac..d6c57194c 100644 --- a/packages/pg/lib/client.js +++ b/packages/pg/lib/client.js @@ -36,6 +36,17 @@ const queryQueueLengthDeprecationNotice = nodeUtils.deprecate( 'Calling client.query() when the client is already executing a query is deprecated and will be removed in pg@9.0. Use async/await or an external async flow control mechanism instead.' ) +function coerceNumberOrDefault(value, defaultValue) { + if (typeof value === 'number') { + return Number.isFinite(value) ? value : defaultValue + } + if (typeof value === 'string' && value.trim() !== '') { + const n = Number(value) + return Number.isFinite(n) ? n : defaultValue + } + return defaultValue +} + class Client extends EventEmitter { constructor(config) { super() @@ -74,6 +85,7 @@ class Client extends EventEmitter { this._txStatus = null this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered + this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS) this.connection = c.connection || new Connection({ @@ -307,7 +319,11 @@ class Client extends EventEmitter { _handleAuthSASL(msg) { this._getPassword(() => { try { - this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream) + this.saslSession = sasl.startSession( + msg.mechanisms, + this.enableChannelBinding && this.connection.stream, + this.scramMaxIterations + ) this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response) } catch (err) { this.connection.emit('error', err) diff --git a/packages/pg/lib/crypto/sasl.js b/packages/pg/lib/crypto/sasl.js index 39af4e4cf..ea63b2413 100644 --- a/packages/pg/lib/crypto/sasl.js +++ b/packages/pg/lib/crypto/sasl.js @@ -30,7 +30,9 @@ function saslprep(password) { return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC') } -function startSession(mechanisms, stream) { +const DEFAULT_MAX_SCRAM_ITERATIONS = 100000 + +function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) { const candidates = ['SCRAM-SHA-256'] if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first @@ -53,6 +55,7 @@ function startSession(mechanisms, stream) { clientNonce, response: gs2Header + ',,n=*,r=' + clientNonce, message: 'SASLInitialResponse', + scramMaxIterations, } } @@ -78,6 +81,18 @@ async function continueSession(session, password, serverData, stream) { throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short') } + const scramMaxIterations = + typeof session.scramMaxIterations === 'number' ? session.scramMaxIterations : DEFAULT_MAX_SCRAM_ITERATIONS + // a value of 0 disables the iteration count check + if (scramMaxIterations !== 0 && sv.iteration > scramMaxIterations) { + throw new Error( + 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count ' + + sv.iteration + + ' exceeds scramMaxIterations of ' + + scramMaxIterations + ) + } + const clientFirstMessageBare = 'n=*,r=' + session.clientNonce const serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration @@ -243,4 +258,5 @@ module.exports = { startSession, continueSession, finalizeSession, + DEFAULT_MAX_SCRAM_ITERATIONS, } diff --git a/packages/pg/test/unit/client/sasl-scram-tests.js b/packages/pg/test/unit/client/sasl-scram-tests.js index fc75a748a..02b0d4e6d 100644 --- a/packages/pg/test/unit/client/sasl-scram-tests.js +++ b/packages/pg/test/unit/client/sasl-scram-tests.js @@ -55,6 +55,18 @@ suite.test('sasl/scram', function () { assert(session1.clientNonce != session2.clientNonce) }) + + suite.test('defaults scramMaxIterations to 100000', function () { + const session = sasl.startSession(['SCRAM-SHA-256']) + + assert.equal(session.scramMaxIterations, 100000) + }) + + suite.test('honors a custom scramMaxIterations', function () { + const session = sasl.startSession(['SCRAM-SHA-256'], null, 50) + + assert.equal(session.scramMaxIterations, 50) + }) }) suite.test('continueSession', function () { @@ -159,6 +171,68 @@ suite.test('sasl/scram', function () { ) }) + suite.test('fails when iteration count exceeds default scramMaxIterations', async function () { + await assert.rejects( + function () { + return sasl.continueSession( + { + message: 'SASLInitialResponse', + clientNonce: 'a', + scramMaxIterations: 100000, + }, + 'password', + 'r=ab,s=abcd,i=100001' + ) + }, + { + message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 100001 exceeds scramMaxIterations of 100000', + } + ) + }) + + suite.test('fails when iteration count exceeds a custom scramMaxIterations', async function () { + await assert.rejects( + function () { + return sasl.continueSession( + { + message: 'SASLInitialResponse', + clientNonce: 'a', + scramMaxIterations: 10, + }, + 'password', + 'r=ab,s=abcd,i=11' + ) + }, + { + message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 11 exceeds scramMaxIterations of 10', + } + ) + }) + + suite.test('allows iteration count at the scramMaxIterations limit', async function () { + const session = { + message: 'SASLInitialResponse', + clientNonce: 'a', + scramMaxIterations: 5, + } + + await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=5') + + assert.equal(session.message, 'SASLResponse') + }) + + suite.test('disables the iteration count check when scramMaxIterations is 0', async function () { + const session = { + message: 'SASLInitialResponse', + clientNonce: 'a', + scramMaxIterations: 0, + } + + await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=999999') + + assert.equal(session.message, 'SASLResponse') + }) + suite.test('sets expected session data (SCRAM-SHA-256)', async function () { const session = { message: 'SASLInitialResponse',