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',