Skip to content

Commit cc03fa5

Browse files
authored
Add scramMaxIterations option to limit SCRAM iteration count (#3677)
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.
1 parent f776327 commit cc03fa5

3 files changed

Lines changed: 108 additions & 2 deletions

File tree

packages/pg/lib/client.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,17 @@ const queryQueueLengthDeprecationNotice = nodeUtils.deprecate(
3636
'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.'
3737
)
3838

39+
function coerceNumberOrDefault(value, defaultValue) {
40+
if (typeof value === 'number') {
41+
return Number.isFinite(value) ? value : defaultValue
42+
}
43+
if (typeof value === 'string' && value.trim() !== '') {
44+
const n = Number(value)
45+
return Number.isFinite(n) ? n : defaultValue
46+
}
47+
return defaultValue
48+
}
49+
3950
class Client extends EventEmitter {
4051
constructor(config) {
4152
super()
@@ -74,6 +85,7 @@ class Client extends EventEmitter {
7485
this._txStatus = null
7586

7687
this.enableChannelBinding = Boolean(c.enableChannelBinding) // set true to use SCRAM-SHA-256-PLUS when offered
88+
this.scramMaxIterations = coerceNumberOrDefault(c.scramMaxIterations, sasl.DEFAULT_MAX_SCRAM_ITERATIONS)
7789
this.connection =
7890
c.connection ||
7991
new Connection({
@@ -307,7 +319,11 @@ class Client extends EventEmitter {
307319
_handleAuthSASL(msg) {
308320
this._getPassword(() => {
309321
try {
310-
this.saslSession = sasl.startSession(msg.mechanisms, this.enableChannelBinding && this.connection.stream)
322+
this.saslSession = sasl.startSession(
323+
msg.mechanisms,
324+
this.enableChannelBinding && this.connection.stream,
325+
this.scramMaxIterations
326+
)
311327
this.connection.sendSASLInitialResponseMessage(this.saslSession.mechanism, this.saslSession.response)
312328
} catch (err) {
313329
this.connection.emit('error', err)

packages/pg/lib/crypto/sasl.js

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ function saslprep(password) {
3030
return password.replace(nonAsciiSpace, ' ').replace(mappedToNothing, '').normalize('NFKC')
3131
}
3232

33-
function startSession(mechanisms, stream) {
33+
const DEFAULT_MAX_SCRAM_ITERATIONS = 100000
34+
35+
function startSession(mechanisms, stream, scramMaxIterations = DEFAULT_MAX_SCRAM_ITERATIONS) {
3436
const candidates = ['SCRAM-SHA-256']
3537
if (stream) candidates.unshift('SCRAM-SHA-256-PLUS') // higher-priority, so placed first
3638

@@ -53,6 +55,7 @@ function startSession(mechanisms, stream) {
5355
clientNonce,
5456
response: gs2Header + ',,n=*,r=' + clientNonce,
5557
message: 'SASLInitialResponse',
58+
scramMaxIterations,
5659
}
5760
}
5861

@@ -78,6 +81,18 @@ async function continueSession(session, password, serverData, stream) {
7881
throw new Error('SASL: SCRAM-SERVER-FIRST-MESSAGE: server nonce is too short')
7982
}
8083

84+
const scramMaxIterations =
85+
typeof session.scramMaxIterations === 'number' ? session.scramMaxIterations : DEFAULT_MAX_SCRAM_ITERATIONS
86+
// a value of 0 disables the iteration count check
87+
if (scramMaxIterations !== 0 && sv.iteration > scramMaxIterations) {
88+
throw new Error(
89+
'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count ' +
90+
sv.iteration +
91+
' exceeds scramMaxIterations of ' +
92+
scramMaxIterations
93+
)
94+
}
95+
8196
const clientFirstMessageBare = 'n=*,r=' + session.clientNonce
8297
const serverFirstMessage = 'r=' + sv.nonce + ',s=' + sv.salt + ',i=' + sv.iteration
8398

@@ -243,4 +258,5 @@ module.exports = {
243258
startSession,
244259
continueSession,
245260
finalizeSession,
261+
DEFAULT_MAX_SCRAM_ITERATIONS,
246262
}

packages/pg/test/unit/client/sasl-scram-tests.js

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,18 @@ suite.test('sasl/scram', function () {
5555

5656
assert(session1.clientNonce != session2.clientNonce)
5757
})
58+
59+
suite.test('defaults scramMaxIterations to 100000', function () {
60+
const session = sasl.startSession(['SCRAM-SHA-256'])
61+
62+
assert.equal(session.scramMaxIterations, 100000)
63+
})
64+
65+
suite.test('honors a custom scramMaxIterations', function () {
66+
const session = sasl.startSession(['SCRAM-SHA-256'], null, 50)
67+
68+
assert.equal(session.scramMaxIterations, 50)
69+
})
5870
})
5971

6072
suite.test('continueSession', function () {
@@ -159,6 +171,68 @@ suite.test('sasl/scram', function () {
159171
)
160172
})
161173

174+
suite.test('fails when iteration count exceeds default scramMaxIterations', async function () {
175+
await assert.rejects(
176+
function () {
177+
return sasl.continueSession(
178+
{
179+
message: 'SASLInitialResponse',
180+
clientNonce: 'a',
181+
scramMaxIterations: 100000,
182+
},
183+
'password',
184+
'r=ab,s=abcd,i=100001'
185+
)
186+
},
187+
{
188+
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 100001 exceeds scramMaxIterations of 100000',
189+
}
190+
)
191+
})
192+
193+
suite.test('fails when iteration count exceeds a custom scramMaxIterations', async function () {
194+
await assert.rejects(
195+
function () {
196+
return sasl.continueSession(
197+
{
198+
message: 'SASLInitialResponse',
199+
clientNonce: 'a',
200+
scramMaxIterations: 10,
201+
},
202+
'password',
203+
'r=ab,s=abcd,i=11'
204+
)
205+
},
206+
{
207+
message: 'SASL: SCRAM-SERVER-FIRST-MESSAGE: iteration count 11 exceeds scramMaxIterations of 10',
208+
}
209+
)
210+
})
211+
212+
suite.test('allows iteration count at the scramMaxIterations limit', async function () {
213+
const session = {
214+
message: 'SASLInitialResponse',
215+
clientNonce: 'a',
216+
scramMaxIterations: 5,
217+
}
218+
219+
await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=5')
220+
221+
assert.equal(session.message, 'SASLResponse')
222+
})
223+
224+
suite.test('disables the iteration count check when scramMaxIterations is 0', async function () {
225+
const session = {
226+
message: 'SASLInitialResponse',
227+
clientNonce: 'a',
228+
scramMaxIterations: 0,
229+
}
230+
231+
await sasl.continueSession(session, 'password', 'r=ab,s=abcd,i=999999')
232+
233+
assert.equal(session.message, 'SASLResponse')
234+
})
235+
162236
suite.test('sets expected session data (SCRAM-SHA-256)', async function () {
163237
const session = {
164238
message: 'SASLInitialResponse',

0 commit comments

Comments
 (0)